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.
@@ -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
- const { filename, sha256, bytes, tarballAbs } = packOnce({
173
- repoDir,
174
- distDir,
175
- ...(o.env !== undefined ? { env: o.env } : {}),
176
- ...(o.npmBinPath !== undefined ? { npmBinPath: o.npmBinPath } : {}),
177
- ...(o.npmTimeoutMs !== undefined ? { npmTimeoutMs: o.npmTimeoutMs } : {}),
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
- const out = spawnEngine(binaryCandidates("python3", o), ["-m", "build", "--no-isolation", "--outdir", distDir, repoDir], o);
125
- if (out.status !== 0) {
126
- throw new EngineRunError(`\`python3 -m build\` exited ${String(out.status)} — no artifacts to gate (border exit 2). stderr: ${tail(out.stderr)}`, out.status);
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
- requireModule(o, "build");
281
- requireModule(o, "twine");
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 versionCheck = spawnSync(bin, ["--version"], {
161
- encoding: "utf8",
162
- stdio: "ignore",
163
- ...(o.env === undefined ? {} : { env: { ...o.env } }),
164
- });
165
- if (versionCheck.error !== undefined || versionCheck.status !== 0) {
166
- throw new EngineMissingError(
167
- `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)`,
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
- const result = spawnSync(bin, ["build", (candidates[0] as { path: string }).path, "-o", dist], {
178
- cwd: repoDir,
179
- encoding: "utf8",
180
- timeout: o.gemTimeoutMs ?? 120_000,
181
- ...(o.env === undefined ? {} : { env: { ...o.env } }),
182
- });
183
- if (result.error !== undefined || result.status !== 0) {
184
- throw new EngineRunError(
185
- `gem build failed (exit ${String(result.status)}) — nothing to gate: ${(result.stderr ?? "").trim().slice(0, 400)}`,
186
- result.status,
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
  }