@rightkit/release 0.6.6 → 0.6.7
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 +122 -9
- package/admission-files.mjs +3 -2
- package/advisory-db.json +4 -0
- package/advisory-db.mjs +124 -0
- package/advisory-exceptions.mjs +13 -10
- package/app-check.mjs +257 -38
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-agent-hook.mjs +9 -0
- package/bin/rightkit-gate.mjs +495 -344
- package/build-release.mjs +13 -6
- package/cli/right-release.mjs +29 -6
- package/compile-inputs.mjs +66 -0
- package/consumer-contracts.json +1044 -0
- package/consumer-contracts.mjs +627 -0
- package/consumer-input-contracts.mjs +422 -0
- package/consumer-policy.mjs +588 -0
- package/consumer-source-contracts.mjs +469 -0
- package/deny-default.toml +42 -0
- package/deterministic-scans.mjs +240 -29
- package/disk-command.mjs +270 -30
- package/disk-policy.json +50 -10
- package/disk-policy.mjs +482 -35
- package/disk-report.mjs +129 -0
- package/door-policy.mjs +228 -0
- package/gate-executor.mjs +30 -0
- package/gate-stages.mjs +110 -0
- package/github-updater-release.mjs +48 -8
- package/init-command.mjs +41 -0
- package/legion-semantic-contracts.mjs +428 -0
- package/ownership-class-rules.mjs +277 -0
- package/ownership-key-authority.mjs +3 -1
- package/ownership-mandatory-set.mjs +3 -2
- package/package.json +10 -3
- package/publish-cargo.mjs +5 -2
- package/publish-update.mjs +14 -11
- package/release-config.mjs +51 -6
- package/rightkit-versions.json +172 -167
- package/shell-contract-bridge.json +251 -0
- package/shell-contract.mjs +1069 -0
- package/sign-updater.mjs +24 -4
- package/source-closure-rules.mjs +204 -0
- package/stage-sidecar-command.mjs +2 -1
- package/stage-sidecar.mjs +437 -114
- package/suite-api.generated.mjs +96 -6
- package/suite-trust-roots.mjs +198 -0
package/deterministic-scans.mjs
CHANGED
|
@@ -27,14 +27,29 @@ import { checkAdvisoryExceptions } from "./advisory-exceptions.mjs";
|
|
|
27
27
|
import { canonicalJson, digestScanSet, requiredScanIds, rustCratePlan, rustScanId } from "./scan-digest.mjs";
|
|
28
28
|
import { reach } from "./lock-graph.mjs";
|
|
29
29
|
import { cargoManifestState } from "./cargo-manifest-text.mjs";
|
|
30
|
+
import { advisoryCacheRoot, ensureAdvisoryDb, prepareDenyOverlay, readAdvisoryPin } from "./advisory-db.mjs";
|
|
30
31
|
|
|
31
32
|
export const SCAN_RESULT_SCHEMA = "rightkit.deterministic-scans/1";
|
|
33
|
+
/**
|
|
34
|
+
* The checker that produced a scan result: `<name>@<version>#<digest>` where the digest covers this module and scan-digest.mjs,
|
|
35
|
+
* so a changed checker at the same version is a different identity. null when it cannot be computed; prior class A evidence
|
|
36
|
+
* then never unlocks class B (an unknown checker is not trusted, it is refused).
|
|
37
|
+
*/
|
|
38
|
+
export const CHECKER_IDENTITY = (() => {
|
|
39
|
+
try {
|
|
40
|
+
const p = JSON.parse(fs.readFileSync(new URL("./package.json", import.meta.url), "utf8"));
|
|
41
|
+
const h = crypto.createHash("sha256");
|
|
42
|
+
for (const f of ["./deterministic-scans.mjs", "./scan-digest.mjs"]) { h.update(f); h.update("\0"); h.update(fs.readFileSync(new URL(f, import.meta.url))); h.update("\0"); }
|
|
43
|
+
return `${p.name}@${p.version}#${h.digest("hex").slice(0, 16)}`;
|
|
44
|
+
} catch { return null; }
|
|
45
|
+
})();
|
|
46
|
+
export const CHECKER_VERSION = CHECKER_IDENTITY;
|
|
32
47
|
export const SCAN_CLASS = Object.freeze({ A: "A", B: "B" });
|
|
33
48
|
|
|
34
49
|
/** Scan id prefixes (the text before the first ":") that belong to each class. */
|
|
35
50
|
export const CLASS_A_SCAN_PREFIXES = Object.freeze([
|
|
36
51
|
"rustfmt", "node-check", "json-parse", "toml-parse", "yaml-parse", "ownership-catalog",
|
|
37
|
-
"actionlint", "pnpm-lockfile", "pnpm-audit", "cargo-metadata", "cargo-deny", "cargo-audit", "cargo-package-list",
|
|
52
|
+
"actionlint", "shellcheck", "pnpm-lockfile", "pnpm-audit", "cargo-metadata", "cargo-deny", "cargo-audit", "cargo-package-list",
|
|
38
53
|
"changelog-version", "license", "secret-scan", "path-policy", "advisory-exceptions", "suite-api",
|
|
39
54
|
]);
|
|
40
55
|
export const CLASS_B_SCAN_PREFIXES = Object.freeze([
|
|
@@ -55,6 +70,27 @@ export function deterministicScansEnabled(config) {
|
|
|
55
70
|
return config?.deterministicScans?.enabled === true;
|
|
56
71
|
}
|
|
57
72
|
|
|
73
|
+
let neutralDir = null;
|
|
74
|
+
/** An empty directory outside every repository, for tool version probes. */
|
|
75
|
+
function probeDir() {
|
|
76
|
+
if (!neutralDir || !fs.existsSync(neutralDir)) neutralDir = fs.mkdtempSync(path.join(os.tmpdir(), "rk-probe-"));
|
|
77
|
+
return neutralDir;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** JSONC to JSON: drops line and block comments outside strings, and trailing commas before } or ]. */
|
|
81
|
+
export function stripJsonComments(text) {
|
|
82
|
+
let out = ""; let i = 0; let inString = false;
|
|
83
|
+
while (i < text.length) {
|
|
84
|
+
const c = text[i];
|
|
85
|
+
if (inString) { out += c; if (c === "\\") { out += text[i + 1] ?? ""; i += 2; continue; } if (c === "\"") inString = false; i++; continue; }
|
|
86
|
+
if (c === "\"") { inString = true; out += c; i++; continue; }
|
|
87
|
+
if (c === "/" && text[i + 1] === "/") { while (i < text.length && text[i] !== "\n") i++; continue; }
|
|
88
|
+
if (c === "/" && text[i + 1] === "*") { i += 2; while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++; i += 2; continue; }
|
|
89
|
+
out += c; i++;
|
|
90
|
+
}
|
|
91
|
+
return out.replace(/,(\s*[}\]])/g, "$1");
|
|
92
|
+
}
|
|
93
|
+
|
|
58
94
|
/** "A", "B" or null for an id such as "clippy:rightkit-fs". */
|
|
59
95
|
export function scanClassOf(id) {
|
|
60
96
|
const prefix = String(id).split(":")[0];
|
|
@@ -178,6 +214,16 @@ function containedPath(raw, contentRoot) {
|
|
|
178
214
|
return { path: norm };
|
|
179
215
|
}
|
|
180
216
|
const PNPM_NO_HOOKS_ENV = Object.freeze({ npm_config_ignore_pnpmfile: "true", npm_config_ignore_scripts: "true", pnpm_config_ignore_pnpmfile: "true", pnpm_config_ignore_scripts: "true" });
|
|
217
|
+
// pnpm 11 checks, before every `pnpm run` and `pnpm exec`, that node_modules matches pnpm-lock.yaml (`verify-deps-before-run`, default
|
|
218
|
+
// `install`) and answers a stale tree by running `pnpm install` in it. That install wants to purge the modules directory and, with no TTY (a
|
|
219
|
+
// gate child has none), aborts: `[ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY] Aborted removal of modules directory due to no TTY` and a
|
|
220
|
+
// stack through runDepsStatusCheck, before the tool or the test runs at all (CodeRight, 2026-10-25: tsc, biome and test:npm failed in 2 s on a
|
|
221
|
+
// tree installed before the last lockfile change). A gate scan must not install into, or purge, the checkout it reads, and the lockfile is
|
|
222
|
+
// verified where it belongs, by `pnpm-lockfile` (`install --frozen-lockfile --lockfile-only`) in the clean export, so the class B pnpm scans
|
|
223
|
+
// turn the check off and run against the tree as installed, as pnpm 10 (default `false`) always did; a tree that is really missing something
|
|
224
|
+
// fails in the tool or the test with its own error. The `pnpm_config_` spelling is the one pnpm 11.25 honours for this setting
|
|
225
|
+
// (`npm_config_verify_deps_before_run=false` still aborted, checked on a stale tree), the lowercase spelling PNPM_NO_HOOKS_ENV uses for its settings.
|
|
226
|
+
const PNPM_RUN_ENV = Object.freeze({ pnpm_config_verify_deps_before_run: "false" });
|
|
181
227
|
const CONFIG_CRATE_FILE = /(^|\/)Cargo\.(toml|lock)$/;
|
|
182
228
|
const JS_TOOLING_FILE = /(^|\/)(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml)$/;
|
|
183
229
|
// Root files that change how EVERY crate (or package) builds: a change to one puts every unit in scope,
|
|
@@ -217,12 +263,31 @@ export const BIOME_CONFIG_FILES = Object.freeze(["biome.json", "biome.jsonc"]);
|
|
|
217
263
|
// READMEs, the root Cargo.toml (workspace version), the versions and ownership data, the ownership-availability helper it imports, itself,
|
|
218
264
|
// the port matrix generator it imports, and its outputs (the port matrix's MD and HTML are checked for consistency with its JSON).
|
|
219
265
|
// The Right Suite API contract and every projection scripts/gen-suite-api.mjs writes from it (the test-vector copy included).
|
|
220
|
-
const SUITE_API_INPUT = /^(contracts\/right-suite-api\.v1\.json|scripts\/gen-suite-api\.mjs|crates\/rightkit-license\/src\/suite_api_generated\.rs|packages\/app-update\/src\/suite-api\.generated\.ts|packages\/release\/suite-api\.generated\.mjs|crates\/rightkit-license\/test-vectors\/right-suite-api\.v1\.json)$/;
|
|
266
|
+
const SUITE_API_INPUT = /^(contracts\/right-suite-api\.v1\.json|scripts\/gen-suite-api\.mjs|crates\/rightkit-license\/src\/suite_api_generated\.rs|packages\/app-update\/src\/suite-api\.generated\.ts|packages\/release\/suite-api\.generated\.mjs|swift\/RightKitSwift\/Sources\/RightKitLicense\/RightSuiteAPI\.generated\.swift|crates\/rightkit-license\/test-vectors\/right-suite-api\.v1\.json)$/;
|
|
221
267
|
const GENERATOR_INPUT = /^(crates\/[^/]+\/(Cargo\.toml|README\.md)|packages\/[^/]+\/(package\.json|README\.md)|Cargo\.toml|packages\/release\/(rightkit-versions\.json|rightkit-ownership\.json|ownership-availability\.mjs)|scripts\/generate-(index|port-matrix)\.mjs|INDEX\.md|docs\/(ADOPTION|CAPABILITY-MAP|PORT-MATRIX)\.md|docs\/port-matrix\.(html|json))$/;
|
|
222
268
|
const PROBES = Object.freeze({
|
|
223
269
|
rustfmt: ["--version"], node: ["--version"], rustright: ["cargo", "--version"], pnpm: ["--version"],
|
|
224
|
-
actionlint: ["-version"], "right-git": ["--version"], swift: ["--version"],
|
|
270
|
+
actionlint: ["-version"], shellcheck: ["--version"], "right-git": ["--version"], swift: ["--version"],
|
|
225
271
|
});
|
|
272
|
+
/**
|
|
273
|
+
* The one version of each analyzer whose verdict class A accepts. A class A finding must not depend on which build of the
|
|
274
|
+
* analyzer a host happens to have: ShellCheck 0.10.0 fails SellRight's repaired deploy scripts on SC2015 and a documented
|
|
275
|
+
* `# shellcheck disable=SC2329` on SC2317 (0.11.0 split that code), so the same commit was red on one host and green on another.
|
|
276
|
+
* A scan (or a scan's `requires`) whose tool answers another version fails with TOOL_VERSION; it is never passed or skipped.
|
|
277
|
+
* The installers (.github/workflows/publish.yml, publish-app.yml, ci.yml) and the managed host path in ci/rk-pipeline.json
|
|
278
|
+
* provision exactly this version; scripts/ci/scan-receipt.test.mjs holds them to it.
|
|
279
|
+
*/
|
|
280
|
+
export const PINNED_SCAN_TOOLS = Object.freeze({ shellcheck: "0.11.0" });
|
|
281
|
+
// The version a probe's output names, for a tool whose first line is not its version (`shellcheck --version` opens with a banner).
|
|
282
|
+
const PROBE_VERSION = Object.freeze({
|
|
283
|
+
shellcheck: (text) => { const m = /^version:\s*(\S+)\s*$/m.exec(text); return m ? `shellcheck ${m[1]}` : null; },
|
|
284
|
+
});
|
|
285
|
+
/** null when `version` (a probe's answer) is the pinned one or the tool has no pin, else the problem. */
|
|
286
|
+
export function pinnedVersionProblem(tool, version) {
|
|
287
|
+
const required = PINNED_SCAN_TOOLS[tool];
|
|
288
|
+
if (!required || version === `${tool} ${required}`) return null;
|
|
289
|
+
return `${tool} found ${version ? `"${version}"` : "with no readable version"}, class A requires ${tool} ${required} (PINNED_SCAN_TOOLS in @rightkit/release deterministic-scans.mjs); another version judges scripts differently, so it is refused, never passed or skipped`;
|
|
290
|
+
}
|
|
226
291
|
|
|
227
292
|
function commandText(d) {
|
|
228
293
|
if (d.internal) return `internal:${d.internal}`;
|
|
@@ -345,6 +410,24 @@ export function scriptBrokerCall(scripts, entry = "test", { maxDepth = 8 } = {})
|
|
|
345
410
|
return visit(String(entry), []);
|
|
346
411
|
}
|
|
347
412
|
|
|
413
|
+
// A test script can reach the broker in ways scriptBrokerCall cannot read from package.json alone: `node -e "...spawnSync('pnpm', ['run', n])..."`,
|
|
414
|
+
// `node ./scripts/verify-local.mjs` (a script that spawns `rustright cargo test`), `sh -c`, `pnpm --dir <elsewhere> run verify`, `pnpm --filter`. HeardRight's
|
|
415
|
+
// root `test` is a `node -e` over `verify`, which is `pnpm --dir tauri-app-next run verify`, which is `node ./scripts/verify-local.mjs`, which runs
|
|
416
|
+
// `rustright cargo test`; guessing from the text of such a script would defer tests that merely mention the tool, so the app says it instead.
|
|
417
|
+
// `ci/gate.config.json` `npm.<unit>.requiresBroker` is `{ "reason": "<why this unit's test needs the broker>" }`, committed with the app and reviewed in the
|
|
418
|
+
// diff that adds it. The reason is required: a declaration without one, or of any other shape, is ignored (the test is planned and runs where this host can
|
|
419
|
+
// run it, the way the fail-closed default goes) and planScans says so in a note. `false` and `null` are plain "no declaration". It changes nothing on a host
|
|
420
|
+
// with the broker, in CI, on a protected ref or with the opt-out: the scan runs there exactly as before. Only where rightkit-gate defers the Rust gate does it
|
|
421
|
+
// take the unit's `test:npm:<unit>` scan out, with the Rust scans, named with its reason and never passed.
|
|
422
|
+
const REQUIRES_BROKER_REASON_LIMIT = 400;
|
|
423
|
+
export function declaredBrokerCall(unit, value) {
|
|
424
|
+
if (value === undefined || value === null || value === false) return {};
|
|
425
|
+
const reason = typeof value === "object" && !Array.isArray(value) && typeof value.reason === "string" ? value.reason.replace(/\s+/g, " ").trim() : "";
|
|
426
|
+
if (!reason) return { problem: `ci/gate.config.json npm.${unit}.requiresBroker is ignored: it must be { "reason": "<why this unit's test needs the broker>" } with a non-empty reason; test:npm:${unit} is planned as if it were not there` };
|
|
427
|
+
const text = reason.length > REQUIRES_BROKER_REASON_LIMIT ? `${reason.slice(0, REQUIRES_BROKER_REASON_LIMIT)}...` : reason;
|
|
428
|
+
return { call: { tool: "rustright", via: ["test"], command: `ci/gate.config.json npm.${unit}.requiresBroker: ${text}`, declared: true } };
|
|
429
|
+
}
|
|
430
|
+
|
|
348
431
|
/**
|
|
349
432
|
* The scan plan for a change: derived from the changed files plus the manifests they belong to.
|
|
350
433
|
* `contentRoot` is a clean export of the commit (pure content checks); `liveRoot` is the checkout the
|
|
@@ -391,6 +474,8 @@ export function planScans({
|
|
|
391
474
|
// The same per Cargo workspace: { [cargoDir]: parsedLock }. A workspace without an entry here (and not the global `cargoDir`) plans
|
|
392
475
|
// no reverse dependents.
|
|
393
476
|
rustLocks = null,
|
|
477
|
+
// The pinned RustSec advisory database ({ url, commit }, advisory-db.json): cargo-deny and cargo-audit read exactly that commit and never fetch.
|
|
478
|
+
advisoryPin = null,
|
|
394
479
|
} = {}) {
|
|
395
480
|
if (!contentRoot) throw new TypeError("planScans: contentRoot is required");
|
|
396
481
|
if (!Array.isArray(cargoCommand) || cargoCommand.length === 0 || !["rustright", "cargo"].includes(cargoCommand[0]) || cargoCommand.slice(1).some((a) => a !== "cargo")) {
|
|
@@ -600,12 +685,30 @@ export function planScans({
|
|
|
600
685
|
// Explicit files: the exported commit has no .git for actionlint's project discovery.
|
|
601
686
|
const workflowFiles = files.filter((f) => /^\.github\/workflows\/[^/]+\.ya?ml$/.test(f)).sort();
|
|
602
687
|
const actionlintConfig = exists(path.join(contentRoot, ".github/actionlint.yaml")) ? ["-config-file", ".github/actionlint.yaml"] : [];
|
|
603
|
-
|
|
688
|
+
// actionlint lints every run: script with shellcheck only when shellcheck is found, and otherwise passes silently; class A
|
|
689
|
+
// requires it (`requires`), so a host without it fails the scan instead of skipping the shell checks.
|
|
690
|
+
if (workflowFiles.length) add({ id: "actionlint", class: A, tool: "actionlint", requires: ["shellcheck"], args: [...actionlintConfig, ...workflowFiles], root: "content" });
|
|
691
|
+
// Standalone shell scripts (SellRight's deploy scripts failed SC2154, SC2030/2031 and SC2016 in CI): every tracked .sh/.bash file,
|
|
692
|
+
// with ShellCheck's own defaults and the repository's .shellcheckrc (data, not code). actionlint covers only workflow run: blocks.
|
|
693
|
+
// A script whose shebang names another shell (zsh, fish) is not ShellCheck's to judge, so it is left out, never failed.
|
|
694
|
+
const otherShell = (f) => { try { return /^#!.*\b(zsh|fish|pwsh|powershell)\b/.test(String(readFile(path.join(contentRoot, f))).split("\n", 1)[0]); } catch { return false; } };
|
|
695
|
+
const shellFiles = files.filter((f) => /\.(sh|bash)$/.test(f) && !/(^|\/)node_modules\//.test(f) && !otherShell(f)).sort();
|
|
696
|
+
if (shellFiles.length) add({ id: "shellcheck", class: A, tool: "shellcheck", args: ["--format=gcc", "--external-sources", ...shellFiles], root: "content" });
|
|
604
697
|
// No right-git drift scan: the one workflow right-git renders here, ci.yml, is hand-owned (its header;
|
|
605
698
|
// docs/CI.md) and held by scripts/ci/ci-scans-contract.mjs instead, so drift is always reported by design.
|
|
606
699
|
|
|
607
700
|
// Exceptions expire by date, not by change: checked on every run, whatever changed.
|
|
608
701
|
if (exists(path.join(contentRoot, "ci/advisory-exceptions.json"))) add({ id: "advisory-exceptions", class: A, internal: "advisory-exceptions" });
|
|
702
|
+
// An app's advisory exceptions follow the same policy as RightKit's own: a Cargo workspace whose deny.toml or .cargo/audit.toml
|
|
703
|
+
// ignores an advisory needs its ledger, <workspace>/.rightkit/advisory-exceptions.json, with owner, evidence, an unexpired
|
|
704
|
+
// reviewBy, the exact affected versions and dependents in that workspace's Cargo.lock, and a person's approval; deny.toml and
|
|
705
|
+
// audit.toml must match it exactly.
|
|
706
|
+
for (const ws of workspaces) {
|
|
707
|
+
if (typeof ws !== "string") continue;
|
|
708
|
+
const dir = ws === "." ? "" : ws;
|
|
709
|
+
const ignores = ["deny.toml", ".cargo/audit.toml"].some((f) => { try { return /\bignore\s*=\s*\[[^\]]*\S[^\]]*\]/s.test(readFile(path.join(contentRoot, dir, f))); } catch { return false; } });
|
|
710
|
+
if (ignores && !(dir === "" && exists(path.join(contentRoot, "ci/advisory-exceptions.json")))) add({ id: `advisory-exceptions${dir ? `:${dir}` : ""}`, class: A, internal: "advisory-exceptions", dir, exceptionsFile: ".rightkit/advisory-exceptions.json" });
|
|
711
|
+
}
|
|
609
712
|
// Like generated-docs, planned only when a changed file is one of the contract's inputs or projections. The generator runs from
|
|
610
713
|
// the trusted scanner checkout (the import below); the exported contract and projections are data it checks.
|
|
611
714
|
if (files.some((f) => SUITE_API_INPUT.test(f)) && exists(path.join(contentRoot, "contracts/right-suite-api.v1.json"))) add({ id: "suite-api", class: A, internal: "suite-api" });
|
|
@@ -614,16 +717,43 @@ export function planScans({
|
|
|
614
717
|
for (const name of crateNames.filter((n) => rustMap?.get(n).publish !== false)) add({ id: rustScanId.packageList(name), class: A, ...cargoIn(wsOf(name), "package", "-p", crateOf(name), "--list", "--allow-dirty", "--locked") });
|
|
615
718
|
}
|
|
616
719
|
for (const ws of [...configWs].sort()) {
|
|
617
|
-
|
|
618
|
-
|
|
720
|
+
// Offline against the pinned database. The pin commit is part of the command (env), so a pin change is a different scan, and the
|
|
721
|
+
// placeholders are filled at run time from the materialised checkout (runScans).
|
|
722
|
+
const pin = advisoryPin ?? (advisoryPin = readAdvisoryPin());
|
|
723
|
+
const pinEnv = { RK_ADVISORY_DB_COMMIT: pin.commit };
|
|
724
|
+
add({ id: `cargo-deny${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, "deny", "--offline", "--metadata-path", "{advisoryMetadata}", "--config", "{advisoryDenyConfig}", "check"), env: pinEnv, advisoryDb: "deny", advisoryPin: pin });
|
|
725
|
+
add({ id: `cargo-audit${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, "audit", "--db", "{advisoryDb}", "--no-fetch"), env: pinEnv, advisoryDb: "audit", advisoryPin: pin });
|
|
619
726
|
}
|
|
727
|
+
// The audit policy is the consumer's own: every package.json script that is exactly `pnpm audit <flags>` (only --prod, --dev,
|
|
728
|
+
// --audit-level <low|moderate|high|critical> are accepted) declares one, and the scan runs the strictest of them all: a dependency
|
|
729
|
+
// scope only when every declaration names that same scope, and the lowest audit level any declares (one without a level means low).
|
|
730
|
+
// With none declared, every dependency at every severity. The gate never runs a weaker audit than the repository declares
|
|
731
|
+
// (RightSites declares `pnpm audit --prod`: production dependencies, all severities).
|
|
732
|
+
const LEVELS = ["low", "moderate", "high", "critical"];
|
|
733
|
+
const consumerAuditFlags = (r) => {
|
|
734
|
+
let scripts = {};
|
|
735
|
+
try { scripts = JSON.parse(readFile(path.join(contentRoot, r, "package.json"))).scripts ?? {}; } catch { /* none: the strict default */ }
|
|
736
|
+
const declared = [];
|
|
737
|
+
for (const cmd of Object.values(scripts)) {
|
|
738
|
+
const m = /^\s*pnpm\s+audit((?:\s+(?:--prod|--dev|--audit-level(?:=|\s+)(?:low|moderate|high|critical)))*)\s*$/.exec(String(cmd));
|
|
739
|
+
if (!m) continue;
|
|
740
|
+
const words = m[1].trim().split(/\s+|=/).filter(Boolean);
|
|
741
|
+
const at = words.indexOf("--audit-level");
|
|
742
|
+
const scope = words.includes("--prod") && !words.includes("--dev") ? "--prod" : words.includes("--dev") && !words.includes("--prod") ? "--dev" : null;
|
|
743
|
+
declared.push({ scope, level: at < 0 ? 0 : LEVELS.indexOf(words[at + 1]) });
|
|
744
|
+
}
|
|
745
|
+
if (!declared.length) return [];
|
|
746
|
+
const scope = declared.every((d) => d.scope === declared[0].scope) ? declared[0].scope : null;
|
|
747
|
+
const level = Math.min(...declared.map((d) => d.level));
|
|
748
|
+
return [...(scope ? [scope] : []), ...(level > 0 ? ["--audit-level", LEVELS[level]] : [])];
|
|
749
|
+
};
|
|
620
750
|
for (const r of jsRoots) {
|
|
621
751
|
// Class A runs no repository hooks: `install` accepts --ignore-pnpmfile and --ignore-scripts; `audit` accepts
|
|
622
752
|
// neither flag, so every class A pnpm scan also sets the same two settings through the environment.
|
|
623
753
|
// One pair per pnpm root, run in the root's directory; the repository root keeps the unsuffixed ids.
|
|
624
754
|
const where = r === "." ? {} : { cwd: r }; const suffix = r === "." ? "" : `:${r}`;
|
|
625
755
|
add({ id: `pnpm-lockfile${suffix}`, class: A, tool: "pnpm", args: ["install", "--frozen-lockfile", "--lockfile-only", "--ignore-pnpmfile", "--ignore-scripts"], env: PNPM_NO_HOOKS_ENV, root: "content", ...where });
|
|
626
|
-
add({ id: `pnpm-audit${suffix}`, class: A, tool: "pnpm", args: ["audit",
|
|
756
|
+
add({ id: `pnpm-audit${suffix}`, class: A, tool: "pnpm", args: ["audit", ...consumerAuditFlags(r)], env: PNPM_NO_HOOKS_ENV, root: "content", ...where });
|
|
627
757
|
}
|
|
628
758
|
const crateDirs = crateNames.map(rustDir); const pkgDirs = pkgNames.map(npmDir);
|
|
629
759
|
const withChangelog = crateNames.filter((n) => exists(path.join(contentRoot, rustDir(n), "CHANGELOG.md")) && exists(path.join(contentRoot, rustDir(n), "Cargo.toml")));
|
|
@@ -681,9 +811,9 @@ export function planScans({
|
|
|
681
811
|
const hasConfig = (dir, names) => ancestorDirsOf(dir).some((d) => names.some((f) => exists(path.join(liveRoot, inDir(d, f)))));
|
|
682
812
|
for (const name of pkgNames) {
|
|
683
813
|
const dir = npmDir(name);
|
|
684
|
-
if (exists(path.join(liveRoot, dir, "tsconfig.json"))) add({ id: `tsc:${name}`, class: B, tool: "pnpm", args: ["exec", "tsc", "--noEmit", "-p", "tsconfig.json"], cwd: dir });
|
|
685
|
-
if (hasConfig(dir, ESLINT_CONFIG_FILES)) add({ id: `eslint:${name}`, class: B, tool: "pnpm", args: ["exec", "eslint", "."], cwd: dir });
|
|
686
|
-
if (hasConfig(dir, BIOME_CONFIG_FILES)) add({ id: `biome:${name}`, class: B, tool: "pnpm", args: ["exec", "biome", "lint", "."], cwd: dir });
|
|
814
|
+
if (exists(path.join(liveRoot, dir, "tsconfig.json"))) add({ id: `tsc:${name}`, class: B, tool: "pnpm", args: ["exec", "tsc", "--noEmit", "-p", "tsconfig.json"], env: PNPM_RUN_ENV, cwd: dir });
|
|
815
|
+
if (hasConfig(dir, ESLINT_CONFIG_FILES)) add({ id: `eslint:${name}`, class: B, tool: "pnpm", args: ["exec", "eslint", "."], env: PNPM_RUN_ENV, cwd: dir });
|
|
816
|
+
if (hasConfig(dir, BIOME_CONFIG_FILES)) add({ id: `biome:${name}`, class: B, tool: "pnpm", args: ["exec", "biome", "lint", "."], env: PNPM_RUN_ENV, cwd: dir });
|
|
687
817
|
}
|
|
688
818
|
if (files.some((f) => f.startsWith("swift/") || f === "Package.swift")) {
|
|
689
819
|
if (platform === "darwin") add({ id: "swift-build", class: B, tool: "swift", args: ["build"] });
|
|
@@ -716,10 +846,13 @@ export function planScans({
|
|
|
716
846
|
if (own.quietTests === true && quietTests === "ci") { deferredToCi.push(`test:npm:${name}`); continue; }
|
|
717
847
|
// A gate proves the repository's own content: sibling checkouts beside it (RightKit's suite-contract tests read the app
|
|
718
848
|
// repositories when present) are not inputs, so the package tests run as an isolated checkout, as they do in CI.
|
|
719
|
-
// `brokerCall` marks a test script that runs the broker (or cargo) through its script chain
|
|
720
|
-
// Rust gate where this host has no broker
|
|
721
|
-
|
|
722
|
-
|
|
849
|
+
// `brokerCall` marks a test script that runs the broker (or cargo) through its script chain (scriptBrokerCall), or a unit whose app declares that
|
|
850
|
+
// its test needs the broker (declaredBrokerCall): rightkit-gate defers such a scan with the Rust gate where this host has no broker.
|
|
851
|
+
// A declaration (`npm.<unit>.requiresBroker`, declaredBrokerCall) covers what the script text does not show; a call the scripts do show wins.
|
|
852
|
+
const declared = declaredBrokerCall(name, own.requiresBroker);
|
|
853
|
+
if (declared.problem) notes.push(declared.problem);
|
|
854
|
+
const brokerCall = scriptBrokerCall(manifest.scripts, "test") ?? declared.call ?? null;
|
|
855
|
+
add({ id: `test:npm:${name}`, class: B, ...(own.quietTests === true ? { quiet: true } : {}), ...(brokerCall ? { brokerCall } : {}), tool: "pnpm", args: ["--dir", npmDir(name), "run", "test"], env: { ...PNPM_RUN_ENV, RIGHTKIT_ISOLATED_CHECKOUT: "1" } });
|
|
723
856
|
}
|
|
724
857
|
if (deferredToCi.length) notes.push(`${deferredToCi.length} quiet test scan(s) left to the CI shards (one crate per runner), not run here: ${deferredToCi.join(", ")}`);
|
|
725
858
|
// scripts/ci tests run when scripts/, ci/ or .github/ changed, or a file the scripts/ci sources reference (imports and
|
|
@@ -831,9 +964,10 @@ export function killProcessTree(child, { platform = process.platform, run = spaw
|
|
|
831
964
|
/**
|
|
832
965
|
* The same invocation as defaultSpawn without blocking, so scans can run side by side. Keeps the last 4 MiB of each stream.
|
|
833
966
|
* With `options.signal` (an AbortSignal), an abort kills the whole process tree and the result carries `cancelled: true`.
|
|
967
|
+
* With `options.stdoutFile`, stdout is written to that file whole instead of kept in memory.
|
|
834
968
|
*/
|
|
835
969
|
export function defaultSpawnAsync(command, args, options = {}) {
|
|
836
|
-
const { signal, ...rest } = options;
|
|
970
|
+
const { signal, stdoutFile, ...rest } = options;
|
|
837
971
|
const inv = resolveInvocation(command, args, { env: rest.env ?? process.env });
|
|
838
972
|
if (inv.error) return Promise.resolve({ status: null, stdout: "", stderr: "", error: inv.error });
|
|
839
973
|
if (signal?.aborted) return Promise.resolve({ status: null, stdout: "", stderr: "", error: new Error("cancelled"), cancelled: true });
|
|
@@ -847,10 +981,12 @@ export function defaultSpawnAsync(command, args, options = {}) {
|
|
|
847
981
|
const onAbort = () => killProcessTree(child);
|
|
848
982
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
849
983
|
child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8");
|
|
850
|
-
|
|
984
|
+
const sink = stdoutFile ? fs.createWriteStream(stdoutFile) : null;
|
|
985
|
+
if (sink) child.stdout.pipe(sink); else child.stdout.on("data", (c) => { stdout = keep(stdout, c); });
|
|
851
986
|
child.stderr.on("data", (c) => { stderr = keep(stderr, c); });
|
|
852
987
|
child.on("error", (e) => { error = e; });
|
|
853
|
-
child.on("close", (status) => {
|
|
988
|
+
child.on("close", async (status) => {
|
|
989
|
+
if (sink) await new Promise((done) => (sink.writableFinished ? done() : sink.on("finish", done).on("error", done)));
|
|
854
990
|
signal?.removeEventListener("abort", onAbort);
|
|
855
991
|
const cancelled = signal?.aborted === true;
|
|
856
992
|
resolve({ status, stdout, stderr, ...(error ? { error } : cancelled ? { error: new Error("cancelled") } : {}), ...(cancelled ? { cancelled: true } : {}) });
|
|
@@ -873,7 +1009,7 @@ export function laneOf(scan) {
|
|
|
873
1009
|
// The lines a reader needs from a failed scan: every line that names a failure (a failed test, a panic, a compiler or tool error,
|
|
874
1010
|
// cargo test's `failures:` block header, pnpm's ERR_ codes), at most twelve of them, then the last four lines of stdout and of
|
|
875
1011
|
// stderr. cargo test writes results to stdout and "Running ..." to stderr, so a plain tail showed only harness chatter.
|
|
876
|
-
const FAILURE_LINE = /(
|
|
1012
|
+
const FAILURE_LINE = /(^|[\s\[])(FAILED|panicked at|error(\[E\d+\])?:|error\[|^failures:|ERR_PNPM_\w+|Error:|assertion `?left|^thread '.*' panicked)/;
|
|
877
1013
|
export function failureDetail(r) {
|
|
878
1014
|
const lines = (text) => String(text ?? "").split(/\r?\n/).map((l) => l.trimEnd()).filter((l) => l.trim());
|
|
879
1015
|
const out = lines(r?.stdout); const err = lines(r?.stderr);
|
|
@@ -896,6 +1032,8 @@ export function findRustfmt({ env = process.env, exists = fs.existsSync, readdir
|
|
|
896
1032
|
if (found.length) return path.join(toolchains, found[0].d, "bin", exe);
|
|
897
1033
|
}
|
|
898
1034
|
const which = spawn(platform === "win32" ? "where" : "which", [exe]);
|
|
1035
|
+
// A Promise has no status: an async spawn here would report a present rustfmt as missing.
|
|
1036
|
+
if (typeof which?.then === "function") throw new TypeError("findRustfmt needs a synchronous spawn");
|
|
899
1037
|
return which && which.status === 0 ? String(which.stdout).split(/\r?\n/)[0].trim() : null;
|
|
900
1038
|
}
|
|
901
1039
|
|
|
@@ -1002,7 +1140,9 @@ const INTERNAL = {
|
|
|
1002
1140
|
"json-parse": (s, ctx) => {
|
|
1003
1141
|
const bad = [];
|
|
1004
1142
|
for (const f of s.files) {
|
|
1005
|
-
|
|
1143
|
+
// tsconfig/jsconfig and editor settings are JSON with comments by their own spec (TypeScript reads them that way).
|
|
1144
|
+
const jsonc = /(^|\/)(tsconfig|jsconfig)(\.[^/]*)?\.json$|(^|\/)\.vscode\/[^/]+\.json$/.test(f);
|
|
1145
|
+
try { const text = ctx.read(f).replace(/^\uFEFF/, ""); JSON.parse(jsonc ? stripJsonComments(text) : text); } catch (e) { bad.push(`${f}: ${e.message}`); }
|
|
1006
1146
|
}
|
|
1007
1147
|
return bad;
|
|
1008
1148
|
},
|
|
@@ -1023,7 +1163,9 @@ const INTERNAL = {
|
|
|
1023
1163
|
refuse: (s) => [s.detail],
|
|
1024
1164
|
// Every RustSec exception is dated, scoped to the locked versions and identical in deny.toml and
|
|
1025
1165
|
// .cargo/audit.toml (ci/advisory-exceptions.json; Astra 2026-10-22). Expiry fails every run.
|
|
1026
|
-
"advisory-exceptions": (s, ctx) =>
|
|
1166
|
+
"advisory-exceptions": (s, ctx) => (s.dir === undefined
|
|
1167
|
+
? checkAdvisoryExceptions({ root: ctx.contentRoot, readFile: ctx.read })
|
|
1168
|
+
: checkAdvisoryExceptions({ root: path.join(ctx.contentRoot, s.dir), readFile: (p) => ctx.read(path.join(s.dir, p)), exceptionsFile: s.exceptionsFile }).map((problem) => `${s.dir || "."}: ${problem}`)),
|
|
1027
1169
|
// Every Right Suite API projection must equal what the generator writes from the contract. The pure check is imported from the
|
|
1028
1170
|
// scanner's own checkout (scripts/ sits beside packages/release), never from the content root.
|
|
1029
1171
|
"suite-api": async (s, ctx) => {
|
|
@@ -1072,7 +1214,9 @@ const INTERNAL = {
|
|
|
1072
1214
|
export async function runScans(plan, options = {}) {
|
|
1073
1215
|
const deps = {
|
|
1074
1216
|
// Concurrent runs need a spawn that does not block the event loop.
|
|
1075
|
-
spawn: Number(options.jobs ?? 1) > 1 || options.signal ? defaultSpawnAsync : defaultSpawn,
|
|
1217
|
+
spawn: Number(options.jobs ?? 1) > 1 || options.signal ? defaultSpawnAsync : defaultSpawn,
|
|
1218
|
+
// Locating a tool (rustfmt's `which`/`where` fallback) is synchronous whatever `spawn` is.
|
|
1219
|
+
lookupSpawn: defaultSpawn, exists: fs.existsSync, readFile: (f) => fs.readFileSync(f, "utf8"), readdir: fs.readdirSync,
|
|
1076
1220
|
env: process.env, platform: process.platform, execPath: process.execPath,
|
|
1077
1221
|
loadToml: defaultLoadToml, loadYaml: defaultLoadYaml, scanSecrets: defaultScanSecrets, findRustfmt,
|
|
1078
1222
|
...options,
|
|
@@ -1086,7 +1230,7 @@ export async function runScans(plan, options = {}) {
|
|
|
1086
1230
|
const selected = plan.scans.filter((s) => classes.includes(s.class) && !exclude(s));
|
|
1087
1231
|
const ordered = [...selected.filter((s) => s.class === SCAN_CLASS.A), ...selected.filter((s) => s.class === SCAN_CLASS.B)];
|
|
1088
1232
|
const ctx = { deps, contentRoot, read: (f) => deps.readFile(path.join(contentRoot, f)) };
|
|
1089
|
-
const toolVersions = {}; const probed = new Map();
|
|
1233
|
+
const toolVersions = {}; const probed = new Map(); const advisoryDbs = new Map();
|
|
1090
1234
|
const results = [];
|
|
1091
1235
|
// options.cache = { read(key), write(key, row), digest(scan) => string | null } (null: not cacheable, which is how the live class A checks opt out).
|
|
1092
1236
|
const cache = options.cache && typeof options.cache.read === "function" && typeof options.cache.write === "function" && typeof options.cache.digest === "function" ? options.cache : null;
|
|
@@ -1098,25 +1242,35 @@ export async function runScans(plan, options = {}) {
|
|
|
1098
1242
|
const priorRows = Array.isArray(priorA?.scans) ? priorA.scans.filter((r) => r.class === SCAN_CLASS.A) : [];
|
|
1099
1243
|
// Prior evidence binds to the input content (inputDigest of the changed files), the class A scan set, and a
|
|
1100
1244
|
// class A run that actually included class A.
|
|
1245
|
+
// ... and to the checker that produced it: evidence from another checker (other version, or same version with changed bytes)
|
|
1246
|
+
// does not unlock class B, and an unknown identity on either side refuses.
|
|
1247
|
+
const checker = CHECKER_IDENTITY;
|
|
1101
1248
|
const priorClassAValid = Boolean(priorA) && priorA.schema === SCAN_RESULT_SCHEMA && Boolean(plan.classDigests?.A) && Boolean(plan.inputDigest)
|
|
1102
1249
|
&& priorA.inputDigest === plan.inputDigest && Array.isArray(priorA.classes) && priorA.classes.includes(SCAN_CLASS.A)
|
|
1103
|
-
&& priorA.classDigests?.A === plan.classDigests.A && digestScanSet(priorRows) === plan.classDigests.A && priorRows.every((r) => r.status === "pass")
|
|
1250
|
+
&& priorA.classDigests?.A === plan.classDigests.A && digestScanSet(priorRows) === plan.classDigests.A && priorRows.every((r) => r.status === "pass")
|
|
1251
|
+
&& typeof checker === "string" && priorA.checker === checker;
|
|
1104
1252
|
// Class A ran "in this call" only when it was selected, the plan has class A scans, and none was excluded:
|
|
1105
1253
|
// excluding every class A scan (or a plan with none) never unlocks class B; explicit prior evidence is needed.
|
|
1106
1254
|
const planA = plan.scans.filter((x) => x.class === SCAN_CLASS.A);
|
|
1107
1255
|
const inCallA = classes.includes(SCAN_CLASS.A) && planA.length > 0 && selected.filter((x) => x.class === SCAN_CLASS.A).length === planA.length;
|
|
1108
1256
|
const classBBlocked = !inCallA && !priorClassAValid;
|
|
1109
1257
|
|
|
1110
|
-
const toolCommand = (tool) => (tool === "node" ? deps.execPath : tool === "rustfmt" ? deps.findRustfmt({ env: deps.env, exists: deps.exists, readdir: deps.readdir, platform: deps.platform, spawn: deps.
|
|
1258
|
+
const toolCommand = (tool) => (tool === "node" ? deps.execPath : tool === "rustfmt" ? deps.findRustfmt({ env: deps.env, exists: deps.exists, readdir: deps.readdir, platform: deps.platform, spawn: deps.lookupSpawn }) : tool);
|
|
1111
1259
|
async function probe(tool) {
|
|
1112
1260
|
if (probed.has(tool)) return probed.get(tool);
|
|
1113
1261
|
let outcome;
|
|
1114
1262
|
const command = toolCommand(tool);
|
|
1115
1263
|
if (!command) outcome = { ok: false, why: "not found" };
|
|
1116
1264
|
else {
|
|
1117
|
-
|
|
1265
|
+
// A version probe never runs inside the repository: a tool started there reads the repository's own configuration first
|
|
1266
|
+
// (pnpm's .npmrc and pnpmfile, corepack's packageManager pin, cargo's .cargo/config), before any scan's protections apply.
|
|
1267
|
+
const r = await deps.spawn(command, PROBES[tool] ?? ["--version"], { cwd: deps.probeDir ?? probeDir(), env: { ...deps.env, ...PNPM_NO_HOOKS_ENV, COREPACK_ENABLE_AUTO_PIN: "0" }, ...sig });
|
|
1118
1268
|
if (!r || r.error || r.status !== 0) outcome = { ok: false, why: r?.error?.message ?? `exit ${r?.status}` };
|
|
1119
|
-
else
|
|
1269
|
+
else {
|
|
1270
|
+
const text = String(r.stdout || r.stderr || "");
|
|
1271
|
+
const version = PROBE_VERSION[tool] ? PROBE_VERSION[tool](text) : text.split(/\r?\n/).find((l) => l.trim())?.trim() ?? "unknown";
|
|
1272
|
+
outcome = { ok: true, command, version: version ?? "unknown", pinProblem: pinnedVersionProblem(tool, version) };
|
|
1273
|
+
}
|
|
1120
1274
|
}
|
|
1121
1275
|
toolVersions[tool] = outcome.ok ? outcome.version : null;
|
|
1122
1276
|
probed.set(tool, outcome);
|
|
@@ -1170,6 +1324,29 @@ export async function runScans(plan, options = {}) {
|
|
|
1170
1324
|
results.push(...slots);
|
|
1171
1325
|
}
|
|
1172
1326
|
|
|
1327
|
+
// The pinned advisory database, materialised once per run on first use (a failure is remembered and refuses every advisory scan).
|
|
1328
|
+
function prepareAdvisory(s) {
|
|
1329
|
+
const key = s.advisoryPin.commit;
|
|
1330
|
+
if (!advisoryDbs.has(key)) {
|
|
1331
|
+
try {
|
|
1332
|
+
const ensure = deps.ensureAdvisoryDb ?? ensureAdvisoryDb;
|
|
1333
|
+
advisoryDbs.set(key, { dir: ensure({ cacheRoot: deps.advisoryCacheRoot ?? advisoryCacheRoot({ env: deps.env, platform: deps.platform }), pin: s.advisoryPin }) });
|
|
1334
|
+
} catch (e) { advisoryDbs.set(key, { error: e }); }
|
|
1335
|
+
}
|
|
1336
|
+
const db = advisoryDbs.get(key);
|
|
1337
|
+
if (db.error) throw db.error;
|
|
1338
|
+
if (s.advisoryDb !== "deny") return { dbDir: db.dir };
|
|
1339
|
+
const prepare = deps.prepareDenyOverlay ?? prepareDenyOverlay;
|
|
1340
|
+
let denyToml = "";
|
|
1341
|
+
try { denyToml = deps.readFile(path.join(rootDir(s.root), s.cwd ?? "", "deny.toml")); } catch {
|
|
1342
|
+
// No deny.toml in the workspace: RightKit's own policy (deny-default.toml) applies, never cargo-deny's empty default,
|
|
1343
|
+
// which allows no licence at all.
|
|
1344
|
+
denyToml = deps.defaultDenyToml ?? fs.readFileSync(new URL("./deny-default.toml", import.meta.url), "utf8");
|
|
1345
|
+
}
|
|
1346
|
+
const overlay = prepare({ denyToml, dbDir: db.dir, pin: s.advisoryPin });
|
|
1347
|
+
return { dbDir: db.dir, configPath: overlay.configPath, cleanup: overlay.cleanup };
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1173
1350
|
// `extraEnv` (the quiet lease id) reaches the child but never the cache key: a lease changes nothing a scan proves.
|
|
1174
1351
|
async function scanRow(s, extraEnv = null) {
|
|
1175
1352
|
const row = { id: s.id, class: s.class, command: s.command, tool: s.tool ?? null, toolVersion: null, status: "fail" };
|
|
@@ -1193,13 +1370,25 @@ export async function runScans(plan, options = {}) {
|
|
|
1193
1370
|
} else {
|
|
1194
1371
|
const p = await probe(s.tool);
|
|
1195
1372
|
row.toolVersion = p.ok ? p.version : null;
|
|
1373
|
+
let missingDep = null; let depVersionProblem = null; const depVersions = [];
|
|
1374
|
+
for (const dep of s.requires ?? []) {
|
|
1375
|
+
const d = await probe(dep);
|
|
1376
|
+
if (!d.ok) { missingDep = `${dep} (${d.why})`; break; }
|
|
1377
|
+
if (d.pinProblem) { depVersionProblem = d.pinProblem; break; }
|
|
1378
|
+
depVersions.push(d.version);
|
|
1379
|
+
}
|
|
1196
1380
|
if (!p.ok) { status = "fail"; code = "TOOL_MISSING"; detail = `required tool ${s.tool} is missing or not runnable (${p.why}); a required scan is never skipped`; }
|
|
1381
|
+
else if (p.pinProblem) { status = "fail"; code = "TOOL_VERSION"; detail = `TOOL_VERSION: ${p.pinProblem} (at ${p.command})`; }
|
|
1382
|
+
else if (missingDep) { status = "fail"; code = "TOOL_MISSING"; detail = `${s.tool} needs ${missingDep}, which is missing or not runnable; without it the scan would pass unchecked, so it fails`; }
|
|
1383
|
+
else if (depVersionProblem) { status = "fail"; code = "TOOL_VERSION"; detail = `TOOL_VERSION: ${s.tool} runs ${depVersionProblem}`; }
|
|
1197
1384
|
else {
|
|
1198
1385
|
let cacheKey = null;
|
|
1199
1386
|
if (cache) {
|
|
1200
1387
|
try {
|
|
1201
1388
|
const digest = cache.digest(s);
|
|
1202
|
-
|
|
1389
|
+
// The analyzers a scan runs through (actionlint's shellcheck) judge it too, so their versions key it as well.
|
|
1390
|
+
const toolVersion = [p.version, ...depVersions].join(" + ");
|
|
1391
|
+
if (digest) cacheKey = scanCacheKey({ id: s.id, command: s.command, toolVersion, env: s.env ?? {}, inheritedEnv: inheritedEnvDigest({ ...deps.env, ...s.env }), digest });
|
|
1203
1392
|
} catch { cacheKey = null; }
|
|
1204
1393
|
const hit = cacheKey ? cache.read(cacheKey) : null;
|
|
1205
1394
|
if (hit && hit.key === cacheKey && hit.row?.status === "pass" && hit.row.id === s.id && hit.row.command === s.command) {
|
|
@@ -1207,7 +1396,29 @@ export async function runScans(plan, options = {}) {
|
|
|
1207
1396
|
return { ...row, status: "pass", cached: true, detail: `cached ${String(hit.at ?? "").slice(0, 10) || "earlier"}` };
|
|
1208
1397
|
}
|
|
1209
1398
|
}
|
|
1210
|
-
|
|
1399
|
+
let advisory = null; let args = s.args;
|
|
1400
|
+
if (s.advisoryDb) {
|
|
1401
|
+
try { advisory = prepareAdvisory(s); } catch (e) { return { ...row, status: "fail", code: "ADVISORY_DB", detail: e.message }; }
|
|
1402
|
+
// cargo-deny --offline must not fetch the advisory database, but its own `cargo metadata` would then be offline too and
|
|
1403
|
+
// fail on a runner whose cargo cache is cold. The workspace's metadata is resolved first, online and --locked (the
|
|
1404
|
+
// lockfile decides every version), and handed to cargo-deny as a file.
|
|
1405
|
+
if (s.advisoryDb === "deny") {
|
|
1406
|
+
const at = args.indexOf("deny");
|
|
1407
|
+
// The whole document is needed (the concurrent spawn keeps only the tail of stdout), so it streams to the file; a
|
|
1408
|
+
// spawn that does not know `stdoutFile` returns stdout, which is written below.
|
|
1409
|
+
const metadataDir = fs.mkdtempSync(path.join(os.tmpdir(), "rk-deny-metadata-"));
|
|
1410
|
+
const metadataFile = path.join(metadataDir, "metadata.json");
|
|
1411
|
+
const metadata = await deps.spawn(p.command, [...args.slice(0, at), ...metadataArgs({ noDeps: false })], { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...s.env }, stdoutFile: metadataFile, ...sig });
|
|
1412
|
+
if (!metadata || metadata.error || metadata.status !== 0) { fs.rmSync(metadataDir, { recursive: true, force: true }); advisory.cleanup?.(); return { ...row, status: "fail", code: "EXIT", detail: `cargo metadata for cargo-deny failed: ${metadata?.error?.message ?? (tail(metadata) || `exit ${metadata?.status}`)}` }; }
|
|
1413
|
+
if (!fs.existsSync(metadataFile)) fs.writeFileSync(metadataFile, String(metadata.stdout ?? ""));
|
|
1414
|
+
advisory.metadataPath = metadataFile;
|
|
1415
|
+
const overlayCleanup = advisory.cleanup;
|
|
1416
|
+
advisory.cleanup = () => { fs.rmSync(metadataDir, { recursive: true, force: true }); overlayCleanup?.(); };
|
|
1417
|
+
}
|
|
1418
|
+
args = args.map((a) => (a === "{advisoryDb}" ? advisory.dbDir : a === "{advisoryDenyConfig}" ? advisory.configPath : a === "{advisoryMetadata}" ? advisory.metadataPath : a));
|
|
1419
|
+
}
|
|
1420
|
+
let r;
|
|
1421
|
+
try { r = await deps.spawn(p.command, args.map((a) => (a === "{contentRoot}" ? contentRoot : a)), { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...s.env, ...(extraEnv ?? {}) }, ...sig }); } finally { advisory?.cleanup?.(); }
|
|
1211
1422
|
if (signal?.aborted && (!r || r.error || r.status !== 0)) return { ...row, status: "not-run", code: "CANCELLED", detail: "cancelled while running" };
|
|
1212
1423
|
if (!r || r.error) { status = "fail"; code = "SPAWN_ERROR"; detail = r?.error?.message ?? "no result"; }
|
|
1213
1424
|
else if (r.status !== 0) { status = "fail"; code = "EXIT"; detail = tail(r) || `exit ${r.status}`; }
|
|
@@ -1221,7 +1432,7 @@ export async function runScans(plan, options = {}) {
|
|
|
1221
1432
|
|
|
1222
1433
|
const cancelled = signal?.aborted === true;
|
|
1223
1434
|
return {
|
|
1224
|
-
schema: SCAN_RESULT_SCHEMA, ok: !cancelled && results.every((r) => r.status === "pass"), complete: !cancelled && results.every((r) => r.status === "pass"), classes, ...(cancelled ? { cancelled: true } : {}),
|
|
1435
|
+
schema: SCAN_RESULT_SCHEMA, ok: !cancelled && results.every((r) => r.status === "pass"), complete: !cancelled && results.every((r) => r.status === "pass"), classes, checker, ...(cancelled ? { cancelled: true } : {}),
|
|
1225
1436
|
inputDigest: plan.inputDigest ?? null,
|
|
1226
1437
|
deferred: results.filter((r) => r.status === "deferred").map((r) => r.id),
|
|
1227
1438
|
scans: results, toolVersions, cacheHits: [...cacheHits].sort(), notes: plan.notes ?? [],
|