mandrel-platform 1.13.0 → 1.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -3
- package/config/stryker.base.json +2 -0
- package/package.json +4 -2
- package/scripts/audit-check.mjs +210 -47
- package/scripts/audit-check.test.mjs +479 -49
- package/scripts/check-action-download-retries.mjs +155 -11
- package/scripts/check-action-download-retries.test.mjs +168 -3
- package/scripts/check-advisory-scan-setup.test.mjs +186 -5
- package/scripts/check-coverage-threshold.mjs +112 -17
- package/scripts/check-coverage-threshold.test.mjs +335 -0
- package/scripts/check-husky-hook-modes.test.mjs +134 -0
- package/scripts/check-runner-runs-on.test.mjs +223 -2
- package/scripts/check-semgrep-lockfile.test.mjs +238 -7
- package/scripts/check-workflow-lint-tier.test.mjs +176 -0
- package/scripts/check-workflow-portability.mjs +6 -3
- package/scripts/check-workflow-portability.test.mjs +1 -1
- package/scripts/env-doctor.mjs +493 -62
- package/scripts/env-doctor.test.mjs +529 -3
- package/scripts/fixtures/npm-audit-v2.json +66 -0
- package/scripts/install-git-hooks.mjs +123 -0
- package/scripts/install-git-hooks.test.mjs +160 -0
- package/scripts/issue-intake.test.mjs +351 -21
- package/scripts/runner-toggle.test.mjs +407 -0
- package/scripts/select-semgrep-python.sh +98 -17
- package/scripts/select-semgrep-python.test.mjs +139 -12
- package/scripts/stryker-base-config.test.mjs +34 -0
- package/scripts/update-semgrep-rules.mjs +83 -5
- package/scripts/update-semgrep-rules.test.mjs +55 -1
- package/scripts/workflow-lint-gate.test.mjs +128 -0
package/README.md
CHANGED
|
@@ -179,7 +179,7 @@ export default config;
|
|
|
179
179
|
|
|
180
180
|
Shared Stryker mutation-testing defaults: pnpm package manager, `perTest`
|
|
181
181
|
coverage analysis, HTML + clear-text + progress reporters, `ignoreStatic`,
|
|
182
|
-
`disableBail: true`, the timeout budget a bail-free run needs
|
|
182
|
+
`disableBail: true`, `concurrency: 1`, the timeout budget a bail-free run needs
|
|
183
183
|
(`timeoutMS: 120000` — 120 s — plus `timeoutFactor: 3` and
|
|
184
184
|
`dryRunTimeoutMinutes: 15`), and high/low/break thresholds.
|
|
185
185
|
|
|
@@ -203,6 +203,26 @@ export default {
|
|
|
203
203
|
};
|
|
204
204
|
```
|
|
205
205
|
|
|
206
|
+
`concurrency: 1` is a cap, not a recommendation for every host. Stryker's own
|
|
207
|
+
default is `n-1` logical cores (`n` when `n <= 4`), so a scope that spreads this
|
|
208
|
+
base and names no `concurrency` of its own would take one worker per core — the
|
|
209
|
+
unsafe direction to inherit by omission, because on a runner host shared between
|
|
210
|
+
repos those cores are billed to a neighbouring tenant as a failed required
|
|
211
|
+
check. Raising it is one line, either in the spread (a later key wins) or on the
|
|
212
|
+
CLI:
|
|
213
|
+
|
|
214
|
+
```js
|
|
215
|
+
export default {
|
|
216
|
+
...base,
|
|
217
|
+
concurrency: 4, // dedicated CI — no other tenant on this host
|
|
218
|
+
};
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
# CLI wins over the config file: it completely replaces the value, not supplements it
|
|
223
|
+
npx stryker run --concurrency 4
|
|
224
|
+
```
|
|
225
|
+
|
|
206
226
|
Adopting the base is not a drop-in bump: `disableBail: true` changes what the
|
|
207
227
|
mutation score *means*, so any baseline captured under bail has to be
|
|
208
228
|
re-derived, and the base's `break: 50` is a fleet floor of last resort rather
|
|
@@ -504,7 +524,9 @@ override, and vice versa:
|
|
|
504
524
|
just fixable ones. The two managers report in different schemas (a legacy
|
|
505
525
|
`advisories` map; npm v7+ nests advisories under `vulnerabilities`), and
|
|
506
526
|
both are read — a report matching **neither** fails the gate closed rather
|
|
507
|
-
than reading as clean
|
|
527
|
+
than reading as clean, as does output that does not parse as JSON at all
|
|
528
|
+
(an audit that never ran exits 0 with an empty stdout, which is not a
|
|
529
|
+
clean graph).
|
|
508
530
|
2. **Unbounded-override lint.** Blocks on any dependency override written
|
|
509
531
|
without an upper bound — **independently of the CVE scan, and with zero
|
|
510
532
|
CVEs present**. It runs *first*, before any audit is invoked at all.
|
|
@@ -516,7 +538,9 @@ override, and vice versa:
|
|
|
516
538
|
Known/accepted CVEs are suppressed via a **dated, self-expiring allowlist**
|
|
517
539
|
(`audit-allowlist.json` in the project root). Expired entries are treated as
|
|
518
540
|
un-suppressed and cause the script to exit non-zero — forcing teams to
|
|
519
|
-
periodically re-evaluate accepted risk.
|
|
541
|
+
periodically re-evaluate accepted risk. Advisory ids compare
|
|
542
|
+
case-insensitively, so the canonical `GHSA-7w5x-hrqm-74c2` form GitHub renders
|
|
543
|
+
suppresses under either package manager.
|
|
520
544
|
|
|
521
545
|
**Consumer usage (`package.json`):**
|
|
522
546
|
|
package/config/stryker.base.json
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
"coverageAnalysis": "perTest",
|
|
7
7
|
"ignoreStatic": true,
|
|
8
8
|
"cleanTempDir": true,
|
|
9
|
+
"concurrency_comment": "Capped at 1 deliberately. Stryker defaults `concurrency` to `n-1` logical cores (`n` when `n <= 4`), so a consumer scope that spreads this base and names no `concurrency` of its own silently takes one worker per core — 17 on an 18-core host. Where a runner host is shared between repos, that lane costs cores no caller budgeted, and the damage lands on a neighbouring tenant as a failed required check nobody can diagnose from inside it. Omission is therefore the unsafe direction, and this pins the safe one: more parallelism is opt-in, either by spreading a later `concurrency` key or via `--concurrency` on the CLI, which completely replaces the config value. Consumers on dedicated CI should raise it — see the README. Asserted by scripts/stryker-base-config.test.mjs.",
|
|
10
|
+
"concurrency": 1,
|
|
9
11
|
"disableBail_comment": "Bail is OFF deliberately. Under Stryker's default bail the vitest runner can score a mutant Survived having completed zero of its covering tests, so a run reports a number nothing measured and a committed baseline becomes a floor under it. Every mutant now runs its full covering set, which lengthens the run — the three timeouts below are sized for that and must move together with this flag. Asserted by scripts/stryker-base-config.test.mjs.",
|
|
10
12
|
"disableBail": true,
|
|
11
13
|
"timeoutMS": 120000,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mandrel-platform",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.2",
|
|
4
4
|
"description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -52,13 +52,15 @@
|
|
|
52
52
|
"test": "node --test \"scripts/**/*.test.mjs\"",
|
|
53
53
|
"platform:sync": "node scripts/platform-sync.mjs",
|
|
54
54
|
"sync:commands": "node .agents/scripts/sync-claude-commands.js",
|
|
55
|
-
"prepare": "node .agents/scripts/sync-claude-commands.js",
|
|
55
|
+
"prepare": "node .agents/scripts/sync-claude-commands.js && node scripts/install-git-hooks.mjs",
|
|
56
56
|
"bootstrap": "node .agents/scripts/bootstrap.js",
|
|
57
57
|
"quality:preview": "node .agents/scripts/quality-preview.js --changed-since HEAD",
|
|
58
58
|
"quality:watch": "node .agents/scripts/quality-watch.js"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@biomejs/biome": "2.5.0",
|
|
62
|
+
"@commitlint/cli": "21.2.2",
|
|
63
|
+
"@commitlint/config-conventional": "21.2.2",
|
|
62
64
|
"markdownlint-cli2": "0.23.2"
|
|
63
65
|
},
|
|
64
66
|
"overrides": {
|
package/scripts/audit-check.mjs
CHANGED
|
@@ -25,7 +25,14 @@
|
|
|
25
25
|
* and found nothing blocking. A report that parsed but matches neither known
|
|
26
26
|
* schema fails the gate on ANY audit exit code, including zero.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
28
|
+
* Nor is silence. Output that does not parse as JSON fails the gate on any
|
|
29
|
+
* exit code too, and the audit's stderr is captured and printed alongside
|
|
30
|
+
* it. Every way the audit can fail to run at all — a missing binary, a
|
|
31
|
+
* killed child, an output ceiling hit mid-write — arrives as exit 0 with
|
|
32
|
+
* nothing readable on stdout, which the earlier contract reported as "No
|
|
33
|
+
* vulnerabilities found".
|
|
34
|
+
*
|
|
35
|
+
* That first clause is load-bearing. The two managers report differently — a
|
|
29
36
|
* legacy `advisories` map (pnpm / npm v6) versus npm v7+, which nests
|
|
30
37
|
* advisories under `vulnerabilities` — and the earlier contract passed an
|
|
31
38
|
* unrecognized report whenever the audit exited zero. Since `npm audit`
|
|
@@ -47,19 +54,25 @@
|
|
|
47
54
|
* node scripts/audit-check.mjs --allowlist path/to/allowlist.json
|
|
48
55
|
* node scripts/audit-check.mjs --package-json path/to/package.json
|
|
49
56
|
*
|
|
57
|
+
* Advisory ids (Story #488):
|
|
58
|
+
* Every advisory id and every allowlist id passes through one normalizer, so
|
|
59
|
+
* the allowlist compares case-insensitively by construction. The canonical
|
|
60
|
+
* `GHSA-` rendering — uppercase prefix, lowercase body, exactly as GitHub
|
|
61
|
+
* shows it and as operators paste it — is both what matches and what prints.
|
|
62
|
+
*
|
|
50
63
|
* Exit codes:
|
|
51
64
|
* 0 — no blocking vulnerabilities (all High/Critical suppressed with
|
|
52
65
|
* valid, non-expired allowlist entries, or none found) and every
|
|
53
66
|
* dependency override carries an upper bound
|
|
54
67
|
* 1 — one or more unsuppressed High/Critical CVEs, expired allowlist
|
|
55
68
|
* entries were encountered, an override was unbounded, the package
|
|
56
|
-
* manager could not be determined from a lockfile,
|
|
57
|
-
* matched no known schema
|
|
69
|
+
* manager could not be determined from a lockfile, the audit produced
|
|
70
|
+
* no readable JSON, or the report matched no known schema
|
|
58
71
|
*
|
|
59
72
|
* Allowlist format (JSON):
|
|
60
73
|
* [
|
|
61
74
|
* {
|
|
62
|
-
* "id": "GHSA-
|
|
75
|
+
* "id": "GHSA-7w5x-hrqm-74c2", // GitHub Advisory ID or CVE ID (case-insensitive)
|
|
63
76
|
* "reason": "No fix available; mitigated by X",
|
|
64
77
|
* "expires": "2026-12-31" // REQUIRED — strictly YYYY-MM-DD
|
|
65
78
|
* }
|
|
@@ -76,7 +89,7 @@
|
|
|
76
89
|
* Override with `--allowlist <path>`.
|
|
77
90
|
*/
|
|
78
91
|
|
|
79
|
-
import {
|
|
92
|
+
import { spawnSync } from "node:child_process";
|
|
80
93
|
import { existsSync, readFileSync } from "node:fs";
|
|
81
94
|
import { dirname, resolve } from "node:path";
|
|
82
95
|
|
|
@@ -86,6 +99,44 @@ import { dirname, resolve } from "node:path";
|
|
|
86
99
|
|
|
87
100
|
const BLOCKING_SEVERITIES = new Set(["high", "critical"]);
|
|
88
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Canonicalize an advisory id so allowlist matching is case-insensitive BY
|
|
104
|
+
* CONSTRUCTION rather than by remembering to fold case at each comparison
|
|
105
|
+
* site.
|
|
106
|
+
*
|
|
107
|
+
* GitHub renders and links advisory ids with an uppercase `GHSA-` prefix and a
|
|
108
|
+
* lowercase body (`GHSA-7w5x-hrqm-74c2`), and that is the form operators copy
|
|
109
|
+
* into the allowlist. The npm path used to uppercase the id it parsed out of
|
|
110
|
+
* `via[].url` while the pnpm path kept `ghsa_id` verbatim, and the allowlist
|
|
111
|
+
* was matched with an exact `Set.has` — so the canonical form everybody writes
|
|
112
|
+
* suppressed under pnpm and silently did nothing under npm. A suppression
|
|
113
|
+
* mechanism that is inert for the spelling its own runbook shows is worse than
|
|
114
|
+
* none: the entry looks applied.
|
|
115
|
+
*
|
|
116
|
+
* Both the advisory ids and every allowlist id pass through here, so the two
|
|
117
|
+
* sides cannot disagree. The GHSA form is normalized to its canonical
|
|
118
|
+
* rendering (uppercase prefix, lowercase body) because it is also what gets
|
|
119
|
+
* PRINTED; anything else (a CVE id, a bare vendor id) folds to upper case,
|
|
120
|
+
* which is canonical for CVE and case-insensitive for the rest.
|
|
121
|
+
*
|
|
122
|
+
* @param {unknown} id
|
|
123
|
+
* @returns {string} canonical id, or `""` when there is nothing to normalize
|
|
124
|
+
*/
|
|
125
|
+
export function normalizeAdvisoryId(id) {
|
|
126
|
+
if (typeof id !== "string") {
|
|
127
|
+
return "";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const trimmed = id.trim();
|
|
131
|
+
if (trimmed === "") {
|
|
132
|
+
return "";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return /^GHSA-/i.test(trimmed)
|
|
136
|
+
? `GHSA-${trimmed.slice("GHSA-".length).toLowerCase()}`
|
|
137
|
+
: trimmed.toUpperCase();
|
|
138
|
+
}
|
|
139
|
+
|
|
89
140
|
/**
|
|
90
141
|
* @typedef {{ id: string; reason?: string; expires: string }} AllowlistEntry
|
|
91
142
|
*/
|
|
@@ -203,7 +254,7 @@ export function partitionAllowlist(allowlist, today) {
|
|
|
203
254
|
if (expiresMs < todayMs) {
|
|
204
255
|
expired.push(entry);
|
|
205
256
|
} else {
|
|
206
|
-
suppressed.add(entry.id);
|
|
257
|
+
suppressed.add(normalizeAdvisoryId(entry.id));
|
|
207
258
|
}
|
|
208
259
|
}
|
|
209
260
|
|
|
@@ -541,7 +592,7 @@ export function ghsaIdFromUrl(url) {
|
|
|
541
592
|
}
|
|
542
593
|
|
|
543
594
|
return /^GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}$/i.test(segment)
|
|
544
|
-
? segment
|
|
595
|
+
? normalizeAdvisoryId(segment)
|
|
545
596
|
: null;
|
|
546
597
|
}
|
|
547
598
|
|
|
@@ -565,8 +616,10 @@ function normalizeLegacyReport(report) {
|
|
|
565
616
|
continue;
|
|
566
617
|
}
|
|
567
618
|
const adv = /** @type {Record<string, unknown>} */ (advisory);
|
|
568
|
-
const ghsaId =
|
|
569
|
-
const cveIds = Array.isArray(adv["cve"])
|
|
619
|
+
const ghsaId = normalizeAdvisoryId(adv["ghsa_id"]);
|
|
620
|
+
const cveIds = Array.isArray(adv["cve"])
|
|
621
|
+
? adv["cve"].map((c) => normalizeAdvisoryId(c))
|
|
622
|
+
: [];
|
|
570
623
|
|
|
571
624
|
out.push({
|
|
572
625
|
ids: [ghsaId, ...cveIds].filter(Boolean),
|
|
@@ -596,6 +649,7 @@ function normalizeNpmReport(report) {
|
|
|
596
649
|
/** @type {Map<string, NormalizedAdvisory>} */
|
|
597
650
|
const bySource = new Map();
|
|
598
651
|
const vulnerabilities = /** @type {Record<string, unknown>} */ (report.vulnerabilities);
|
|
652
|
+
let anonymousCount = 0;
|
|
599
653
|
|
|
600
654
|
for (const entry of Object.values(vulnerabilities)) {
|
|
601
655
|
if (entry === null || typeof entry !== "object") {
|
|
@@ -614,13 +668,36 @@ function normalizeNpmReport(report) {
|
|
|
614
668
|
const url = String(adv["url"] ?? "");
|
|
615
669
|
const ghsaId = ghsaIdFromUrl(url);
|
|
616
670
|
const source = adv["source"] === undefined ? "" : String(adv["source"]);
|
|
617
|
-
|
|
671
|
+
// npm's bundled advisory calculator does not emit a `cve` key on a
|
|
672
|
+
// `via[]` advisory — the read is kept because a report produced by
|
|
673
|
+
// another tool may carry one, not because npm's does.
|
|
674
|
+
const cveIds = Array.isArray(adv["cve"])
|
|
675
|
+
? adv["cve"].map((c) => normalizeAdvisoryId(c))
|
|
676
|
+
: [];
|
|
618
677
|
const ids = [ghsaId ?? "", ...cveIds].filter(Boolean);
|
|
619
678
|
|
|
620
679
|
// Key on the advisory's own identity so one advisory reachable through
|
|
621
|
-
// several packages is reported once.
|
|
622
|
-
|
|
623
|
-
|
|
680
|
+
// several packages is reported once. Each fallback is tried on its own
|
|
681
|
+
// value rather than chained with `??`: `source` is coerced to `""` when
|
|
682
|
+
// absent and `"" ?? url` is `""`, which made the url fallback dead code
|
|
683
|
+
// and gave every id-less advisory the same empty key.
|
|
684
|
+
//
|
|
685
|
+
// An advisory with nothing identifying left still counts. It gets a
|
|
686
|
+
// unique per-report key so it survives to be reported as `(unknown)` —
|
|
687
|
+
// dropping a Critical for lacking a name is the one failure this gate
|
|
688
|
+
// must never have, and the empty-key `continue` did exactly that.
|
|
689
|
+
let key = ghsaId ?? "";
|
|
690
|
+
if (key === "" && source !== "") {
|
|
691
|
+
key = `source:${source}`;
|
|
692
|
+
}
|
|
693
|
+
if (key === "") {
|
|
694
|
+
key = url;
|
|
695
|
+
}
|
|
696
|
+
if (key === "") {
|
|
697
|
+
anonymousCount += 1;
|
|
698
|
+
key = `anonymous:${anonymousCount}`;
|
|
699
|
+
}
|
|
700
|
+
if (bySource.has(key)) {
|
|
624
701
|
continue;
|
|
625
702
|
}
|
|
626
703
|
|
|
@@ -873,45 +950,122 @@ export function lintOverrides(packageJsonPath) {
|
|
|
873
950
|
}
|
|
874
951
|
|
|
875
952
|
/**
|
|
876
|
-
* Audit invocation per manager
|
|
877
|
-
* this gate's claim is about what
|
|
878
|
-
*
|
|
953
|
+
* Audit invocation per manager, as an ARGV rather than a shell string. Both
|
|
954
|
+
* are restricted to the PRODUCTION graph: this gate's claim is about what
|
|
955
|
+
* ships, and a dev-only advisory would make it unactionable noise.
|
|
956
|
+
* `--omit=dev` is npm's documented spelling of that.
|
|
957
|
+
*
|
|
958
|
+
* The argv form is not cosmetic. The previous shell string appended
|
|
959
|
+
* `2>/dev/null`, which discarded the one channel that says WHY an audit
|
|
960
|
+
* produced nothing — and a shell is an injection surface Semgrep's
|
|
961
|
+
* `spawn-shell-true` rule blocks outright.
|
|
879
962
|
*/
|
|
880
963
|
const AUDIT_COMMANDS = {
|
|
881
|
-
pnpm: "pnpm audit --prod --json",
|
|
882
|
-
npm: "npm audit --omit=dev --json",
|
|
964
|
+
pnpm: { bin: "pnpm", args: ["audit", "--prod", "--json"] },
|
|
965
|
+
npm: { bin: "npm", args: ["audit", "--omit=dev", "--json"] },
|
|
883
966
|
};
|
|
884
967
|
|
|
885
968
|
/**
|
|
886
|
-
*
|
|
887
|
-
*
|
|
888
|
-
*
|
|
969
|
+
* How long the audit may run before it is killed. An audit resolves the whole
|
|
970
|
+
* production graph against a registry, so the budget is generous — but it is
|
|
971
|
+
* bounded, because the previous call had no timeout at all and a hung
|
|
972
|
+
* registry connection would hang the gate (and the CI leg holding it) forever.
|
|
973
|
+
*/
|
|
974
|
+
const AUDIT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Output ceiling for the audit's stdout. Node's default is 1 MiB, and an
|
|
978
|
+
* `npm audit --json` over a large graph clears that easily — at which point
|
|
979
|
+
* the child is killed and its truncated stdout is unparseable JSON. That used
|
|
980
|
+
* to reach the "non-JSON output, exit 0 → clean" branch, so an audit too big
|
|
981
|
+
* to read reported the graph clean. 64 MiB is far past any real report.
|
|
982
|
+
*/
|
|
983
|
+
const AUDIT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Human-readable rendering of a manager's audit invocation, for log lines.
|
|
889
987
|
*
|
|
890
988
|
* @param {"pnpm" | "npm"} manager
|
|
891
|
-
* @returns {
|
|
989
|
+
* @returns {string}
|
|
892
990
|
*/
|
|
893
|
-
function
|
|
894
|
-
const
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
991
|
+
function describeAuditCommand(manager) {
|
|
992
|
+
const { bin, args } = AUDIT_COMMANDS[manager];
|
|
993
|
+
return [bin, ...args].join(" ");
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Run the detected manager's audit, returning raw stdout, captured stderr and
|
|
998
|
+
* an exit code. Both managers exit non-zero when vulnerabilities are found;
|
|
999
|
+
* the JSON is wanted regardless of the exit code.
|
|
1000
|
+
*
|
|
1001
|
+
* `projectDir` is the directory whose lockfile decided the manager — not
|
|
1002
|
+
* `process.cwd()`, which is what the shell call inherited. Those differ
|
|
1003
|
+
* whenever `--package-json` points elsewhere, and when they differ the gate
|
|
1004
|
+
* audited a graph other than the one it named. Its whole output is a claim
|
|
1005
|
+
* about a specific dependency graph, so auditing a different tree than the one
|
|
1006
|
+
* detected makes the claim unfalsifiable.
|
|
1007
|
+
*
|
|
1008
|
+
* `spawnImpl` is the injectable seam (`.agents/rules/test-seams.md`): it
|
|
1009
|
+
* defaults to the real `spawnSync`, so production callers are unchanged, and a
|
|
1010
|
+
* test substitutes a recording stub instead of spawning a package manager.
|
|
1011
|
+
*
|
|
1012
|
+
* @param {"pnpm" | "npm"} manager
|
|
1013
|
+
* @param {string} projectDir directory holding the detected lockfile
|
|
1014
|
+
* @param {typeof spawnSync} spawnImpl
|
|
1015
|
+
* @returns {{ command: string; output: string; stderr: string; exitCode: number }}
|
|
1016
|
+
*/
|
|
1017
|
+
function runAudit(manager, projectDir, spawnImpl) {
|
|
1018
|
+
const { bin, args } = AUDIT_COMMANDS[manager];
|
|
1019
|
+
const command = describeAuditCommand(manager);
|
|
1020
|
+
|
|
1021
|
+
const result = spawnImpl(bin, args, {
|
|
1022
|
+
cwd: projectDir,
|
|
1023
|
+
encoding: "utf8",
|
|
1024
|
+
timeout: AUDIT_TIMEOUT_MS,
|
|
1025
|
+
maxBuffer: AUDIT_MAX_BUFFER_BYTES,
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
const output = typeof result?.stdout === "string" ? result.stdout : "";
|
|
1029
|
+
let stderr = typeof result?.stderr === "string" ? result.stderr : "";
|
|
1030
|
+
|
|
1031
|
+
// A spawn that never produced an exit status — the binary is missing, the
|
|
1032
|
+
// timeout fired, the output ceiling blew — reports through `error`. It is
|
|
1033
|
+
// a failure, and the reason belongs on stderr with everything else.
|
|
1034
|
+
if (result?.error) {
|
|
1035
|
+
const detail =
|
|
1036
|
+
result.error instanceof Error ? result.error.message : String(result.error);
|
|
1037
|
+
stderr = stderr === "" ? detail : `${stderr}\n${detail}`;
|
|
1038
|
+
return { command, output, stderr, exitCode: 1 };
|
|
905
1039
|
}
|
|
1040
|
+
|
|
1041
|
+
return { command, output, stderr, exitCode: result?.status ?? 1 };
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Echo whatever the audit wrote to stderr, bounded. Every failure path calls
|
|
1046
|
+
* this: the old shell string sent stderr to `/dev/null`, so an audit that
|
|
1047
|
+
* failed for an nameable reason (no registry, a corrupt lockfile, an
|
|
1048
|
+
* unsupported flag) surfaced as an unexplained empty report.
|
|
1049
|
+
*
|
|
1050
|
+
* @param {string} stderr
|
|
1051
|
+
*/
|
|
1052
|
+
function printAuditStderr(stderr) {
|
|
1053
|
+
const text = typeof stderr === "string" ? stderr.trim() : "";
|
|
1054
|
+
if (text === "") {
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
console.error("[audit-check] audit stderr:");
|
|
1058
|
+
console.error(text.slice(0, 2000));
|
|
906
1059
|
}
|
|
907
1060
|
|
|
908
1061
|
/**
|
|
909
1062
|
* CLI entrypoint. Returns the process exit code (0 clean, 1 blocking).
|
|
910
1063
|
*
|
|
911
1064
|
* @param {string[]} argv argv minus `node` and the script path
|
|
1065
|
+
* @param {{ spawnImpl?: typeof spawnSync }} [deps] injectable subprocess seam
|
|
912
1066
|
* @returns {number}
|
|
913
1067
|
*/
|
|
914
|
-
export function runCli(argv) {
|
|
1068
|
+
export function runCli(argv, { spawnImpl = spawnSync } = {}) {
|
|
915
1069
|
const { allowlistPath, packageJsonPath } = parseArgs(argv);
|
|
916
1070
|
|
|
917
1071
|
// --- Lint dependency overrides -------------------------------------------
|
|
@@ -976,7 +1130,8 @@ export function runCli(argv) {
|
|
|
976
1130
|
//
|
|
977
1131
|
// From the committed lockfile, not from `packageManager` / `engines`: the
|
|
978
1132
|
// lockfile is what the audit reads, and metadata can disagree with it.
|
|
979
|
-
const
|
|
1133
|
+
const projectDir = dirname(packageJsonPath);
|
|
1134
|
+
const detected = detectPackageManager(projectDir);
|
|
980
1135
|
if (detected.error) {
|
|
981
1136
|
console.error(`[audit-check] ERROR: ${detected.error}`);
|
|
982
1137
|
return 1;
|
|
@@ -984,13 +1139,15 @@ export function runCli(argv) {
|
|
|
984
1139
|
const manager = detected.manager;
|
|
985
1140
|
|
|
986
1141
|
console.log(
|
|
987
|
-
`[audit-check] Detected ${manager} from its lockfile; running
|
|
1142
|
+
`[audit-check] Detected ${manager} from its lockfile; running ` +
|
|
1143
|
+
`${describeAuditCommand(manager)} in ${projectDir} ...`,
|
|
988
1144
|
);
|
|
989
1145
|
const {
|
|
990
1146
|
command: auditCommand,
|
|
991
1147
|
output: auditOutput,
|
|
1148
|
+
stderr: auditStderr,
|
|
992
1149
|
exitCode: auditExitCode,
|
|
993
|
-
} = runAudit(manager);
|
|
1150
|
+
} = runAudit(manager, projectDir, spawnImpl);
|
|
994
1151
|
|
|
995
1152
|
// --- Parse audit JSON ----------------------------------------------------
|
|
996
1153
|
|
|
@@ -999,17 +1156,22 @@ export function runCli(argv) {
|
|
|
999
1156
|
try {
|
|
1000
1157
|
report = JSON.parse(auditOutput);
|
|
1001
1158
|
} catch {
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1159
|
+
// Silence is NOT clean. This branch used to return 0 whenever the audit
|
|
1160
|
+
// exited 0 with unparseable stdout — and every way the audit can fail to
|
|
1161
|
+
// run at all lands exactly there: a missing binary, a killed child, a
|
|
1162
|
+
// truncated write, an audit that printed a human-readable notice instead
|
|
1163
|
+
// of JSON. "Clean" is only ever a positively recognized schema with
|
|
1164
|
+
// nothing blocking in it, so this fails closed on any exit code and shows
|
|
1165
|
+
// the stderr that says why.
|
|
1007
1166
|
console.error(
|
|
1008
|
-
`[audit-check] ERROR: ${auditCommand}
|
|
1009
|
-
|
|
1010
|
-
"
|
|
1167
|
+
`[audit-check] ERROR: ${auditCommand} (exit ${auditExitCode}) produced no ` +
|
|
1168
|
+
"readable JSON. Failing closed: an audit whose output cannot be read " +
|
|
1169
|
+
"has not shown the dependency graph is clean.",
|
|
1011
1170
|
);
|
|
1012
|
-
|
|
1171
|
+
printAuditStderr(auditStderr);
|
|
1172
|
+
if (auditOutput.trim() !== "") {
|
|
1173
|
+
console.error(auditOutput.slice(0, 2000));
|
|
1174
|
+
}
|
|
1013
1175
|
return 1;
|
|
1014
1176
|
}
|
|
1015
1177
|
|
|
@@ -1032,6 +1194,7 @@ export function runCli(argv) {
|
|
|
1032
1194
|
"map nor an npm `vulnerabilities` map. Failing closed: a report that " +
|
|
1033
1195
|
"cannot be read cannot show the graph is clean.",
|
|
1034
1196
|
);
|
|
1197
|
+
printAuditStderr(auditStderr);
|
|
1035
1198
|
console.error(auditOutput.slice(0, 2000));
|
|
1036
1199
|
return exitCode;
|
|
1037
1200
|
}
|