border-customs 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +224 -18
- package/dist/assets/exfil-src/rules.ts +1 -1
- package/dist/assets/exfil-src/rulesPath.ts +28 -0
- package/dist/assets/exfil-src/scan.ts +2 -0
- package/dist/assets/exfil-src/severity.ts +7 -3
- package/dist/assets/gitleaks-defaults-v8.30.1.toml +24 -0
- package/dist/assets/package-lock.json +69 -12
- package/dist/assets/residue-src/crates.ts +60 -31
- package/dist/assets/residue-src/npm.ts +35 -8
- package/dist/assets/residue-src/pypi.ts +31 -11
- package/dist/assets/residue-src/rubygems.ts +43 -20
- package/dist/index.js +2617 -4041
- package/package.json +9 -6
- package/plugin/border.ts +33 -9
|
@@ -35,11 +35,13 @@ import {
|
|
|
35
35
|
NPM_PRIVATE_RULE,
|
|
36
36
|
NPM_TARGET_LABEL,
|
|
37
37
|
NPM_UNEXPECTED_RULE,
|
|
38
|
-
packOnce,
|
|
38
|
+
packOnce, sandboxPackOnce,
|
|
39
39
|
publintFinding,
|
|
40
40
|
sanitizeOut,
|
|
41
41
|
tail,
|
|
42
42
|
} from "./npmPack.ts";
|
|
43
|
+
import { realDockerExec } from "../roundtrip/docker.ts";
|
|
44
|
+
import type { DockerExec } from "../roundtrip/docker.ts";
|
|
43
45
|
|
|
44
46
|
export { NPM_LIFECYCLE_RULE, NPM_PUBLINT_RULE, NPM_PRIVATE_RULE, NPM_TARGET_LABEL, NPM_UNEXPECTED_RULE };
|
|
45
47
|
|
|
@@ -64,11 +66,15 @@ export type NpmStageOptions = {
|
|
|
64
66
|
readonly timeoutMs?: number;
|
|
65
67
|
readonly npmTimeoutMs?: number;
|
|
66
68
|
readonly publintTimeoutMs?: number;
|
|
69
|
+
/** W4 sandbox seam (only consulted when targets.npm.sandboxImage is set). */
|
|
70
|
+
readonly sandboxExec?: DockerExec;
|
|
67
71
|
};
|
|
68
72
|
|
|
69
73
|
export type NpmStageResult = {
|
|
70
74
|
readonly findings: readonly Finding[];
|
|
71
75
|
readonly artifact: NpmArtifactRecord | null;
|
|
76
|
+
/** Observed in-image toolchain versions (sandbox runs only) — record binding. */
|
|
77
|
+
readonly toolchain?: string;
|
|
72
78
|
};
|
|
73
79
|
|
|
74
80
|
function nativeFinding(
|
|
@@ -169,13 +175,33 @@ export async function runNpmArtifactStage(o: NpmStageOptions): Promise<NpmStageR
|
|
|
169
175
|
const stateDir = join(repoDir, BORDER_STATE_DIR);
|
|
170
176
|
const distDir = join(stateDir, "dist");
|
|
171
177
|
mkdirSync(distDir, { recursive: true });
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
178
|
+
// W4: sandboxImage configured ⇒ the pack runs in the pinned container and
|
|
179
|
+
// the host npm is never touched; otherwise the host path (npm-version guard
|
|
180
|
+
// inside packOnce) builds exactly the same bytes recipe.
|
|
181
|
+
const sandboxImage = o.cfg.targets.npm.sandboxImage;
|
|
182
|
+
let filename: string;
|
|
183
|
+
let sha256: string;
|
|
184
|
+
let bytes: number;
|
|
185
|
+
let tarballAbs: string;
|
|
186
|
+
let stageToolchain: string | undefined;
|
|
187
|
+
if (sandboxImage !== undefined) {
|
|
188
|
+
const run = sandboxPackOnce({
|
|
189
|
+
repoDir,
|
|
190
|
+
distDir,
|
|
191
|
+
spec: { image: sandboxImage, docker: o.sandboxExec ?? realDockerExec() },
|
|
192
|
+
});
|
|
193
|
+
({ filename, sha256, bytes, tarballAbs } = run.outcome);
|
|
194
|
+
stageToolchain = run.toolchain;
|
|
195
|
+
} else {
|
|
196
|
+
const packed = packOnce({
|
|
197
|
+
repoDir,
|
|
198
|
+
distDir,
|
|
199
|
+
...(o.env !== undefined ? { env: o.env } : {}),
|
|
200
|
+
...(o.npmBinPath !== undefined ? { npmBinPath: o.npmBinPath } : {}),
|
|
201
|
+
...(o.npmTimeoutMs !== undefined ? { npmTimeoutMs: o.npmTimeoutMs } : {}),
|
|
202
|
+
});
|
|
203
|
+
({ filename, sha256, bytes, tarballAbs } = packed);
|
|
204
|
+
}
|
|
179
205
|
|
|
180
206
|
const findings: Finding[] = [];
|
|
181
207
|
const extractDir = join(stateDir, "tmp", sha256.slice(0, 8));
|
|
@@ -304,5 +330,6 @@ export async function runNpmArtifactStage(o: NpmStageOptions): Promise<NpmStageR
|
|
|
304
330
|
return {
|
|
305
331
|
findings,
|
|
306
332
|
artifact: { file: `${BORDER_STATE_DIR}/dist/${filename}`, sha256, bytes },
|
|
333
|
+
...(stageToolchain !== undefined ? { toolchain: stageToolchain } : {}),
|
|
307
334
|
};
|
|
308
335
|
}
|
|
@@ -52,6 +52,8 @@ import {
|
|
|
52
52
|
spawnEngine,
|
|
53
53
|
type EngineOptions,
|
|
54
54
|
} from "../engines/support.ts";
|
|
55
|
+
import { sandboxRun } from "./sandbox.ts";
|
|
56
|
+
import { realDockerExec, type DockerExec } from "../roundtrip/docker.ts";
|
|
55
57
|
import { redact, type TextSanitizer } from "../redact.ts";
|
|
56
58
|
|
|
57
59
|
/** Prerequisite failure — the CLI maps it to border exit 2 (library never process.exits). */
|
|
@@ -70,8 +72,12 @@ export type PypiInput = EngineOptions & {
|
|
|
70
72
|
/** degraded-engine inheritance (todo 10 policy): the check pipeline skips the leg whose engine failed its probe. */
|
|
71
73
|
readonly skipGitleaks?: boolean;
|
|
72
74
|
readonly skipSecretlint?: boolean;
|
|
75
|
+
/** W4: digest-pinned image (from targets.pypi.sandboxImage); set ⇒ the
|
|
76
|
+
* repo-chosen PEP 517 backend executes ONLY inside the container. */
|
|
77
|
+
readonly sandboxImage?: string;
|
|
78
|
+
readonly sandboxExec?: DockerExec;
|
|
73
79
|
};
|
|
74
|
-
export type PypiScanResult = { readonly artifacts: readonly PypiArtifact[]; readonly findings: readonly Finding[] };
|
|
80
|
+
export type PypiScanResult = { readonly artifacts: readonly PypiArtifact[]; readonly findings: readonly Finding[]; readonly toolchain?: string };
|
|
75
81
|
|
|
76
82
|
const ANSI_RE = /\u001B\[[0-9;]*m/g;
|
|
77
83
|
/** Names setuptools emits into the sdist that no source declaration lists (whitelisted metadata). */
|
|
@@ -102,8 +108,8 @@ function requireModule(o: EngineOptions, module: "build" | "twine"): void {
|
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
/** build-once (G38): wipe stale dist, run `python3 -m build`, content-address every produced artifact. */
|
|
105
|
-
export function buildPypiArtifacts(o: PypiInput): { readonly artifacts: readonly PypiArtifact[]; readonly distDir: string } {
|
|
106
|
-
requireModule(o, "build");
|
|
111
|
+
export function buildPypiArtifacts(o: PypiInput): { readonly artifacts: readonly PypiArtifact[]; readonly distDir: string; readonly toolchain?: string } {
|
|
112
|
+
if (o.sandboxImage === undefined) requireModule(o, "build");
|
|
107
113
|
const repoDir = resolve(o.repoDir);
|
|
108
114
|
if (!existsSync(join(repoDir, "pyproject.toml"))) {
|
|
109
115
|
throw new PypiPrerequisiteError(`no pyproject.toml in the repo root — the PyPI pipeline builds from pyproject's own [build-system]`);
|
|
@@ -121,9 +127,21 @@ export function buildPypiArtifacts(o: PypiInput): { readonly artifacts: readonly
|
|
|
121
127
|
for (const name of readdirSync(distDir)) {
|
|
122
128
|
if (name.endsWith(".tar.gz") || name.endsWith(".whl")) rmSync(join(distDir, name), { force: true });
|
|
123
129
|
}
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
|
|
130
|
+
let sandboxToolchain: string | undefined;
|
|
131
|
+
if (o.sandboxImage !== undefined) {
|
|
132
|
+
const run = sandboxRun({
|
|
133
|
+
spec: { image: o.sandboxImage, docker: o.sandboxExec ?? realDockerExec() },
|
|
134
|
+
repoDir,
|
|
135
|
+
outDir: distDir,
|
|
136
|
+
laneProbes: ["python3"],
|
|
137
|
+
laneScript: "python3 -m build --no-isolation --outdir /audit/out .",
|
|
138
|
+
});
|
|
139
|
+
sandboxToolchain = run.toolchain;
|
|
140
|
+
} else {
|
|
141
|
+
const out = spawnEngine(binaryCandidates("python3", o), ["-m", "build", "--no-isolation", "--outdir", distDir, repoDir], o);
|
|
142
|
+
if (out.status !== 0) {
|
|
143
|
+
throw new EngineRunError(`\`python3 -m build\` exited ${String(out.status)} — no artifacts to gate (border exit 2). stderr: ${tail(out.stderr)}`, out.status);
|
|
144
|
+
}
|
|
127
145
|
}
|
|
128
146
|
const names = readdirSync(distDir).filter((n) => n.endsWith(".tar.gz") || n.endsWith(".whl")).sort();
|
|
129
147
|
if (names.length === 0) {
|
|
@@ -133,7 +151,7 @@ export function buildPypiArtifacts(o: PypiInput): { readonly artifacts: readonly
|
|
|
133
151
|
const path = join(distDir, name);
|
|
134
152
|
return { path, kind: name.endsWith(".whl") ? "wheel" : "sdist", sha256: createHash("sha256").update(readFileSync(path)).digest("hex") };
|
|
135
153
|
});
|
|
136
|
-
return { artifacts, distDir };
|
|
154
|
+
return { artifacts, distDir, ...(sandboxToolchain !== undefined ? { toolchain: sandboxToolchain } : {}) };
|
|
137
155
|
}
|
|
138
156
|
|
|
139
157
|
function walkFilesRel(root: string): string[] {
|
|
@@ -277,12 +295,14 @@ function twineCheck(o: PypiInput, artifacts: readonly PypiArtifact[], target: st
|
|
|
277
295
|
|
|
278
296
|
/** Full todo-12 pipeline: prereqs → build → extract-scan (both artifacts, deduped) → manifest-diff → twine. */
|
|
279
297
|
export async function scanPyPiArtifacts(o: PypiInput): Promise<PypiScanResult> {
|
|
280
|
-
|
|
281
|
-
|
|
298
|
+
if (o.sandboxImage === undefined) {
|
|
299
|
+
requireModule(o, "build");
|
|
300
|
+
requireModule(o, "twine");
|
|
301
|
+
}
|
|
282
302
|
const repoDir = resolve(o.repoDir);
|
|
283
303
|
const stateDir = resolve(o.stateDir ?? join(repoDir, ".border"));
|
|
284
304
|
const target = o.target ?? "artifact";
|
|
285
|
-
const { artifacts } = buildPypiArtifacts(o);
|
|
305
|
+
const { artifacts, toolchain: pypiToolchain } = buildPypiArtifacts(o);
|
|
286
306
|
const tmpParent = join(stateDir, "tmp");
|
|
287
307
|
mkdirSync(tmpParent, { recursive: true });
|
|
288
308
|
|
|
@@ -349,5 +369,5 @@ export async function scanPyPiArtifacts(o: PypiInput): Promise<PypiScanResult> {
|
|
|
349
369
|
// non-empty ⇒ a concurrent run owns sandboxes here; leave for the .border/tmp GC
|
|
350
370
|
}
|
|
351
371
|
}
|
|
352
|
-
return { artifacts, findings: [...merged.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([, f]) => f) };
|
|
372
|
+
return { artifacts, findings: [...merged.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([, f]) => f), ...(pypiToolchain !== undefined ? { toolchain: pypiToolchain } : {}) };
|
|
353
373
|
}
|
|
@@ -36,6 +36,8 @@ import { gemCoherenceFindings } from "../rules/releaseCoherence.ts";
|
|
|
36
36
|
import { scanTree } from "../engines/gitleaks.ts";
|
|
37
37
|
import { scanPaths, type SecretlintMode } from "../engines/secretlint.ts";
|
|
38
38
|
import { EngineMissingError, EngineRunError, type EngineOptions } from "../engines/support.ts";
|
|
39
|
+
import { sandboxRun, shQuote } from "./sandbox.ts";
|
|
40
|
+
import { realDockerExec, type DockerExec } from "../roundtrip/docker.ts";
|
|
39
41
|
import type { TextSanitizer } from "../redact.ts";
|
|
40
42
|
import { ConfigError } from "../channels/errors.ts";
|
|
41
43
|
import { selectGemspec } from "../channels/gemspec.ts";
|
|
@@ -63,6 +65,8 @@ export type GemStageOptions = {
|
|
|
63
65
|
readonly secretlintMode?: SecretlintMode;
|
|
64
66
|
/** Exact gem binary seam (tests / pinned toolchains); default "gem" on PATH. */
|
|
65
67
|
readonly gemBinPath?: string;
|
|
68
|
+
/** W4 sandbox seam (only consulted when targets.rubygems.sandboxImage is set). */
|
|
69
|
+
readonly sandboxExec?: DockerExec;
|
|
66
70
|
/** shared engine/extract timeout. */
|
|
67
71
|
readonly timeoutMs?: number;
|
|
68
72
|
readonly gemTimeoutMs?: number;
|
|
@@ -71,6 +75,8 @@ export type GemStageOptions = {
|
|
|
71
75
|
export type GemStageResult = {
|
|
72
76
|
readonly findings: readonly Finding[];
|
|
73
77
|
readonly artifact: GemArtifactRecord | null;
|
|
78
|
+
/** Observed in-image toolchain versions (sandbox runs only) — record binding. */
|
|
79
|
+
readonly toolchain?: string;
|
|
74
80
|
};
|
|
75
81
|
|
|
76
82
|
function scopeFindingPath(f: Finding, extractDir: string): Finding {
|
|
@@ -157,15 +163,18 @@ export async function runRubygemsArtifactStage(o: GemStageOptions): Promise<GemS
|
|
|
157
163
|
}
|
|
158
164
|
const gemName = `${identity.name}-${identity.version}.gem`;
|
|
159
165
|
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
const sandboxImage = o.cfg.targets.rubygems.sandboxImage;
|
|
167
|
+
if (sandboxImage === undefined) {
|
|
168
|
+
const versionCheck = spawnSync(bin, ["--version"], {
|
|
169
|
+
encoding: "utf8",
|
|
170
|
+
stdio: "ignore",
|
|
171
|
+
...(o.env === undefined ? {} : { env: { ...o.env } }),
|
|
172
|
+
});
|
|
173
|
+
if (versionCheck.error !== undefined || versionCheck.status !== 0) {
|
|
174
|
+
throw new EngineMissingError(
|
|
175
|
+
`gem (RubyGems) is not available (\`\${bin} --version\` failed) — install it (apt-get install ruby-full on Debian/Ubuntu, or via rbenv/rvm) or pass gemBinPath before running border check (border exit 2)`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
169
178
|
}
|
|
170
179
|
|
|
171
180
|
ensureStateDir(repoDir);
|
|
@@ -174,17 +183,30 @@ export async function runRubygemsArtifactStage(o: GemStageOptions): Promise<GemS
|
|
|
174
183
|
mkdirSync(distDir, { recursive: true });
|
|
175
184
|
|
|
176
185
|
const dist = join(distDir, gemName);
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
`gem build
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
let stageToolchain: string | undefined;
|
|
187
|
+
if (sandboxImage !== undefined) {
|
|
188
|
+
const relGemspec = relative(repoDir, (candidates[0] as { path: string }).path);
|
|
189
|
+
const run = sandboxRun({
|
|
190
|
+
spec: { image: sandboxImage, docker: o.sandboxExec ?? realDockerExec() },
|
|
191
|
+
repoDir,
|
|
192
|
+
outDir: distDir,
|
|
193
|
+
laneProbes: ["gem", "ruby"],
|
|
194
|
+
laneScript: `gem build ${shQuote(relGemspec)} -o ${shQuote("/audit/out/" + gemName)}`,
|
|
195
|
+
});
|
|
196
|
+
stageToolchain = run.toolchain;
|
|
197
|
+
} else {
|
|
198
|
+
const result = spawnSync(bin, ["build", (candidates[0] as { path: string }).path, "-o", dist], {
|
|
199
|
+
cwd: repoDir,
|
|
200
|
+
encoding: "utf8",
|
|
201
|
+
timeout: o.gemTimeoutMs ?? 120_000,
|
|
202
|
+
...(o.env === undefined ? {} : { env: { ...o.env } }),
|
|
203
|
+
});
|
|
204
|
+
if (result.error !== undefined || result.status !== 0) {
|
|
205
|
+
throw new EngineRunError(
|
|
206
|
+
`gem build failed (exit ${String(result.status)}) — nothing to gate: ${(result.stderr ?? "").trim().slice(0, 400)}`,
|
|
207
|
+
result.status,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
188
210
|
}
|
|
189
211
|
if (!existsSync(dist)) {
|
|
190
212
|
throw new EngineRunError(`gem build produced no ${gemName} in ${relative(repoDir, distDir)} — failing closed (was the gemspec's literal name/version re-read?)`, null);
|
|
@@ -286,5 +308,6 @@ export async function runRubygemsArtifactStage(o: GemStageOptions): Promise<GemS
|
|
|
286
308
|
return {
|
|
287
309
|
findings,
|
|
288
310
|
artifact: { file: `${BORDER_STATE_DIR}/dist/${gemName}`, sha256, bytes: bytes.length },
|
|
311
|
+
...(stageToolchain !== undefined ? { toolchain: stageToolchain } : {}),
|
|
289
312
|
};
|
|
290
313
|
}
|