border-customs 0.1.0 → 0.3.1

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,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
+ }