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.
- package/README.md +256 -40
- package/dist/assets/residue-src/crates.ts +228 -0
- package/dist/assets/residue-src/npm.ts +294 -0
- package/dist/assets/residue-src/pypi.ts +333 -0
- package/dist/assets/residue-src/residue.ts +348 -0
- package/dist/assets/residue-src/residueGem.ts +371 -0
- package/dist/assets/residue-src/residueMatchers.ts +417 -0
- package/dist/assets/residue-src/residuePy.ts +26 -0
- package/dist/assets/residue-src/residueRust.ts +252 -0
- package/dist/assets/residue-src/rubygems.ts +278 -0
- package/dist/index.js +5126 -2825
- package/package.json +6 -2
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// provenance: R3a crates leg (plan border-residue-gate §57) + VERIFIER-REPORT-R3A.json fix round
|
|
2
|
+
// (blocker R3A-V1, nit N1) + VERIFIER-REPORT-R3A-FIX.json round (blocker R3AFIX-F1 env-shape,
|
|
3
|
+
// nits N-a/N-b). Doctrine: NO T1 pass-lane for cargo either — build.rs is unmatchable
|
|
4
|
+
// ⇒ UNKNOWN ⇒ the pre-R3a CRITICAL default stays, so this leg only ADDS detection (plan Must-NOT:
|
|
5
|
+
// never softer). Cargo runs build.rs on every consumer build ⇒ its writes are install-time surface:
|
|
6
|
+
// plan §57 names exactly the {fs::write, File::create} verb pair and confines targets to
|
|
7
|
+
// OUT_DIR / CARGO_TARGET_TMPDIR (cargo's discarding roots; R1 anatomy). Widening the verb set is a
|
|
8
|
+
// PLAN amendment, not a matcher edit (contract §8). Cargo.toml network scan is scoped to
|
|
9
|
+
// [..build-dependencies] section bodies only — runtime [dependencies] is T0 negative space (plan §56).
|
|
10
|
+
import {
|
|
11
|
+
RESIDUE_T3_RULE,
|
|
12
|
+
RS_BUILD_DEPS_SECTION,
|
|
13
|
+
RS_BUILD_HOOK_BASENAMES,
|
|
14
|
+
RS_CONFINEMENT_ENV_TOKENS,
|
|
15
|
+
RS_WRITE_CALL,
|
|
16
|
+
} from "../rules/residueMatchers.ts";
|
|
17
|
+
import { FAMILY_WORDING, familyHits, firstArgument, readText, sectionBlock, t4HitFor, type ResidueHit } from "./residue.ts";
|
|
18
|
+
|
|
19
|
+
/** Residue hits for one extracted crate (pkgDir + inner rels incl. the `<name>-<version>/` wrapper). */
|
|
20
|
+
export function residueCratesHits(pkgDir: string, innerRels: readonly string[]): ResidueHit[] {
|
|
21
|
+
const hits: ResidueHit[] = [];
|
|
22
|
+
for (const rel of innerRels) {
|
|
23
|
+
const text = readText(pkgDir, rel);
|
|
24
|
+
if (text === null) continue;
|
|
25
|
+
const name = rel.split("/").pop() ?? "";
|
|
26
|
+
if (RS_BUILD_HOOK_BASENAMES.includes(name)) {
|
|
27
|
+
hits.push(...familyHits(rel, text, FAMILY_WORDING.crates));
|
|
28
|
+
for (const m of text.matchAll(RS_WRITE_CALL)) {
|
|
29
|
+
const arg = firstArgument(text, (m.index ?? 0) + m[0].length - 1);
|
|
30
|
+
// fail CLOSED on an unparseable argument (truncated call ⇒ row, never silence — R3a-F).
|
|
31
|
+
if (arg === null || !confined(text, arg)) {
|
|
32
|
+
hits.push({
|
|
33
|
+
rule: RESIDUE_T3_RULE,
|
|
34
|
+
severity: "HIGH",
|
|
35
|
+
message: `build-script code in '${rel}' writes outside the OUT_DIR/CARGO_TARGET_TMPDIR confinement roots: ${JSON.stringify((arg ?? m[0]).trim().replace(/\s+/g, " ").slice(0, 120))} (T3) — cargo discards those roots; anything else is residue (plan §57)`,
|
|
36
|
+
fileRel: rel,
|
|
37
|
+
patternId: RS_CONFINEMENT_PATTERN_ID,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (name === "Cargo.toml") {
|
|
43
|
+
const buildDeps = sectionBlock(text, RS_BUILD_DEPS_SECTION);
|
|
44
|
+
hits.push(...familyHits(rel, buildDeps, FAMILY_WORDING.crates));
|
|
45
|
+
}
|
|
46
|
+
const t4 = t4HitFor(rel, text);
|
|
47
|
+
if (t4 !== null) hits.push(t4);
|
|
48
|
+
}
|
|
49
|
+
return hits;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ------------------------------------------- §57 write-confinement (V1/N1 fix round)
|
|
53
|
+
|
|
54
|
+
/** R3A-N2 discriminator: confinement shares (rule, file) with the family lanes on dotfile writes,
|
|
55
|
+
* so its rows carry a stable digest suffix; the family lanes stay key-identical to 0.2.0. */
|
|
56
|
+
const RS_CONFINEMENT_PATTERN_ID = "rs-confinement";
|
|
57
|
+
//
|
|
58
|
+
// A write target is CONFINED iff it makes a LIVE env-reference call — `env::var("OUT_DIR")` or
|
|
59
|
+
// `env::var("CARGO_TARGET_TMPDIR")`, optional `std::` prefix (R3AFIX-F1: OUT_DIR/CARGO_TARGET_TMPDIR
|
|
60
|
+
// are legitimately reachable in Rust ONLY through env::var-family calls, so recognition is confined
|
|
61
|
+
// to that form; the prior substring test accepted path text like "/usr/local/bin/OUT_DIR" fail-OPEN),
|
|
62
|
+
// or its chain HEAD is one identifier whose EVERY recorded assignment — `let [mut] id = rhs` and bare
|
|
63
|
+
// `id = rhs` alike (VERIFIER-REPORT-R3A R3A-V1: a let-only scan let `o = format!(HOME…)` rebinds score
|
|
64
|
+
// zero) — makes such a call itself, or resolves through the same map to another fully-confined
|
|
65
|
+
// identifier. The ALL rule re-applies at every hop and cycles fail closed, so one rebind anywhere
|
|
66
|
+
// voids the route. Anything the closed grammar cannot decide (unknown head, unbound id — a tuple
|
|
67
|
+
// destructure `let (o, _) = …` records no plain `o =` assignment, N-b — zero assignments, self-refining
|
|
68
|
+
// `let o = f(&o)` chains, multi-line fluent chains, exotic indirection like `env!("OUT_DIR")` or a
|
|
69
|
+
// const alias) ⇒ violation: false alarms are recoverable, silent escapes are not. INVISIBLE (no row,
|
|
70
|
+
// escape class governed by contract §8): in-place mutation of a confined id without assignment —
|
|
71
|
+
// `o.push_str(..)` and `o += ".."` alike (N-a: `+=` is NOT caught by ASSIGN_SHAPE, the `+` breaks the
|
|
72
|
+
// `\w+\s*=` adjacency; it never was a violation row).
|
|
73
|
+
|
|
74
|
+
/** Code-position `env::var("<root>")` test — the ONLY accepted confinement shape (R3AFIX-F1).
|
|
75
|
+
* `"` strings, `r#"…"#` raw strings and line comments are skipped; block comments are consumed at
|
|
76
|
+
* their TRUE extent — Rust nests them (Rust Reference: two `/*` openers need two closers, so the
|
|
77
|
+
* inner one does NOT end the comment), so the skip tracks depth (R3AFIX3-B1: a flat
|
|
78
|
+
* `indexOf(closer)` ended early on nested openers and resurrected a
|
|
79
|
+
* commented-out env::var as live code ⇒ token-in-comment confined, fail-OPEN). Quotes inside comments
|
|
80
|
+
* are inert (the depth loop owns the region); unterminated regions consume the rest of the slice, and
|
|
81
|
+
* char-literal apostrophes only ever shift string windows: every mis-parse hides text and can only
|
|
82
|
+
* LOSE a real call ⇒ missed confinement ⇒ violation row, never acceptance (fail closed). */
|
|
83
|
+
function envRefCall(s: string): boolean {
|
|
84
|
+
let i = 0;
|
|
85
|
+
while (i < s.length) {
|
|
86
|
+
const ch = s.charAt(i);
|
|
87
|
+
if (ch === "/" && s.charAt(i + 1) === "/") {
|
|
88
|
+
const nl = s.indexOf("\n", i);
|
|
89
|
+
i = nl === -1 ? s.length : nl;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (ch === "/" && s.charAt(i + 1) === "*") {
|
|
93
|
+
let depth = 1;
|
|
94
|
+
let j = i + 2;
|
|
95
|
+
while (j < s.length && depth > 0) {
|
|
96
|
+
if (s.charAt(j) === "/" && s.charAt(j + 1) === "*") {
|
|
97
|
+
depth += 1;
|
|
98
|
+
j += 2;
|
|
99
|
+
} else if (s.charAt(j) === "*" && s.charAt(j + 1) === "/") {
|
|
100
|
+
depth -= 1;
|
|
101
|
+
j += 2;
|
|
102
|
+
} else {
|
|
103
|
+
j += 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
i = j;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (ch === '"') {
|
|
110
|
+
const close = endQuoted(s, i);
|
|
111
|
+
i = close < 0 ? s.length : close + 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const prevWord = i > 0 && /\w/.test(s.charAt(i - 1));
|
|
115
|
+
if (!prevWord) {
|
|
116
|
+
const raw = /^r(#*)"/.exec(s.slice(i));
|
|
117
|
+
if (raw !== null) {
|
|
118
|
+
const term = `"${raw[1] ?? ""}`;
|
|
119
|
+
const end = s.indexOf(term, i + raw[0].length);
|
|
120
|
+
i = end === -1 ? s.length : end + term.length;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const head = ENV_REF_HEAD.exec(s.slice(i));
|
|
124
|
+
if (head !== null) {
|
|
125
|
+
const open = i + head[0].length - 1;
|
|
126
|
+
const close = endQuoted(s, open);
|
|
127
|
+
if (close < 0) return false;
|
|
128
|
+
if (RS_CONFINEMENT_ENV_TOKENS.includes(s.slice(open + 1, close))) return true;
|
|
129
|
+
i = close + 1;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
i += 1;
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
const ENV_REF_HEAD = /^(?:std::)?env::var\s*\(\s*"/;
|
|
138
|
+
/** Index of the closing quote of the `"`-string opened at `open` (backslash escapes honored), -1 if
|
|
139
|
+
* unterminated. `firstArgument` uses the same escape rule, so both agree on what a string is. */
|
|
140
|
+
function endQuoted(s: string, open: number): number {
|
|
141
|
+
for (let i = open + 1; i < s.length; i += 1) {
|
|
142
|
+
const ch = s.charAt(i);
|
|
143
|
+
if (ch === "\\") i += 1;
|
|
144
|
+
else if (ch === '"') return i;
|
|
145
|
+
}
|
|
146
|
+
return -1;
|
|
147
|
+
}
|
|
148
|
+
/** identifier = rhs, non-comparison: `==`, `!=`, `<=`, `>=`, `=>` and compound ops can never match
|
|
149
|
+
* (\w+ sits immediately before optional whitespace before `=`); strings/comments match like NB2
|
|
150
|
+
* prose (§1.6-9): over-matching only ever adds rows, fail-closed by direction. */
|
|
151
|
+
const ASSIGN_SHAPE = /\b(\w+)\s*=(?![=>])/g;
|
|
152
|
+
const RAW_ID_HEAD = /^(?:&+\s*)?(?:mut\s+)?([A-Za-z_]\w*)$/;
|
|
153
|
+
|
|
154
|
+
/** Every textual assignment to tracked identifiers: id → list of rhs slices. Statement end = first
|
|
155
|
+
* `;` after the `=`; a following assignment starting before that `;` ends the slice early; with no
|
|
156
|
+
* `;` left, the slice stops at the newline (shortest-slice = fail-closed). Multi-line bindings
|
|
157
|
+
* (`let o = Path::new(\n "OUT_DIR");`) stay whole because the slice spans newlines. */
|
|
158
|
+
function allAssignments(text: string): Map<string, string[]> {
|
|
159
|
+
const map = new Map<string, string[]>();
|
|
160
|
+
const marks = [...text.matchAll(ASSIGN_SHAPE)].map((m) => ({ id: m[1] ?? "", start: m.index ?? 0, len: m[0].length }));
|
|
161
|
+
for (let k = 0; k < marks.length; k += 1) {
|
|
162
|
+
const { id, start, len } = marks[k] as { id: string; start: number; len: number };
|
|
163
|
+
const after = start + len;
|
|
164
|
+
const semi = text.indexOf(";", after);
|
|
165
|
+
const next = k + 1 < marks.length ? (marks[k + 1] as { start: number }).start : Infinity;
|
|
166
|
+
const eol = text.indexOf("\n", after);
|
|
167
|
+
const end = semi === -1 ? Math.min(next, eol === -1 ? text.length : eol) : Math.min(semi, next);
|
|
168
|
+
const bucket = map.get(id) ?? [];
|
|
169
|
+
bucket.push(text.slice(after, end));
|
|
170
|
+
map.set(id, bucket);
|
|
171
|
+
}
|
|
172
|
+
return map;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Data-flow head of a `.method(..)` / `::assoc(..)` / `macro!(..)` chain: methods recurse into the
|
|
176
|
+
* receiver, qualified/macro calls into their first argument (N1: PathBuf::from(&out_dir).join(..)
|
|
177
|
+
* has head out_dir; format!'s first arg is a string literal ⇒ no head). Undecidable shapes
|
|
178
|
+
* (`+` concatenation, dangling `.`, hop/8 exhausted) ⇒ null ⇒ unresolvable ⇒ violation (fail closed). */
|
|
179
|
+
function chainHead(expr: string): string | null {
|
|
180
|
+
let e = expr.trim();
|
|
181
|
+
for (let hop = 0; hop < 8; hop += 1) {
|
|
182
|
+
const bare = RAW_ID_HEAD.exec(e);
|
|
183
|
+
if (bare?.[1] !== undefined) return bare[1];
|
|
184
|
+
if (!e.endsWith(")")) return null;
|
|
185
|
+
const open = matchingOpenParen(e);
|
|
186
|
+
if (open < 0) return null;
|
|
187
|
+
const before = e.slice(0, open).trimEnd();
|
|
188
|
+
if (before.endsWith("!")) {
|
|
189
|
+
const arg = firstArgument(e, open);
|
|
190
|
+
if (arg === null) return null;
|
|
191
|
+
e = arg.trim();
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const tail = /(\.|::)(\w+)$/.exec(before);
|
|
195
|
+
if (tail === null) return null;
|
|
196
|
+
if (tail[1] === ".") {
|
|
197
|
+
const recv = before.slice(0, before.length - tail[0].length).trim();
|
|
198
|
+
if (recv === "") return null;
|
|
199
|
+
e = recv;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const arg = firstArgument(e, open);
|
|
203
|
+
if (arg === null) return null;
|
|
204
|
+
e = arg.trim();
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Quote-aware backwards paren match for `s[close] === ")"`; escape handling is one-char naive —
|
|
210
|
+
* mis-parses only ever yield -1 ⇒ chainHead null ⇒ violation, the fail-closed direction. */
|
|
211
|
+
function matchingOpenParen(s: string): number {
|
|
212
|
+
let depth = 0;
|
|
213
|
+
let quote: string | null = null;
|
|
214
|
+
for (let i = s.length - 1; i >= 0; i -= 1) {
|
|
215
|
+
const ch = s.charAt(i);
|
|
216
|
+
if (quote !== null) {
|
|
217
|
+
if (ch === quote && s.charAt(i - 1) !== "\\") quote = null;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (ch === '"' || ch === "'") quote = ch;
|
|
221
|
+
else if (ch === ")") depth += 1;
|
|
222
|
+
else if (ch === "(") {
|
|
223
|
+
depth -= 1;
|
|
224
|
+
if (depth === 0) return i;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return -1;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function confined(text: string, arg: string): boolean {
|
|
231
|
+
const trimmed = arg.trim();
|
|
232
|
+
if (envRefCall(trimmed)) return true;
|
|
233
|
+
const head = chainHead(trimmed);
|
|
234
|
+
if (head === null) return false;
|
|
235
|
+
return bindingConfined(allAssignments(text), head, new Set([head]));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** V1 core: id is confined iff EVERY recorded assignment (let-shadow OR bare `id =`) names a token
|
|
239
|
+
* directly or resolves through chainHead to another confined id. The ALL rule re-applies at every
|
|
240
|
+
* hop, so one poisoned rebind anywhere in the chain voids the whole route. Cycles, unbound ids and
|
|
241
|
+
* undecidable rhs heads return false — fail-closed by every exit (`visiting` only grows). */
|
|
242
|
+
function bindingConfined(assign: Map<string, string[]>, id: string, visiting: Set<string>): boolean {
|
|
243
|
+
const rhss = assign.get(id);
|
|
244
|
+
if (rhss === undefined || rhss.length === 0) return false;
|
|
245
|
+
return rhss.every((rhs) => {
|
|
246
|
+
if (envRefCall(rhs)) return true;
|
|
247
|
+
const next = chainHead(rhs);
|
|
248
|
+
if (next === null || visiting.has(next)) return false;
|
|
249
|
+
visiting.add(next);
|
|
250
|
+
return bindingConfined(assign, next, visiting);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// provenance: original clean-room implementation per .omo/plans/border-push-channels.md todo C4
|
|
2
|
+
//
|
|
3
|
+
// gem build-once artifact pipeline (plan C4 Scope + CHANNEL-CONTRACTS.md §d/§e).
|
|
4
|
+
// ONE `gem build <gemspec> -o <repo>/.border/dist/<name>-<version>.gem` is the
|
|
5
|
+
// single source of bytes for the whole gate chain: the recorded {file, sha256,
|
|
6
|
+
// bytes} is what the push core re-hashes before publish and the ledger pins —
|
|
7
|
+
// nothing downstream rebuilds. The -o flag is MANDATORY (contract trap §d:
|
|
8
|
+
// without it the output name derives from the SPEC name-version, not the
|
|
9
|
+
// gemspec filename — the recorded name would be a guess). gem build is
|
|
10
|
+
// byte-deterministic for a given tree (spike §e: date fixed 1980-01-02, member
|
|
11
|
+
// bytes stable) — which is what makes the freshness repack sound; caveats: an
|
|
12
|
+
// explicit s.date assignment makes builds legitimately non-deterministic
|
|
13
|
+
// (REJECTED here and at coords), and the digest is blind to git HEAD (the
|
|
14
|
+
// fingerprint key covers HEAD; the repack covers worktree bytes — compose).
|
|
15
|
+
// The .gem is a PLAIN tar (outer members metadata.gz / data.tar.gz /
|
|
16
|
+
// checksums.yaml.gz) that gitleaks 8.30.1 provably does NOT scan natively
|
|
17
|
+
// (contract §d), so it is extracted ONE pass (`tar -xf` — NOT -xzf, .gem is
|
|
18
|
+
// not gzipped) to a throwaway sandbox under .border/tmp/ and the EXTRACTED
|
|
19
|
+
// TREE is scanned: gitleaks descends into the inner data.tar.gz itself at
|
|
20
|
+
// --max-archive-depth 2 (paths arrive `data.tar.gz!lib/x.rb` — the inner
|
|
21
|
+
// archive is never double-extracted), secretlint covers the top-level members.
|
|
22
|
+
// The sandbox is removed on success AND failure.
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
25
|
+
import { join, relative } from "node:path";
|
|
26
|
+
import { spawnSync } from "node:child_process";
|
|
27
|
+
import { gunzipSync } from "node:zlib";
|
|
28
|
+
|
|
29
|
+
import type { BorderConfig } from "../config.ts";
|
|
30
|
+
import type { Finding } from "../findings.ts";
|
|
31
|
+
import { BORDER_STATE_DIR, ensureStateDir } from "../check/lock.ts";
|
|
32
|
+
import { extractArchive, removeSandbox } from "./extract.ts";
|
|
33
|
+
import { type ResidueHit, residueFindings } from "./residue.ts";
|
|
34
|
+
import { residueGemHits } from "./residueGem.ts";
|
|
35
|
+
import { scanTree } from "../engines/gitleaks.ts";
|
|
36
|
+
import { scanPaths, type SecretlintMode } from "../engines/secretlint.ts";
|
|
37
|
+
import { EngineMissingError, EngineRunError, type EngineOptions } from "../engines/support.ts";
|
|
38
|
+
import type { TextSanitizer } from "../redact.ts";
|
|
39
|
+
import { ConfigError } from "../channels/errors.ts";
|
|
40
|
+
import { selectGemspec } from "../channels/gemspec.ts";
|
|
41
|
+
|
|
42
|
+
/** Native-finding target label shared across the artifact-stage scans. */
|
|
43
|
+
export const RUBYGEMS_TARGET_LABEL = "artifact";
|
|
44
|
+
|
|
45
|
+
/** Directories never descended into when hunting for *.gemspec files. */
|
|
46
|
+
const SKIP_DIRECTORIES = new Set([".git", ".border"]);
|
|
47
|
+
|
|
48
|
+
export type GemArtifactRecord = {
|
|
49
|
+
/** repo-relative path of the single built gem (`.border/dist/<file>`). */
|
|
50
|
+
readonly file: string;
|
|
51
|
+
readonly sha256: string;
|
|
52
|
+
readonly bytes: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type GemStageOptions = {
|
|
56
|
+
readonly repoDir: string;
|
|
57
|
+
readonly cfg: BorderConfig;
|
|
58
|
+
readonly env?: EngineOptions["env"];
|
|
59
|
+
readonly sanitizer?: TextSanitizer;
|
|
60
|
+
readonly skipGitleaks?: boolean;
|
|
61
|
+
readonly skipSecretlint?: boolean;
|
|
62
|
+
readonly secretlintMode?: SecretlintMode;
|
|
63
|
+
/** Exact gem binary seam (tests / pinned toolchains); default "gem" on PATH. */
|
|
64
|
+
readonly gemBinPath?: string;
|
|
65
|
+
/** shared engine/extract timeout. */
|
|
66
|
+
readonly timeoutMs?: number;
|
|
67
|
+
readonly gemTimeoutMs?: number;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type GemStageResult = {
|
|
71
|
+
readonly findings: readonly Finding[];
|
|
72
|
+
readonly artifact: GemArtifactRecord | null;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function scopeFindingPath(f: Finding, extractDir: string): Finding {
|
|
76
|
+
if (f.path === undefined) return f;
|
|
77
|
+
const bang = f.path.indexOf("!");
|
|
78
|
+
if (bang > 0) {
|
|
79
|
+
const rel = relative(extractDir, f.path.slice(0, bang));
|
|
80
|
+
if (!rel.startsWith("..")) return { ...f, path: rel === "" ? f.path.slice(bang + 1) : `${rel}${f.path.slice(bang)}` };
|
|
81
|
+
}
|
|
82
|
+
const rel = relative(extractDir, f.path);
|
|
83
|
+
return rel === "" || rel.startsWith("..") ? f : { ...f, path: rel };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function listFiles(dir: string): string[] {
|
|
87
|
+
const out: string[] = [];
|
|
88
|
+
const stack = [dir];
|
|
89
|
+
while (stack.length > 0) {
|
|
90
|
+
const cur = stack.pop() as string;
|
|
91
|
+
for (const ent of readdirSync(cur, { withFileTypes: true })) {
|
|
92
|
+
const p = join(cur, ent.name);
|
|
93
|
+
if (ent.isDirectory()) stack.push(p);
|
|
94
|
+
else if (ent.isFile()) out.push(p);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** *.gemspec candidates from the WORKING TREE (gem build packages the tree,
|
|
101
|
+
* not HEAD — same doctrine as cargo's working-tree identity). */
|
|
102
|
+
function treeGemspecCandidates(repoDir: string): { path: string; body: string }[] {
|
|
103
|
+
const out: { path: string; body: string }[] = [];
|
|
104
|
+
const stack = [repoDir];
|
|
105
|
+
while (stack.length > 0) {
|
|
106
|
+
const cur = stack.pop() as string;
|
|
107
|
+
for (const ent of readdirSync(cur, { withFileTypes: true })) {
|
|
108
|
+
const p = join(cur, ent.name);
|
|
109
|
+
if (ent.isDirectory()) {
|
|
110
|
+
if (!SKIP_DIRECTORIES.has(ent.name)) stack.push(p);
|
|
111
|
+
} else if (ent.isFile() && ent.name.toLowerCase().endsWith(".gemspec")) {
|
|
112
|
+
out.push({ path: relative(repoDir, p), body: readFileSync(p, "utf8") });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Read the single gemspec's {name, version} FROM THE WORKING TREE — gem
|
|
120
|
+
* builds the tree, so the gem identity is the tree's identity. Returns null
|
|
121
|
+
* when the tree cannot yield an unambiguous literal identity (zero/multiple
|
|
122
|
+
* gemspecs, computed values, s.date): the freshness repack then fails closed
|
|
123
|
+
* to a full re-check (null can never justify a skip). */
|
|
124
|
+
export function singleTreeGemspec(repoDir: string, cfg?: BorderConfig): { path: string; name: string; version: string } | null {
|
|
125
|
+
const candidates = treeGemspecCandidates(repoDir);
|
|
126
|
+
if (candidates.length !== 1) return null;
|
|
127
|
+
try {
|
|
128
|
+
const identity = selectGemspec(candidates, cfg?.targets.rubygems?.name, "targets.rubygems", "in the working tree");
|
|
129
|
+
return { path: (candidates[0] as { path: string }).path, ...identity };
|
|
130
|
+
} catch (e) {
|
|
131
|
+
if (e instanceof ConfigError) return null;
|
|
132
|
+
throw e;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Run the rubygems artifact stage. Throws EngineMissingError/EngineRunError
|
|
138
|
+
* + ConfigError (exit 2 via translateError) for anything that must NOT read
|
|
139
|
+
* as "clean": gem absent, build failure, missing .gem output, ambiguous or
|
|
140
|
+
* non-literal gemspecs, s.date assignments.
|
|
141
|
+
*/
|
|
142
|
+
export async function runRubygemsArtifactStage(o: GemStageOptions): Promise<GemStageResult> {
|
|
143
|
+
if (o.cfg.targets.rubygems === undefined) {
|
|
144
|
+
throw new EngineRunError("runRubygemsArtifactStage invoked without targets.rubygems configured — pipeline bug", null);
|
|
145
|
+
}
|
|
146
|
+
const { repoDir } = o;
|
|
147
|
+
const bin = o.gemBinPath ?? "gem";
|
|
148
|
+
|
|
149
|
+
const candidates = treeGemspecCandidates(repoDir);
|
|
150
|
+
let identity: { name: string; version: string };
|
|
151
|
+
try {
|
|
152
|
+
identity = selectGemspec(candidates, o.cfg.targets.rubygems?.name, "targets.rubygems", "in the working tree");
|
|
153
|
+
} catch (e) {
|
|
154
|
+
if (e instanceof ConfigError) throw new EngineRunError(e.message, null);
|
|
155
|
+
throw e;
|
|
156
|
+
}
|
|
157
|
+
const gemName = `${identity.name}-${identity.version}.gem`;
|
|
158
|
+
|
|
159
|
+
const versionCheck = spawnSync(bin, ["--version"], {
|
|
160
|
+
encoding: "utf8",
|
|
161
|
+
stdio: "ignore",
|
|
162
|
+
...(o.env === undefined ? {} : { env: { ...o.env } }),
|
|
163
|
+
});
|
|
164
|
+
if (versionCheck.error !== undefined || versionCheck.status !== 0) {
|
|
165
|
+
throw new EngineMissingError(
|
|
166
|
+
`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)`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
ensureStateDir(repoDir);
|
|
171
|
+
const stateDir = join(repoDir, BORDER_STATE_DIR);
|
|
172
|
+
const distDir = join(stateDir, "dist");
|
|
173
|
+
mkdirSync(distDir, { recursive: true });
|
|
174
|
+
|
|
175
|
+
const dist = join(distDir, gemName);
|
|
176
|
+
const result = spawnSync(bin, ["build", (candidates[0] as { path: string }).path, "-o", dist], {
|
|
177
|
+
cwd: repoDir,
|
|
178
|
+
encoding: "utf8",
|
|
179
|
+
timeout: o.gemTimeoutMs ?? 120_000,
|
|
180
|
+
...(o.env === undefined ? {} : { env: { ...o.env } }),
|
|
181
|
+
});
|
|
182
|
+
if (result.error !== undefined || result.status !== 0) {
|
|
183
|
+
throw new EngineRunError(
|
|
184
|
+
`gem build failed (exit ${String(result.status)}) — nothing to gate: ${(result.stderr ?? "").trim().slice(0, 400)}`,
|
|
185
|
+
result.status,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (!existsSync(dist)) {
|
|
189
|
+
throw new EngineRunError(`gem build produced no ${gemName} in ${relative(repoDir, distDir)} — failing closed (was the gemspec's literal name/version re-read?)`, null);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const bytes = readFileSync(dist);
|
|
193
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
194
|
+
|
|
195
|
+
// raw .gem is a gitleaks native miss (contract §d) — scan the EXTRACTED tree
|
|
196
|
+
const findings: Finding[] = [];
|
|
197
|
+
const extractDir = join(stateDir, "tmp", sha256.slice(0, 8));
|
|
198
|
+
rmSync(extractDir, { recursive: true, force: true });
|
|
199
|
+
mkdirSync(extractDir, { recursive: true });
|
|
200
|
+
// R3b sibling sandbox — deliberately OUTSIDE extractDir: gitleaks already descends the inner
|
|
201
|
+
// data.tar.gz natively (contract §d double-scan trap); a second copy inside would duplicate rows.
|
|
202
|
+
const dataDir = `${extractDir}-data`;
|
|
203
|
+
try {
|
|
204
|
+
extractArchive(dist, extractDir, {
|
|
205
|
+
...(o.env !== undefined ? { env: o.env } : {}),
|
|
206
|
+
...(o.timeoutMs !== undefined ? { timeoutMs: o.timeoutMs } : {}),
|
|
207
|
+
});
|
|
208
|
+
const relPaths = listFiles(extractDir).map((p) => relative(extractDir, p));
|
|
209
|
+
if (relPaths.length === 0) throw new EngineRunError("extracted artifact is empty — unusable gem package output", null);
|
|
210
|
+
|
|
211
|
+
if (o.skipGitleaks !== true) {
|
|
212
|
+
const tree = scanTree({
|
|
213
|
+
dir: extractDir,
|
|
214
|
+
stateDir,
|
|
215
|
+
target: RUBYGEMS_TARGET_LABEL,
|
|
216
|
+
...(o.env !== undefined ? { env: o.env } : {}),
|
|
217
|
+
...(o.sanitizer !== undefined ? { sanitizer: o.sanitizer } : {}),
|
|
218
|
+
...(o.timeoutMs !== undefined ? { timeoutMs: o.timeoutMs } : {}),
|
|
219
|
+
});
|
|
220
|
+
for (const f of tree) findings.push(scopeFindingPath(f, extractDir));
|
|
221
|
+
}
|
|
222
|
+
if (o.skipSecretlint !== true) {
|
|
223
|
+
const linted = await scanPaths({
|
|
224
|
+
dir: extractDir,
|
|
225
|
+
files: relPaths,
|
|
226
|
+
target: RUBYGEMS_TARGET_LABEL,
|
|
227
|
+
rules: o.cfg.rules,
|
|
228
|
+
...(o.secretlintMode !== undefined ? { mode: o.secretlintMode } : {}),
|
|
229
|
+
...(o.env !== undefined ? { env: o.env } : {}),
|
|
230
|
+
...(o.sanitizer !== undefined ? { sanitizer: o.sanitizer } : {}),
|
|
231
|
+
...(o.timeoutMs !== undefined ? { timeoutMs: o.timeoutMs } : {}),
|
|
232
|
+
});
|
|
233
|
+
findings.push(...linted);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// R3b residue lane (plan border-residue-gate): additive-only — every finding above this line
|
|
237
|
+
// is emitted exactly as before the leg existed. Non-gem-layout containers (no metadata.gz/
|
|
238
|
+
// data.tar.gz members) keep their pre-R3b behavior: engine rows only, no residue rows.
|
|
239
|
+
const members = new Set(relPaths);
|
|
240
|
+
if (members.has("metadata.gz") && members.has("data.tar.gz")) {
|
|
241
|
+
let metadata: string;
|
|
242
|
+
try {
|
|
243
|
+
metadata = gunzipSync(readFileSync(join(extractDir, "metadata.gz"))).toString("utf8");
|
|
244
|
+
} catch {
|
|
245
|
+
throw new EngineRunError(`${gemName}: metadata.gz is not gunzip-readable — the extensions declaration cannot be verified, failing closed`, null);
|
|
246
|
+
}
|
|
247
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
248
|
+
mkdirSync(dataDir, { recursive: true });
|
|
249
|
+
extractArchive(join(extractDir, "data.tar.gz"), dataDir, {
|
|
250
|
+
...(o.env !== undefined ? { env: o.env } : {}),
|
|
251
|
+
...(o.timeoutMs !== undefined ? { timeoutMs: o.timeoutMs } : {}),
|
|
252
|
+
});
|
|
253
|
+
const dataRels = listFiles(dataDir).map((p) => relative(dataDir, p));
|
|
254
|
+
let hits: ResidueHit[];
|
|
255
|
+
try {
|
|
256
|
+
hits = residueGemHits(dataDir, dataRels, metadata);
|
|
257
|
+
} catch (e) {
|
|
258
|
+
throw new EngineRunError(`${gemName}: ${e instanceof Error ? e.message : String(e)} — failing closed`, null);
|
|
259
|
+
}
|
|
260
|
+
for (const f of residueFindings(hits, {
|
|
261
|
+
root: "data.tar.gz",
|
|
262
|
+
sep: "!",
|
|
263
|
+
identity: gemName,
|
|
264
|
+
...(o.sanitizer !== undefined ? { sanitizer: o.sanitizer } : {}),
|
|
265
|
+
})) {
|
|
266
|
+
findings.push(f);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} finally {
|
|
270
|
+
removeSandbox(extractDir);
|
|
271
|
+
removeSandbox(dataDir);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
findings,
|
|
276
|
+
artifact: { file: `${BORDER_STATE_DIR}/dist/${gemName}`, sha256, bytes: bytes.length },
|
|
277
|
+
};
|
|
278
|
+
}
|