border-customs 0.1.0 → 0.3.0

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.
@@ -0,0 +1,294 @@
1
+ // provenance: original clean-room implementation per .omo/plans/border-push-gate.md todo 11
2
+ //
3
+ // npm pack-once artifact pipeline (plan G38/G39/G33/G23). ONE `npm pack` into
4
+ // <repo>/.border/dist/ is the single source of bytes for the whole gate chain:
5
+ // the recorded {file, sha256, bytes} is what todo 17 re-hashes before publish
6
+ // and todo 14 pins in the ledger — nothing downstream rebuilds. The tarball is
7
+ // extracted to a throwaway sandbox under .border/tmp/, content-scanned (gitleaks
8
+ // dir + secretlint paths), manifest-diffed against the `files` whitelist, then
9
+ // the sandbox is removed on success AND failure. publint runs against the
10
+ // tarball itself, never the working tree.
11
+ //
12
+ // PATH CONTRACT (the false-green trap, round-3 M-A): packs live under
13
+ // `.border/`, which filterBorderStateFindings hard-excludes at the user-repo
14
+ // root — so EVERY finding path emitted here is scoped to the artifact root
15
+ // (`package/<rel>`), never containing a `.border` segment. test/artifacts.npm.test.ts
16
+ // pins this end-to-end with a secret planted inside a packed file.
17
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
18
+ import { join, relative } from "node:path";
19
+
20
+ import type { BorderConfig } from "../config.ts";
21
+ import type { Finding, Severity } from "../findings.ts";
22
+ import { redact, type TextSanitizer } from "../redact.ts";
23
+ import { BORDER_STATE_DIR, ensureStateDir } from "../check/lock.ts";
24
+ import { extractArchive, removeSandbox } from "./extract.ts";
25
+ import { scanTree } from "../engines/gitleaks.ts";
26
+ import { scanPaths, type SecretlintMode } from "../engines/secretlint.ts";
27
+ import { EngineRunError, type EngineOptions } from "../engines/support.ts";
28
+ import { LIFECYCLE_SCRIPT_KEYS, parseNpmManifest, unexpectedEntries } from "./manifestDiff.ts";
29
+ import { residueFindings, scanNpmLifecycleHook, sweepResidueNpmArtifact } from "./residue.ts";
30
+ import { RESIDUE_T1_RULE } from "../rules/residueMatchers.ts";
31
+ import {
32
+ NPM_LIFECYCLE_RULE,
33
+ NPM_PUBLINT_RULE,
34
+ NPM_PRIVATE_RULE,
35
+ NPM_TARGET_LABEL,
36
+ NPM_UNEXPECTED_RULE,
37
+ packOnce,
38
+ publintFinding,
39
+ sanitizeOut,
40
+ tail,
41
+ } from "./npmPack.ts";
42
+
43
+ export { NPM_LIFECYCLE_RULE, NPM_PUBLINT_RULE, NPM_PRIVATE_RULE, NPM_TARGET_LABEL, NPM_UNEXPECTED_RULE };
44
+
45
+ export type NpmArtifactRecord = {
46
+ /** repo-relative path of the single packed tarball (`.border/dist/<file>`). */
47
+ readonly file: string;
48
+ readonly sha256: string;
49
+ readonly bytes: number;
50
+ };
51
+
52
+ export type NpmStageOptions = {
53
+ readonly repoDir: string;
54
+ readonly cfg: BorderConfig;
55
+ readonly env?: EngineOptions["env"];
56
+ readonly sanitizer?: TextSanitizer;
57
+ readonly skipGitleaks?: boolean;
58
+ readonly skipSecretlint?: boolean;
59
+ readonly secretlintMode?: SecretlintMode;
60
+ readonly npmBinPath?: string;
61
+ readonly publintBinPath?: string;
62
+ /** shared engine/extract timeout; npm/publint have their own knobs. */
63
+ readonly timeoutMs?: number;
64
+ readonly npmTimeoutMs?: number;
65
+ readonly publintTimeoutMs?: number;
66
+ };
67
+
68
+ export type NpmStageResult = {
69
+ readonly findings: readonly Finding[];
70
+ readonly artifact: NpmArtifactRecord | null;
71
+ };
72
+
73
+ function nativeFinding(
74
+ rule: string,
75
+ severity: Severity,
76
+ message: string,
77
+ path: string,
78
+ identity: string,
79
+ sanitizer: TextSanitizer | undefined,
80
+ ): Finding {
81
+ const digest = redact(identity);
82
+ return {
83
+ rule,
84
+ severity,
85
+ target: NPM_TARGET_LABEL,
86
+ path,
87
+ engine: "native",
88
+ message: sanitizeOut(sanitizer, message),
89
+ valueDigest: digest.valueDigest,
90
+ snippet: digest.snippet,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * GAP B (todo 11 wired into `border check`): re-attribute stage findings from
96
+ * artifact-root-relative (`package/<rel>`, publint's `package.json`) to the
97
+ * exact packed bytes: `<tarball>!<rel>`. The tarball basename never contains a
98
+ * `!` and carries no `.border` segment, so it survives the repo-scoped filter.
99
+ */
100
+ export function attributeToTarball(findings: readonly Finding[], artifact: NpmArtifactRecord | null): readonly Finding[] {
101
+ if (artifact === null) return findings;
102
+ const name = artifact.file.slice(artifact.file.lastIndexOf("/") + 1);
103
+ return findings.map((f) => {
104
+ const p = f.path ?? "";
105
+ const rel = p === "package.json" ? p : p.startsWith("package/") ? p.slice("package/".length) : null;
106
+ return rel === null || rel === "" ? f : { ...f, path: `${name}!${rel}` };
107
+ });
108
+ }
109
+
110
+ /** Re-scope engine findings (absolute / `<abs>!<inner>` shapes) to artifact-root-relative. */
111
+ function scopeFindingPath(f: Finding, extractDir: string): Finding {
112
+ if (f.path === undefined) return f;
113
+ const bang = f.path.indexOf("!");
114
+ if (bang > 0) {
115
+ const rel = relative(extractDir, f.path.slice(0, bang));
116
+ if (!rel.startsWith("..")) return { ...f, path: rel === "" ? f.path.slice(bang + 1) : `${rel}${f.path.slice(bang)}` };
117
+ }
118
+ const rel = relative(extractDir, f.path);
119
+ return rel === "" || rel.startsWith("..") ? f : { ...f, path: rel };
120
+ }
121
+
122
+ function listFiles(dir: string): string[] {
123
+ const out: string[] = [];
124
+ const stack = [dir];
125
+ while (stack.length > 0) {
126
+ const cur = stack.pop() as string;
127
+ for (const ent of readdirSync(cur, { withFileTypes: true })) {
128
+ const p = join(cur, ent.name);
129
+ if (ent.isDirectory()) stack.push(p);
130
+ else if (ent.isFile()) out.push(p);
131
+ }
132
+ }
133
+ return out;
134
+ }
135
+
136
+ /**
137
+ * Run the npm artifact stage. Throws EngineMissingError/EngineRunError (exit 2
138
+ * via translateError) for anything that must NOT read as "clean": npm absent,
139
+ * pack failure, unparseable manifests, digest mismatch, unusable publint.
140
+ */
141
+ export async function runNpmArtifactStage(o: NpmStageOptions): Promise<NpmStageResult> {
142
+ if (o.cfg.targets.npm === undefined) {
143
+ throw new EngineRunError("runNpmArtifactStage invoked without targets.npm configured — pipeline bug", null);
144
+ }
145
+ const { repoDir } = o;
146
+ const manifestPath = join(repoDir, "package.json");
147
+ if (!existsSync(manifestPath)) {
148
+ throw new EngineRunError("targets.npm is configured but the repo has no package.json to pack", null);
149
+ }
150
+ const manifest = parseNpmManifest(readFileSync(manifestPath, "utf8"));
151
+ const identity = `${manifest.name ?? "?"}@${manifest.version ?? "?"}`;
152
+
153
+ if (manifest.isPrivate) {
154
+ return {
155
+ findings: [nativeFinding(
156
+ NPM_PRIVATE_RULE,
157
+ "CRITICAL",
158
+ `package.json is private:true but border.yaml configures targets.npm — nothing can ever publish from this repo; drop the private flag or the npm target`,
159
+ "package.json",
160
+ `${identity}:private`,
161
+ o.sanitizer,
162
+ )],
163
+ artifact: null,
164
+ };
165
+ }
166
+
167
+ ensureStateDir(repoDir);
168
+ const stateDir = join(repoDir, BORDER_STATE_DIR);
169
+ const distDir = join(stateDir, "dist");
170
+ mkdirSync(distDir, { recursive: true });
171
+ const { filename, sha256, bytes, tarballAbs } = packOnce({
172
+ repoDir,
173
+ distDir,
174
+ ...(o.env !== undefined ? { env: o.env } : {}),
175
+ ...(o.npmBinPath !== undefined ? { npmBinPath: o.npmBinPath } : {}),
176
+ ...(o.npmTimeoutMs !== undefined ? { npmTimeoutMs: o.npmTimeoutMs } : {}),
177
+ });
178
+
179
+ const findings: Finding[] = [];
180
+ const extractDir = join(stateDir, "tmp", sha256.slice(0, 8));
181
+ rmSync(extractDir, { recursive: true, force: true }); // stale sandbox from an interrupted run
182
+ mkdirSync(extractDir, { recursive: true });
183
+ try {
184
+ extractArchive(tarballAbs, extractDir, {
185
+ ...(o.env !== undefined ? { env: o.env } : {}),
186
+ ...(o.timeoutMs !== undefined ? { timeoutMs: o.timeoutMs } : {}),
187
+ });
188
+ const relPaths = listFiles(extractDir).map((p) => relative(extractDir, p));
189
+ if (relPaths.length === 0) throw new EngineRunError("extracted artifact is empty — unusable npm pack output", null);
190
+ const root = relPaths[0]?.split("/")[0] ?? "package";
191
+ if (!relPaths.every((p) => p.startsWith(`${root}/`))) {
192
+ throw new EngineRunError("extracted artifact mixes multiple top-level roots — unexpected tar layout, failing closed", null);
193
+ }
194
+ const packedManifestRel = `${root}/package.json`;
195
+ if (!relPaths.includes(packedManifestRel)) throw new EngineRunError("extracted artifact has no package/package.json", null);
196
+ // lifecycle gate reads the PUBLISHED manifest (the bytes consumers install),
197
+ // not the working tree — npm may rewrite package.json inside the tarball.
198
+ const packedManifest = parseNpmManifest(readFileSync(join(extractDir, packedManifestRel), "utf8"));
199
+
200
+ // RESIDUE-CONTRACT §1.6/§3 (R2): a hook is downgraded to MEDIUM ONLY when the
201
+ // classifier matches closed signature #1 in full; every other outcome — unknown
202
+ // grammar, unreadable target, out-of-signature literal — keeps the 0.2.0
203
+ // lifecycle-script CRITICAL row byte-identically (gate-2 pins). Family rows
204
+ // (T2/T3/cross-manager) are emitted IN ADDITION; the gate only ever gets stricter.
205
+ const pkgDir = join(extractDir, root);
206
+ const residueCtx = { root, identity, ...(o.sanitizer !== undefined ? { sanitizer: o.sanitizer } : {}) };
207
+ for (const key of LIFECYCLE_SCRIPT_KEYS) {
208
+ const cmd = packedManifest.scripts[key];
209
+ if (cmd === undefined) continue;
210
+ const scan = scanNpmLifecycleHook({ pkgDir, hook: cmd });
211
+ if (scan.signatureId !== null) {
212
+ findings.push(nativeFinding(
213
+ RESIDUE_T1_RULE,
214
+ "MEDIUM",
215
+ `package.json script '${key}' matches closed safe signature '${scan.signatureId}' (T1: install-root writes only, frozen probe/fallback argv literals, zero network/out-of-tree/persistence/cross-manager hits) — allowed but audit-visible; any deviation keeps the blocking lifecycle-script CRITICAL`,
216
+ packedManifestRel,
217
+ `${identity}:residue:t1:${key}`,
218
+ o.sanitizer,
219
+ ));
220
+ } else {
221
+ findings.push(nativeFinding(
222
+ NPM_LIFECYCLE_RULE,
223
+ "CRITICAL",
224
+ `package.json script '${key}' is a lifecycle hook — npm executes it on every consumer install (G33): ${tail(sanitizeOut(o.sanitizer, cmd))}`,
225
+ packedManifestRel,
226
+ `${identity}:lifecycle:${key}`,
227
+ o.sanitizer,
228
+ ));
229
+ }
230
+ findings.push(...residueFindings(scan.hits, residueCtx));
231
+ }
232
+ const entries = relPaths.filter((p) => p !== packedManifestRel).map((p) => p.slice(root.length + 1));
233
+ for (const entry of unexpectedEntries(entries, manifest.files)) {
234
+ findings.push(nativeFinding(
235
+ NPM_UNEXPECTED_RULE,
236
+ "HIGH",
237
+ `packed entry '${entry}' matches no package.json files whitelist pattern and is not an always-packed name — force-packed via main/bin or default inclusion (G39); publish only intentional bytes`,
238
+ `${root}/${entry}`,
239
+ `${identity}:unexpected:${entry}`,
240
+ o.sanitizer,
241
+ ));
242
+ }
243
+
244
+ // T4 + B2 marker pair-detection are artifact-wide (contract §4/§5.3): a persistence string
245
+ // in ANY packed file blocks, even with no lifecycle hook present. NpmManifest does
246
+ // not parse `bin`, so the CLI-inverse surface is scanned over the packed bin/ tree.
247
+ const binRels = relPaths.filter((p) => p.startsWith(`${root}/bin/`));
248
+ findings.push(...residueFindings(sweepResidueNpmArtifact({ pkgDir, files: relPaths, binRels }), residueCtx));
249
+
250
+ if (o.skipGitleaks !== true) {
251
+ const tree = scanTree({
252
+ dir: extractDir,
253
+ stateDir,
254
+ target: NPM_TARGET_LABEL,
255
+ ...(o.env !== undefined ? { env: o.env } : {}),
256
+ ...(o.sanitizer !== undefined ? { sanitizer: o.sanitizer } : {}),
257
+ ...(o.timeoutMs !== undefined ? { timeoutMs: o.timeoutMs } : {}),
258
+ });
259
+ for (const f of tree) findings.push(scopeFindingPath(f, extractDir));
260
+ }
261
+ if (o.skipSecretlint !== true) {
262
+ const linted = await scanPaths({
263
+ dir: extractDir,
264
+ files: relPaths,
265
+ target: NPM_TARGET_LABEL,
266
+ rules: o.cfg.rules,
267
+ ...(o.secretlintMode !== undefined ? { mode: o.secretlintMode } : {}),
268
+ ...(o.env !== undefined ? { env: o.env } : {}),
269
+ ...(o.sanitizer !== undefined ? { sanitizer: o.sanitizer } : {}),
270
+ ...(o.timeoutMs !== undefined ? { timeoutMs: o.timeoutMs } : {}),
271
+ });
272
+ findings.push(...linted);
273
+ }
274
+ } finally {
275
+ removeSandbox(extractDir);
276
+ }
277
+
278
+ const publint = publintFinding(
279
+ {
280
+ ...(o.env !== undefined ? { env: o.env } : {}),
281
+ ...(o.publintBinPath !== undefined ? { publintBinPath: o.publintBinPath } : {}),
282
+ ...(o.publintTimeoutMs !== undefined ? { publintTimeoutMs: o.publintTimeoutMs } : {}),
283
+ ...(o.sanitizer !== undefined ? { sanitizer: o.sanitizer } : {}),
284
+ },
285
+ tarballAbs,
286
+ identity,
287
+ );
288
+ if (publint !== null) findings.push(publint);
289
+
290
+ return {
291
+ findings,
292
+ artifact: { file: `${BORDER_STATE_DIR}/dist/${filename}`, sha256, bytes },
293
+ };
294
+ }
@@ -0,0 +1,333 @@
1
+ // provenance: original clean-room implementation per .omo/plans/border-push-gate.md todo 12
2
+ //
3
+ // PyPI artifact pipeline: build-once (G38) → extract → scan → twine gate.
4
+ // build = `python3 -m build --no-isolation --outdir <repo>/.border/dist/`.
5
+ // Prerequisites `python3 -m build --version` / `python3 -m twine --version`
6
+ // are probed FIRST; a missing one is PypiPrerequisiteError (border exit 2,
7
+ // naming the module) — never a silent skip. `--no-isolation` is deliberate:
8
+ // plan todo-12 Must-NOT forbids venv creation, and the image provisioned
9
+ // build 1.6.0/twine 7.0.0/setuptools for python3.14 (todo 1). The build
10
+ // backend comes from the target pyproject.toml itself — no poetry/flit
11
+ // special-casing. dist/ is wiped before every build, so leftovers from an
12
+ // earlier run (or an attacker's pre-seeded .tar.gz) are never scanned.
13
+ // extract = wheel via `python3 -m zipfile -e`, sdist via extractArchive
14
+ // (.tar.gz → `tar -xzf`), each into a mkdtemp sandbox under .border/tmp/,
15
+ // removed in a finally-block (round-2 m3).
16
+ // scan = gitleaks scanTree + secretlint scanPaths per extracted tree, with
17
+ // finding paths RE-ANCHORED to artifact-inner relpaths (sdist wrapper
18
+ // stripped). This is load-bearing: filterBorderStateFindings(findings,
19
+ // repoDir) (src/check/exclusions.ts) silently drops any finding whose
20
+ // repo-relative path carries a `.border` segment — if we reported sandbox
21
+ // paths the whole artifact gate would false-green. Duplicate (rule, relpath,
22
+ // digest) triples across the sdist/wheel trees collapse to one finding.
23
+ // manifest = sdist entries vs `git ls-files`; entries explained by MANIFEST.in
24
+ // globs, pyproject readme/package-data/data-files strings, or build-
25
+ // generated metadata (PKG-INFO, setup.cfg stub, *.egg-info/*, ...) pass;
26
+ // anything else ⇒ HIGH `sdist-unexpected-file` — the G17/G18/G42 class:
27
+ // setuptools walks the WORKING TREE, so .gitignore never hides a planted
28
+ // file from the shipped artifact.
29
+ // twine = `python3 -m twine check --strict` over every dist file; a FAILED
30
+ // artifact ⇒ HIGH `twine-check` finding (fail = verdict, not crash); an
31
+ // unparseable non-zero exit is a tool crash ⇒ EngineRunError (exit 2).
32
+ // G23: every value flows through the engine adapters' redact() ingest; free
33
+ // text we author (twine blocks) is sanitized. sha256 of each produced
34
+ // .tar.gz/.whl is returned for the orchestrator's ctx.artifacts.pypi list
35
+ // (todo 17 wires ctx; buildPypiArtifacts is exported so the gate builds once).
36
+ import { createHash } from "node:crypto";
37
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmdirSync, rmSync } from "node:fs";
38
+ import { basename, dirname, join, relative, resolve } from "node:path";
39
+
40
+ import { extractArchive, removeSandbox } from "./extract.ts";
41
+ import { residueFindings } from "./residue.ts";
42
+ import { residuePypiHits } from "./residuePy.ts";
43
+ import type { Finding } from "../findings.ts";
44
+ import { globToRegExp } from "../rules/artifactMatchers.ts";
45
+ import { scanTree } from "../engines/gitleaks.ts";
46
+ import { scanPaths, type SecretlintRulesInput } from "../engines/secretlint.ts";
47
+ import {
48
+ binaryCandidates,
49
+ EngineMissingError,
50
+ EngineRunError,
51
+ spawnEngine,
52
+ type EngineOptions,
53
+ } from "../engines/support.ts";
54
+ import { redact, type TextSanitizer } from "../redact.ts";
55
+
56
+ /** Prerequisite failure — the CLI maps it to border exit 2 (library never process.exits). */
57
+ export class PypiPrerequisiteError extends Error {
58
+ override readonly name = "PypiPrerequisiteError";
59
+ readonly exitCode = 2 as const;
60
+ }
61
+
62
+ export type PypiArtifact = { readonly path: string; readonly kind: "sdist" | "wheel"; readonly sha256: string };
63
+ export type PypiInput = EngineOptions & {
64
+ readonly repoDir: string;
65
+ readonly stateDir?: string;
66
+ readonly sanitizer?: TextSanitizer;
67
+ readonly rules?: SecretlintRulesInput;
68
+ readonly target?: string;
69
+ /** degraded-engine inheritance (todo 10 policy): the check pipeline skips the leg whose engine failed its probe. */
70
+ readonly skipGitleaks?: boolean;
71
+ readonly skipSecretlint?: boolean;
72
+ };
73
+ export type PypiScanResult = { readonly artifacts: readonly PypiArtifact[]; readonly findings: readonly Finding[] };
74
+
75
+ const ANSI_RE = /\u001B\[[0-9;]*m/g;
76
+ /** Names setuptools emits into the sdist that no source declaration lists (whitelisted metadata). */
77
+ const GENERATED_NAMES = new Set(["PKG-INFO", "SOURCES.txt", "setup.py", "setup.cfg", "pyproject.toml", "MANIFEST.in",
78
+ "RECORD", "WHEEL", "METADATA", "top_level.txt", "dependency_links.txt", "requires.txt", "not-zip-safe"]);
79
+
80
+ function tail(text: string, max = 300): string {
81
+ return text.replace(ANSI_RE, "").replace(/\s+/g, " ").trim().slice(-max);
82
+ }
83
+
84
+ function requireModule(o: EngineOptions, module: "build" | "twine"): void {
85
+ let out: ReturnType<typeof spawnEngine>;
86
+ try {
87
+ out = spawnEngine(binaryCandidates("python3", o), ["-m", module, "--version"], o);
88
+ } catch (err) {
89
+ if (err instanceof EngineMissingError) {
90
+ throw new PypiPrerequisiteError(
91
+ `\`python3 -m ${module}\` is unavailable: python3 was not found on PATH (border exit 2 — install python3 plus the '${module}' package)`,
92
+ );
93
+ }
94
+ throw err;
95
+ }
96
+ if (out.status !== 0) {
97
+ throw new PypiPrerequisiteError(
98
+ `\`python3 -m ${module} --version\` exited ${String(out.status)} — the '${module}' module is missing or broken (border exit 2). stderr: ${tail(out.stderr)}`,
99
+ );
100
+ }
101
+ }
102
+
103
+ /** build-once (G38): wipe stale dist, run `python3 -m build`, content-address every produced artifact. */
104
+ export function buildPypiArtifacts(o: PypiInput): { readonly artifacts: readonly PypiArtifact[]; readonly distDir: string } {
105
+ requireModule(o, "build");
106
+ const repoDir = resolve(o.repoDir);
107
+ if (!existsSync(join(repoDir, "pyproject.toml"))) {
108
+ throw new PypiPrerequisiteError(`no pyproject.toml in the repo root — the PyPI pipeline builds from pyproject's own [build-system]`);
109
+ }
110
+ const distDir = join(resolve(o.stateDir ?? join(repoDir, ".border")), "dist");
111
+ mkdirSync(distDir, { recursive: true });
112
+ // Wipe ONLY the pypi kinds (.tar.gz/.whl): an earlier run's — or an
113
+ // attacker's pre-seeded — pypi artifact must never be scanned (build-once
114
+ // hygiene), but OTHER channels' staged artifacts (.tgz/.crate/.gem) must
115
+ // survive a multi-target check. Exposed by the C5 cross-channel matrix:
116
+ // a full rmSync made every git+npm+pypi repo's npm push leg fail at the
117
+ // re-hash gate ("artifact changed since check — .tgz is missing from the
118
+ // working tree"), because the npm stage packed first and this wipe deleted
119
+ // the recorded bytes before the record was written.
120
+ for (const name of readdirSync(distDir)) {
121
+ if (name.endsWith(".tar.gz") || name.endsWith(".whl")) rmSync(join(distDir, name), { force: true });
122
+ }
123
+ const out = spawnEngine(binaryCandidates("python3", o), ["-m", "build", "--no-isolation", "--outdir", distDir, repoDir], o);
124
+ if (out.status !== 0) {
125
+ throw new EngineRunError(`\`python3 -m build\` exited ${String(out.status)} — no artifacts to gate (border exit 2). stderr: ${tail(out.stderr)}`, out.status);
126
+ }
127
+ const names = readdirSync(distDir).filter((n) => n.endsWith(".tar.gz") || n.endsWith(".whl")).sort();
128
+ if (names.length === 0) {
129
+ throw new EngineRunError(`\`python3 -m build\` produced no .tar.gz/.whl in ${distDir} (border exit 2)`, null);
130
+ }
131
+ const artifacts = names.map((name): PypiArtifact => {
132
+ const path = join(distDir, name);
133
+ return { path, kind: name.endsWith(".whl") ? "wheel" : "sdist", sha256: createHash("sha256").update(readFileSync(path)).digest("hex") };
134
+ });
135
+ return { artifacts, distDir };
136
+ }
137
+
138
+ function walkFilesRel(root: string): string[] {
139
+ return readdirSync(root, { withFileTypes: true }).flatMap((e) =>
140
+ e.isDirectory() ? walkFilesRel(join(root, e.name)).map((r) => `${e.name}/${r}`) : e.isFile() ? [e.name] : []);
141
+ }
142
+
143
+ /** An sdist always wraps everything in `<name>-<version>/`; strip that ONE shared
144
+ * top segment so wheel and sdist trees attribute the same inner relpath (and so
145
+ * the wrapper's version string never pollutes finding paths). */
146
+ function stripWrapper(entries: readonly string[]): { readonly root: string; readonly rest: readonly string[] } {
147
+ const tops = new Set(entries.map((e) => (e.includes("/") ? e.slice(0, e.indexOf("/")) : "\u0000")));
148
+ const only = tops.size === 1 ? [...tops][0] : undefined;
149
+ return only !== undefined && only !== "\u0000"
150
+ ? { root: only, rest: entries.map((e) => e.slice(only.length + 1)) }
151
+ : { root: "", rest: entries };
152
+ }
153
+
154
+ /** gitleaks dir-leg paths are absolute under the scan root; reattribute `<abs>` or
155
+ * `<abs>!<inner>` (native-miss archive reattribution) to scan-root-relative. */
156
+ function toInner(findingPath: string | undefined, scanRoot: string): string {
157
+ if (findingPath === undefined) return "";
158
+ const bang = findingPath.indexOf("!");
159
+ const head = bang === -1 ? findingPath : findingPath.slice(0, bang); const rest = bang === -1 ? "" : findingPath.slice(bang + 1);
160
+ const rel = relative(scanRoot, head);
161
+ const inner = rel !== "" && !rel.startsWith("..") ? rel : basename(head);
162
+ return rest === "" ? inner : `${inner}!${rest}`;
163
+ }
164
+
165
+ function gitTracked(o: EngineOptions, repoDir: string): Set<string> {
166
+ const out = spawnEngine(binaryCandidates("git", o), ["-C", repoDir, "ls-files"], {
167
+ ...o,
168
+ env: { ...(o.env ?? process.env), GIT_CEILING_DIRECTORIES: dirname(repoDir) },
169
+ });
170
+ if (out.status !== 0) throw new EngineRunError(`git ls-files in ${repoDir} exited ${String(out.status)} (border exit 2)`, out.status);
171
+ return new Set(out.stdout.split("\n").filter((l) => l !== ""));
172
+ }
173
+
174
+ /** MANIFEST.in + pyproject declarations → include/exclude glob matchers over
175
+ * sdist-inner (project-relative) paths. distutils precedence: prune/exclude
176
+ * remove whatever the includes pulled in, so exclusions are checked FIRST. */
177
+ type Matchers = { readonly inc: readonly RegExp[]; readonly exc: readonly RegExp[] };
178
+
179
+ function toRe(globs: readonly string[]): RegExp[] {
180
+ return globs.filter((g) => g !== "").map((g) => globToRegExp(g.includes("/") ? g : `**/${g}`));
181
+ }
182
+
183
+ function explainMatchers(repoDir: string): Matchers {
184
+ const inc: string[] = [];
185
+ const exc: string[] = [];
186
+ const manifest = join(repoDir, "MANIFEST.in");
187
+ if (existsSync(manifest)) {
188
+ for (const line of readFileSync(manifest, "utf8").split(/\r?\n/)) {
189
+ const t = line.split("#")[0]?.trim() ?? "";
190
+ if (t === "") continue;
191
+ const [cmd, ...args] = t.split(/\s+/);
192
+ const dir = args[0] ?? "";
193
+ if (cmd === "include") inc.push(...args);
194
+ else if (cmd === "recursive-include") for (const g of args.slice(1)) inc.push(`${dir}/**/${g}`, `${dir}/${g}`);
195
+ else if (cmd === "graft") inc.push(`${dir}/**`);
196
+ else if (cmd === "exclude") exc.push(...args);
197
+ else if (cmd === "global-exclude") for (const g of args) exc.push(`**/${g}`);
198
+ else if (cmd === "prune") exc.push(`${dir}/**`);
199
+ // ignore-* and unknown directives: distutils defaults; approximation documented here.
200
+ }
201
+ }
202
+ const pyproject = join(repoDir, "pyproject.toml");
203
+ if (existsSync(pyproject)) {
204
+ const text = readFileSync(pyproject, "utf8");
205
+ const readme = /^\s*readme\s*=\s*"([^"]+)"/m.exec(text);
206
+ if (readme?.[1] !== undefined) inc.push(readme[1]);
207
+ for (const block of text.matchAll(/readme\s*=\s*\{([^}]*)\}/g)) for (const f of block[1]?.matchAll(/"([^"]+)"/g) ?? []) inc.push(f[1] ?? "");
208
+ for (const block of text.matchAll(/\[tool\.setuptools\.(?:package-data|data-files)\]([\s\S]*?)(?=\n\[|$)/g))
209
+ for (const g of block[1]?.matchAll(/"([^"]+)"/g) ?? []) inc.push(g[1] ?? "");
210
+ }
211
+ return { inc: toRe(inc), exc: toRe(exc) };
212
+ }
213
+
214
+ function isGeneratedMetadata(inner: string): boolean {
215
+ const segs = inner.split("/");
216
+ return GENERATED_NAMES.has(segs[segs.length - 1] ?? "") || segs.some((s) => s.endsWith(".egg-info") || s.endsWith(".dist-info"));
217
+ }
218
+
219
+ function manifestFindings(o: PypiInput, sdistInner: readonly string[], target: string): Finding[] {
220
+ const tracked = gitTracked(o, resolve(o.repoDir));
221
+ const { inc, exc } = explainMatchers(resolve(o.repoDir));
222
+ const out: Finding[] = [];
223
+ for (const entry of sdistInner) {
224
+ const declared = inc.some((m) => m.test(entry)) && !exc.some((m) => m.test(entry));
225
+ if (tracked.has(entry) || isGeneratedMetadata(entry) || declared) continue;
226
+ const { valueDigest, snippet } = redact(entry);
227
+ out.push({
228
+ rule: "sdist-unexpected-file",
229
+ severity: "HIGH",
230
+ target,
231
+ path: entry,
232
+ engine: "pypi-manifest",
233
+ message: `sdist entry '${entry}' is not git-tracked and not explained by MANIFEST.in / pyproject declarations or build-generated metadata. setuptools builds sdist/wheels from the WORKING TREE (.gitignore-blind, G17/G18/G42): remove the file or commit/declare it intentionally.`,
234
+ valueDigest,
235
+ snippet,
236
+ });
237
+ }
238
+ return out;
239
+ }
240
+
241
+ function twineCheck(o: PypiInput, artifacts: readonly PypiArtifact[], target: string): Finding[] {
242
+ // COLUMNS/NO_COLOR: twine renders via rich, which hard-wraps long dist paths
243
+ // mid-token at terminal width — unwrapped single-line verdicts are what the
244
+ // PASSED/FAILED parser below consumes.
245
+ const out = spawnEngine(
246
+ binaryCandidates("python3", o),
247
+ ["-m", "twine", "check", "--strict", ...artifacts.map((a) => a.path)],
248
+ { ...o, env: { ...(o.env ?? process.env), COLUMNS: "100000", NO_COLOR: "1" } },
249
+ );
250
+ if (out.status === 0) return [];
251
+ const text = `${out.stdout}\n${out.stderr}`.replace(ANSI_RE, "");
252
+ const verdicts = [...text.matchAll(/Checking\s+(\S+):\s+(\w+)/g)];
253
+ const failed = verdicts.filter((m) => m[2] === "FAILED");
254
+ if (failed.length === 0) {
255
+ throw new EngineRunError(`\`python3 -m twine check\` exited ${String(out.status)} without a parseable PASSED/FAILED verdict (border exit 2): ${tail(text)}`, out.status);
256
+ }
257
+ return failed.map((m): Finding => {
258
+ const abs = m[1] ?? "";
259
+ const base = basename(abs);
260
+ const idx = text.indexOf(m[0]);
261
+ const next = text.indexOf("Checking ", idx + m[0].length);
262
+ const block = tail(text.slice(idx, next === -1 ? undefined : next), 400).replaceAll(abs, base);
263
+ const { valueDigest, snippet } = redact(`${base}\u0000FAILED`);
264
+ return {
265
+ rule: "twine-check",
266
+ severity: "HIGH",
267
+ target,
268
+ path: base,
269
+ engine: "twine",
270
+ message: o.sanitizer !== undefined ? o.sanitizer.sanitize(`twine check --strict rejected artifact '${base}': ${block}`) : `twine check --strict rejected artifact '${base}': ${block}`,
271
+ valueDigest,
272
+ snippet,
273
+ };
274
+ });
275
+ }
276
+
277
+ /** Full todo-12 pipeline: prereqs → build → extract-scan (both artifacts, deduped) → manifest-diff → twine. */
278
+ export async function scanPyPiArtifacts(o: PypiInput): Promise<PypiScanResult> {
279
+ requireModule(o, "build");
280
+ requireModule(o, "twine");
281
+ const repoDir = resolve(o.repoDir);
282
+ const stateDir = resolve(o.stateDir ?? join(repoDir, ".border"));
283
+ const target = o.target ?? "artifact";
284
+ const { artifacts } = buildPypiArtifacts(o);
285
+ const tmpParent = join(stateDir, "tmp");
286
+ mkdirSync(tmpParent, { recursive: true });
287
+
288
+ const merged = new Map<string, Finding>();
289
+ const absorb = (f: Finding, path: string): void => {
290
+ const g: Finding = { ...f, ...(path !== "" ? { path } : {}) };
291
+ merged.set(`${g.rule}\u0000${g.path ?? ""}\u0000${g.valueDigest}`, g);
292
+ };
293
+ try {
294
+ for (const artifact of artifacts) {
295
+ const sandbox = mkdtempSync(join(tmpParent, "pypi-"));
296
+ try {
297
+ if (artifact.kind === "wheel") {
298
+ const z = spawnEngine(binaryCandidates("python3", o), ["-m", "zipfile", "-e", artifact.path, sandbox], o);
299
+ if (z.status !== 0) throw new EngineRunError(`\`python3 -m zipfile -e ${basename(artifact.path)}\` exited ${String(z.status)} (border exit 2): ${tail(z.stderr)}`, z.status);
300
+ } else {
301
+ extractArchive(artifact.path, sandbox, o);
302
+ }
303
+ const { root, rest } = artifact.kind === "sdist" ? stripWrapper(walkFilesRel(sandbox)) : { root: "", rest: walkFilesRel(sandbox) };
304
+ const scanRoot = join(sandbox, root);
305
+ const scanOpts = { ...o, target };
306
+ if (o.skipGitleaks !== true) {
307
+ for (const f of scanTree({ ...scanOpts, dir: scanRoot, stateDir })) absorb(f, toInner(f.path, scanRoot));
308
+ }
309
+ if (o.skipSecretlint !== true) {
310
+ for (const f of await scanPaths({ ...scanOpts, ...(o.sanitizer !== undefined ? { sanitizer: o.sanitizer } : {}), ...(o.rules !== undefined ? { rules: o.rules } : {}), dir: scanRoot, files: rest })) absorb(f, f.path ?? "");
311
+ }
312
+ // R3a residue leg (plan §57): build hooks + artifact-wide T4, attributed `<archive>!<inner>`.
313
+ for (const f of residueFindings(residuePypiHits(scanRoot, rest), {
314
+ root: basename(artifact.path),
315
+ sep: "!",
316
+ identity: basename(artifact.path),
317
+ ...(o.sanitizer !== undefined ? { sanitizer: o.sanitizer } : {}),
318
+ })) absorb(f, f.path ?? "");
319
+ if (artifact.kind === "sdist") for (const f of manifestFindings(o, rest, target)) absorb(f, f.path ?? "");
320
+ } finally {
321
+ removeSandbox(sandbox);
322
+ }
323
+ }
324
+ for (const f of twineCheck(o, artifacts, target)) absorb(f, f.path ?? "");
325
+ } finally {
326
+ try {
327
+ rmdirSync(tmpParent);
328
+ } catch {
329
+ // non-empty ⇒ a concurrent run owns sandboxes here; leave for the .border/tmp GC
330
+ }
331
+ }
332
+ return { artifacts, findings: [...merged.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([, f]) => f) };
333
+ }