mandrel-platform 1.11.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -5
- package/package.json +4 -1
- package/scripts/audit-check.mjs +345 -106
- package/scripts/audit-check.test.mjs +208 -3
- package/scripts/check-advisory-scan-setup.test.mjs +247 -0
- package/scripts/check-semgrep-lockfile.test.mjs +205 -0
- package/scripts/semgrep-requirements.txt +146 -74
- package/scripts/update-semgrep-rules.mjs +15 -2
package/README.md
CHANGED
|
@@ -496,13 +496,22 @@ Dependency gate script with **two independent blocking conditions**. It exits
|
|
|
496
496
|
non-zero when *either* fires — a clean CVE scan does not excuse an unbounded
|
|
497
497
|
override, and vice versa:
|
|
498
498
|
|
|
499
|
-
1. **CVE gate.**
|
|
500
|
-
|
|
501
|
-
the
|
|
502
|
-
|
|
499
|
+
1. **CVE gate.** Detects the package manager from the committed lockfile —
|
|
500
|
+
`pnpm-lock.yaml` means pnpm, `package-lock.json` means npm — runs that
|
|
501
|
+
manager's audit over the production graph, and blocks on any
|
|
502
|
+
**unsuppressed** High or Critical vulnerability in it. This is the stricter
|
|
503
|
+
athportal/swarm-os policy: all unsuppressed High/Critical are blocking, not
|
|
504
|
+
just fixable ones. The two managers report in different schemas (a legacy
|
|
505
|
+
`advisories` map; npm v7+ nests advisories under `vulnerabilities`), and
|
|
506
|
+
both are read — a report matching **neither** fails the gate closed rather
|
|
507
|
+
than reading as clean.
|
|
503
508
|
2. **Unbounded-override lint.** Blocks on any dependency override written
|
|
504
509
|
without an upper bound — **independently of the CVE scan, and with zero
|
|
505
|
-
CVEs present**. It runs *first*, before
|
|
510
|
+
CVEs present**. It runs *first*, before any audit is invoked at all.
|
|
511
|
+
|
|
512
|
+
> Neither lockfile present, or both, is a configuration error the gate reports
|
|
513
|
+
> and exits non-zero on. It will not guess which dependency graph its verdict
|
|
514
|
+
> is about.
|
|
506
515
|
|
|
507
516
|
Known/accepted CVEs are suppressed via a **dated, self-expiring allowlist**
|
|
508
517
|
(`audit-allowlist.json` in the project root). Expired entries are treated as
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mandrel-platform",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
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": {
|
|
@@ -60,5 +60,8 @@
|
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@biomejs/biome": "2.5.0",
|
|
62
62
|
"markdownlint-cli2": "0.23.2"
|
|
63
|
+
},
|
|
64
|
+
"overrides": {
|
|
65
|
+
"smol-toml": "~1.7.1"
|
|
63
66
|
}
|
|
64
67
|
}
|
package/scripts/audit-check.mjs
CHANGED
|
@@ -11,12 +11,26 @@
|
|
|
11
11
|
* whose expiry has passed are treated as un-suppressed and will cause
|
|
12
12
|
* the script to exit non-zero.
|
|
13
13
|
*
|
|
14
|
+
* Package managers (Story #475):
|
|
15
|
+
* The audit runs under whichever manager the COMMITTED LOCKFILE names —
|
|
16
|
+
* `pnpm-lock.yaml` → pnpm, `package-lock.json` → npm — not what
|
|
17
|
+
* `packageManager` or `engines` declares, because the lockfile is what the
|
|
18
|
+
* audit reads and metadata can disagree with it. Neither lockfile, or both,
|
|
19
|
+
* is a loud configuration error: this gate's output is a claim about a
|
|
20
|
+
* specific dependency graph, and guessing which graph would make that claim
|
|
21
|
+
* unfalsifiable.
|
|
22
|
+
*
|
|
14
23
|
* Fail-closed contract:
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
24
|
+
* A report counts as clean only when its schema was POSITIVELY RECOGNIZED
|
|
25
|
+
* and found nothing blocking. A report that parsed but matches neither known
|
|
26
|
+
* schema fails the gate on ANY audit exit code, including zero.
|
|
27
|
+
*
|
|
28
|
+
* That last clause is load-bearing. The two managers report differently — a
|
|
29
|
+
* legacy `advisories` map (pnpm / npm v6) versus npm v7+, which nests
|
|
30
|
+
* advisories under `vulnerabilities` — and the earlier contract passed an
|
|
31
|
+
* unrecognized report whenever the audit exited zero. Since `npm audit`
|
|
32
|
+
* exits zero when clean, that branch would have reported an npm graph clean
|
|
33
|
+
* without reading a single advisory, and kept doing so as highs landed.
|
|
20
34
|
*
|
|
21
35
|
* Unbounded-override lint (Story #365):
|
|
22
36
|
* A dependency override REWRITES a transitive dependent's declared range.
|
|
@@ -38,8 +52,9 @@
|
|
|
38
52
|
* valid, non-expired allowlist entries, or none found) and every
|
|
39
53
|
* dependency override carries an upper bound
|
|
40
54
|
* 1 — one or more unsuppressed High/Critical CVEs, expired allowlist
|
|
41
|
-
* entries were encountered, an override was unbounded,
|
|
42
|
-
*
|
|
55
|
+
* entries were encountered, an override was unbounded, the package
|
|
56
|
+
* manager could not be determined from a lockfile, or the audit report
|
|
57
|
+
* matched no known schema
|
|
43
58
|
*
|
|
44
59
|
* Allowlist format (JSON):
|
|
45
60
|
* [
|
|
@@ -63,7 +78,7 @@
|
|
|
63
78
|
|
|
64
79
|
import { execSync } from "node:child_process";
|
|
65
80
|
import { existsSync, readFileSync } from "node:fs";
|
|
66
|
-
import { resolve } from "node:path";
|
|
81
|
+
import { dirname, resolve } from "node:path";
|
|
67
82
|
|
|
68
83
|
// ---------------------------------------------------------------------------
|
|
69
84
|
// Pure core (unit-testable — no process.exit, no filesystem, no child process)
|
|
@@ -441,115 +456,311 @@ export function findUnboundedOverrides(pkgJson) {
|
|
|
441
456
|
}
|
|
442
457
|
|
|
443
458
|
/**
|
|
444
|
-
*
|
|
445
|
-
* an `advisories` object. This is the discriminator the fail-closed contract
|
|
446
|
-
* hangs on — a parsed-but-unrecognizable report (e.g. an error envelope) is
|
|
447
|
-
* NOT interpretable.
|
|
459
|
+
* Lockfiles this gate knows how to audit, in the order they are probed.
|
|
448
460
|
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
461
|
+
* The lockfile — not `packageManager`, not `engines` — is the discriminator,
|
|
462
|
+
* because it is the thing the audit actually reads. A repo can declare one
|
|
463
|
+
* manager in metadata and commit the other's lockfile (this one does), and it
|
|
464
|
+
* is the lockfile that decides whether an audit can run at all.
|
|
451
465
|
*/
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
466
|
+
const LOCKFILES = [
|
|
467
|
+
{ file: "pnpm-lock.yaml", manager: "pnpm" },
|
|
468
|
+
{ file: "package-lock.json", manager: "npm" },
|
|
469
|
+
];
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Resolve which package manager's audit to run for the project rooted at
|
|
473
|
+
* `projectDir`.
|
|
474
|
+
*
|
|
475
|
+
* Returns `{ manager }` on a clean read, or `{ error }` naming what is wrong.
|
|
476
|
+
* Both ambiguity (two lockfiles) and absence (none) are errors rather than a
|
|
477
|
+
* best guess: this gate's whole output is a claim about a specific dependency
|
|
478
|
+
* graph, and guessing which graph would make that claim unfalsifiable.
|
|
479
|
+
*
|
|
480
|
+
* `existsSyncImpl` is injectable so the decision is unit-testable without
|
|
481
|
+
* materializing a fixture tree per case.
|
|
482
|
+
*
|
|
483
|
+
* @param {string} projectDir directory holding the audited package.json
|
|
484
|
+
* @param {{ existsSyncImpl?: (p: string) => boolean }} [deps]
|
|
485
|
+
* @returns {{ manager: "pnpm" | "npm"; error?: undefined } | { manager?: undefined; error: string }}
|
|
486
|
+
*/
|
|
487
|
+
export function detectPackageManager(projectDir, { existsSyncImpl = existsSync } = {}) {
|
|
488
|
+
const found = LOCKFILES.filter(({ file }) =>
|
|
489
|
+
existsSyncImpl(resolve(projectDir, file)),
|
|
460
490
|
);
|
|
491
|
+
|
|
492
|
+
if (found.length === 1) {
|
|
493
|
+
return { manager: /** @type {"pnpm" | "npm"} */ (found[0].manager) };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (found.length === 0) {
|
|
497
|
+
return {
|
|
498
|
+
error:
|
|
499
|
+
`No lockfile found in ${projectDir}. Expected one of: ` +
|
|
500
|
+
`${LOCKFILES.map(({ file }) => file).join(", ")}. The audit reads the ` +
|
|
501
|
+
`lockfile, so without one there is no dependency graph to prove.`,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
error:
|
|
507
|
+
`Ambiguous lockfiles in ${projectDir}: ${found.map(({ file }) => file).join(" and ")}. ` +
|
|
508
|
+
`Remove the one that is not authoritative — this gate will not guess ` +
|
|
509
|
+
`which dependency graph its verdict is about.`,
|
|
510
|
+
};
|
|
461
511
|
}
|
|
462
512
|
|
|
463
513
|
/**
|
|
464
|
-
*
|
|
465
|
-
* interpretable pnpm-audit report. An advisory is suppressed when any of its
|
|
466
|
-
* ids (GHSA id or CVE ids) is present in `suppressed`.
|
|
514
|
+
* The GHSA id embedded in an advisory URL, or `null`.
|
|
467
515
|
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
516
|
+
* npm's report never exposes a bare `ghsa_id`; the only place the id appears
|
|
517
|
+
* is the advisory `url` (`https://github.com/advisories/GHSA-xxxx-xxxx-xxxx`),
|
|
518
|
+
* and the allowlist matches on that id. The URL is parsed and its LAST PATH
|
|
519
|
+
* SEGMENT tested against an anchored literal — never pattern-matched as a
|
|
520
|
+
* whole string, which would match a GHSA-shaped substring anywhere in a
|
|
521
|
+
* caller-controlled URL, host included.
|
|
471
522
|
*
|
|
472
|
-
* @param {unknown}
|
|
473
|
-
* @
|
|
474
|
-
* @returns {Array<{ id: string; severity: string; title: string; url: string }>}
|
|
523
|
+
* @param {unknown} url
|
|
524
|
+
* @returns {string|null}
|
|
475
525
|
*/
|
|
476
|
-
export function
|
|
477
|
-
|
|
478
|
-
|
|
526
|
+
export function ghsaIdFromUrl(url) {
|
|
527
|
+
if (typeof url !== "string" || url === "") {
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
479
530
|
|
|
480
|
-
|
|
481
|
-
|
|
531
|
+
let segment;
|
|
532
|
+
try {
|
|
533
|
+
const segments = new URL(url).pathname.split("/").filter(Boolean);
|
|
534
|
+
segment = segments[segments.length - 1];
|
|
535
|
+
} catch {
|
|
536
|
+
return null;
|
|
482
537
|
}
|
|
483
538
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
539
|
+
if (typeof segment !== "string") {
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return /^GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}$/i.test(segment)
|
|
544
|
+
? segment.toUpperCase()
|
|
545
|
+
: null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* @typedef {{ ids: string[]; severity: string; title: string; url: string }} NormalizedAdvisory
|
|
550
|
+
*/
|
|
487
551
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
552
|
+
/**
|
|
553
|
+
* Normalize the legacy (npm v6 / pnpm) `advisories` map.
|
|
554
|
+
*
|
|
555
|
+
* @param {Record<string, unknown>} report
|
|
556
|
+
* @returns {NormalizedAdvisory[]}
|
|
557
|
+
*/
|
|
558
|
+
function normalizeLegacyReport(report) {
|
|
559
|
+
/** @type {NormalizedAdvisory[]} */
|
|
560
|
+
const out = [];
|
|
561
|
+
const advisories = /** @type {Record<string, unknown>} */ (report.advisories);
|
|
562
|
+
|
|
563
|
+
for (const advisory of Object.values(advisories)) {
|
|
564
|
+
if (advisory === null || typeof advisory !== "object" || !("severity" in advisory)) {
|
|
494
565
|
continue;
|
|
495
566
|
}
|
|
496
|
-
|
|
497
567
|
const adv = /** @type {Record<string, unknown>} */ (advisory);
|
|
498
|
-
const
|
|
568
|
+
const ghsaId = String(adv["ghsa_id"] ?? "");
|
|
569
|
+
const cveIds = Array.isArray(adv["cve"]) ? adv["cve"].map((c) => String(c)) : [];
|
|
570
|
+
|
|
571
|
+
out.push({
|
|
572
|
+
ids: [ghsaId, ...cveIds].filter(Boolean),
|
|
573
|
+
severity: String(adv["severity"] ?? "").toLowerCase(),
|
|
574
|
+
title: String(adv["title"] ?? "(no title)"),
|
|
575
|
+
url: String(adv["url"] ?? ""),
|
|
576
|
+
});
|
|
577
|
+
}
|
|
499
578
|
|
|
500
|
-
|
|
579
|
+
return out;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Normalize an npm v7+ (`auditReportVersion` 2) report.
|
|
584
|
+
*
|
|
585
|
+
* Advisories are not a top-level map here: they are nested in
|
|
586
|
+
* `vulnerabilities[<pkg>].via[]`, where an entry is either a STRING (the name
|
|
587
|
+
* of another vulnerable package, for a transitive chain) or an advisory
|
|
588
|
+
* object. Only the objects carry an advisory; the strings are edges and are
|
|
589
|
+
* skipped, so a transitive chain is counted once at its source rather than
|
|
590
|
+
* once per hop.
|
|
591
|
+
*
|
|
592
|
+
* @param {Record<string, unknown>} report
|
|
593
|
+
* @returns {NormalizedAdvisory[]}
|
|
594
|
+
*/
|
|
595
|
+
function normalizeNpmReport(report) {
|
|
596
|
+
/** @type {Map<string, NormalizedAdvisory>} */
|
|
597
|
+
const bySource = new Map();
|
|
598
|
+
const vulnerabilities = /** @type {Record<string, unknown>} */ (report.vulnerabilities);
|
|
599
|
+
|
|
600
|
+
for (const entry of Object.values(vulnerabilities)) {
|
|
601
|
+
if (entry === null || typeof entry !== "object") {
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
const via = /** @type {Record<string, unknown>} */ (entry)["via"];
|
|
605
|
+
if (!Array.isArray(via)) {
|
|
501
606
|
continue;
|
|
502
607
|
}
|
|
503
608
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
609
|
+
for (const item of via) {
|
|
610
|
+
if (item === null || typeof item !== "object") {
|
|
611
|
+
continue; // a string edge in a transitive chain, not an advisory
|
|
612
|
+
}
|
|
613
|
+
const adv = /** @type {Record<string, unknown>} */ (item);
|
|
614
|
+
const url = String(adv["url"] ?? "");
|
|
615
|
+
const ghsaId = ghsaIdFromUrl(url);
|
|
616
|
+
const source = adv["source"] === undefined ? "" : String(adv["source"]);
|
|
617
|
+
const cveIds = Array.isArray(adv["cve"]) ? adv["cve"].map((c) => String(c)) : [];
|
|
618
|
+
const ids = [ghsaId ?? "", ...cveIds].filter(Boolean);
|
|
619
|
+
|
|
620
|
+
// Key on the advisory's own identity so one advisory reachable through
|
|
621
|
+
// several packages is reported once.
|
|
622
|
+
const key = ghsaId ?? source ?? url;
|
|
623
|
+
if (key === "" || bySource.has(key)) {
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
512
626
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
severity,
|
|
627
|
+
bySource.set(key, {
|
|
628
|
+
ids,
|
|
629
|
+
severity: String(adv["severity"] ?? "").toLowerCase(),
|
|
517
630
|
title: String(adv["title"] ?? "(no title)"),
|
|
518
|
-
url
|
|
631
|
+
url,
|
|
519
632
|
});
|
|
520
633
|
}
|
|
521
634
|
}
|
|
522
635
|
|
|
636
|
+
return [...bySource.values()];
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Positively identify a parsed audit report and normalize its advisories.
|
|
641
|
+
*
|
|
642
|
+
* **Recognition is positive, and that is the load-bearing property.** A report
|
|
643
|
+
* counts as clean only when a schema was RECOGNIZED and found nothing
|
|
644
|
+
* blocking; a report that parsed but matches nothing returns
|
|
645
|
+
* `{ schema: null }` and the caller fails closed on it regardless of the audit
|
|
646
|
+
* process's exit code. The alternative — treating "no advisories key" as "no
|
|
647
|
+
* advisories" — is how an npm report (which keeps them under
|
|
648
|
+
* `vulnerabilities`) would read as clean without ever being inspected.
|
|
649
|
+
*
|
|
650
|
+
* @param {unknown} report
|
|
651
|
+
* @returns {{ schema: "legacy" | "npm" | null; advisories: NormalizedAdvisory[] }}
|
|
652
|
+
*/
|
|
653
|
+
export function recognizeReport(report) {
|
|
654
|
+
if (report === null || typeof report !== "object") {
|
|
655
|
+
return { schema: null, advisories: [] };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const obj = /** @type {Record<string, unknown>} */ (report);
|
|
659
|
+
|
|
660
|
+
if (obj.advisories !== null && typeof obj.advisories === "object") {
|
|
661
|
+
return { schema: "legacy", advisories: normalizeLegacyReport(obj) };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (obj.vulnerabilities !== null && typeof obj.vulnerabilities === "object") {
|
|
665
|
+
return { schema: "npm", advisories: normalizeNpmReport(obj) };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return { schema: null, advisories: [] };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* True when `report` has the recognizable legacy (npm v6 / pnpm) shape: an
|
|
673
|
+
* object with an `advisories` object.
|
|
674
|
+
*
|
|
675
|
+
* Retained as the narrow legacy-shape predicate it always was. It is NOT the
|
|
676
|
+
* fail-closed discriminator any more — `recognizeReport` is, because a report
|
|
677
|
+
* this returns `false` for may still be a perfectly readable npm report.
|
|
678
|
+
*
|
|
679
|
+
* @param {unknown} report
|
|
680
|
+
* @returns {boolean}
|
|
681
|
+
*/
|
|
682
|
+
export function isInterpretableReport(report) {
|
|
683
|
+
return recognizeReport(report).schema === "legacy";
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Extract the blocking (unsuppressed High/Critical) advisories from a report
|
|
688
|
+
* in EITHER schema. An advisory is suppressed when any of its ids (GHSA or
|
|
689
|
+
* CVE) is present in `suppressed`.
|
|
690
|
+
*
|
|
691
|
+
* An unrecognized report yields an empty array here; callers MUST gate on
|
|
692
|
+
* `recognizeReport(...).schema` rather than on emptiness, which is exactly the
|
|
693
|
+
* fail-open trap `evaluateReport` closes.
|
|
694
|
+
*
|
|
695
|
+
* @param {unknown} report
|
|
696
|
+
* @param {Set<string>} suppressed active (non-expired) suppressed ids
|
|
697
|
+
* @returns {Array<{ id: string; severity: string; title: string; url: string }>}
|
|
698
|
+
*/
|
|
699
|
+
export function extractBlockingAdvisories(report, suppressed) {
|
|
700
|
+
/** @type {Array<{ id: string; severity: string; title: string; url: string }>} */
|
|
701
|
+
const blocking = [];
|
|
702
|
+
|
|
703
|
+
for (const advisory of recognizeReport(report).advisories) {
|
|
704
|
+
if (!BLOCKING_SEVERITIES.has(advisory.severity)) {
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
if (advisory.ids.some((id) => suppressed.has(id))) {
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
blocking.push({
|
|
711
|
+
id: advisory.ids[0] ?? "(unknown)",
|
|
712
|
+
severity: advisory.severity,
|
|
713
|
+
title: advisory.title,
|
|
714
|
+
url: advisory.url,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
|
|
523
718
|
return blocking;
|
|
524
719
|
}
|
|
525
720
|
|
|
526
721
|
/**
|
|
527
|
-
* Pure evaluation of a parsed audit report against the active suppression
|
|
528
|
-
*
|
|
529
|
-
* lifted out of the CLI so it is unit-testable without spawning
|
|
722
|
+
* Pure evaluation of a parsed audit report against the active suppression set
|
|
723
|
+
* and the audit process's exit code. This is the fail-closed decision core,
|
|
724
|
+
* lifted out of the CLI so it is unit-testable without spawning a package
|
|
725
|
+
* manager.
|
|
726
|
+
*
|
|
727
|
+
* An unrecognized report fails closed on ANY exit code, including zero. It
|
|
728
|
+
* used to pass on a zero exit — the branch that would have let an npm report
|
|
729
|
+
* (advisories under `vulnerabilities`, exit 0 when clean) report clean without
|
|
730
|
+
* being read at all.
|
|
731
|
+
*
|
|
732
|
+
* `_auditExitCode` is retained but no longer consulted, and deliberately so on
|
|
733
|
+
* both counts. It is retained because this function is exported from a
|
|
734
|
+
* published package, so dropping the parameter would break an importer's call.
|
|
735
|
+
* It is not consulted because the decision no longer has anything to ask it:
|
|
736
|
+
* an unrecognized report fails closed whatever the exit code, and a recognized
|
|
737
|
+
* one is judged on the advisories it actually contains. Do not reintroduce
|
|
738
|
+
* "exit code 0 means clean" — that is precisely the branch this Story removed,
|
|
739
|
+
* and `npm audit` exits 0 whenever it finds nothing, including when the gate
|
|
740
|
+
* never understood the report it was handed.
|
|
530
741
|
*
|
|
531
742
|
* @param {unknown} report parsed audit JSON (or `null`)
|
|
532
|
-
* @param {number}
|
|
743
|
+
* @param {number} _auditExitCode audit process exit code (unused — see above)
|
|
533
744
|
* @param {Set<string>} suppressed active (non-expired) suppressed ids
|
|
534
|
-
* @returns {{ exitCode: number; reason: "clean" | "uninterpretable-failclosed" | "unsuppressed" | "
|
|
745
|
+
* @returns {{ exitCode: number; reason: "clean" | "uninterpretable-failclosed" | "unsuppressed"; schema: "legacy" | "npm" | null; blocking: Array<{ id: string; severity: string; title: string; url: string }> }}
|
|
535
746
|
*/
|
|
536
|
-
export function evaluateReport(report,
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
747
|
+
export function evaluateReport(report, _auditExitCode, suppressed) {
|
|
748
|
+
const { schema } = recognizeReport(report);
|
|
749
|
+
|
|
750
|
+
if (schema === null) {
|
|
751
|
+
return {
|
|
752
|
+
exitCode: 1,
|
|
753
|
+
reason: "uninterpretable-failclosed",
|
|
754
|
+
schema,
|
|
755
|
+
blocking: [],
|
|
756
|
+
};
|
|
546
757
|
}
|
|
547
758
|
|
|
548
759
|
const blocking = extractBlockingAdvisories(report, suppressed);
|
|
549
760
|
if (blocking.length === 0) {
|
|
550
|
-
return { exitCode: 0, reason: "clean", blocking };
|
|
761
|
+
return { exitCode: 0, reason: "clean", schema, blocking };
|
|
551
762
|
}
|
|
552
|
-
return { exitCode: 1, reason: "unsuppressed", blocking };
|
|
763
|
+
return { exitCode: 1, reason: "unsuppressed", schema, blocking };
|
|
553
764
|
}
|
|
554
765
|
|
|
555
766
|
// ---------------------------------------------------------------------------
|
|
@@ -615,7 +826,7 @@ export function loadAllowlist(allowlistPath) {
|
|
|
615
826
|
* exit code (0 clean, 1 blocking) and prints what is wrong and how to fix it.
|
|
616
827
|
*
|
|
617
828
|
* Split out of `runCli` so BOTH outcomes are executable in a test: the clean
|
|
618
|
-
* path returns here without ever reaching
|
|
829
|
+
* path returns here without ever reaching the audit, which needs a real
|
|
619
830
|
* lockfile and a network. A missing package.json is not this gate's business —
|
|
620
831
|
* the audit is what proves the graph.
|
|
621
832
|
*
|
|
@@ -662,21 +873,35 @@ export function lintOverrides(packageJsonPath) {
|
|
|
662
873
|
}
|
|
663
874
|
|
|
664
875
|
/**
|
|
665
|
-
*
|
|
666
|
-
*
|
|
876
|
+
* Audit invocation per manager. Both are restricted to the PRODUCTION graph:
|
|
877
|
+
* this gate's claim is about what ships, and a dev-only advisory would make it
|
|
878
|
+
* unactionable noise. `--omit=dev` is npm's documented spelling of that.
|
|
879
|
+
*/
|
|
880
|
+
const AUDIT_COMMANDS = {
|
|
881
|
+
pnpm: "pnpm audit --prod --json",
|
|
882
|
+
npm: "npm audit --omit=dev --json",
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Run the detected manager's audit, returning raw stdout and exit code. Both
|
|
887
|
+
* managers exit non-zero when vulnerabilities are found; the JSON is wanted
|
|
667
888
|
* regardless of the exit code.
|
|
668
889
|
*
|
|
669
|
-
* @
|
|
890
|
+
* @param {"pnpm" | "npm"} manager
|
|
891
|
+
* @returns {{ command: string; output: string; exitCode: number }}
|
|
670
892
|
*/
|
|
671
|
-
function
|
|
893
|
+
function runAudit(manager) {
|
|
894
|
+
const command = AUDIT_COMMANDS[manager];
|
|
672
895
|
try {
|
|
673
|
-
const output = execSync(
|
|
674
|
-
|
|
675
|
-
});
|
|
676
|
-
return { output, exitCode: 0 };
|
|
896
|
+
const output = execSync(`${command} 2>/dev/null`, { encoding: "utf8" });
|
|
897
|
+
return { command, output, exitCode: 0 };
|
|
677
898
|
} catch (err) {
|
|
678
899
|
const execError = /** @type {{ stdout?: string; status?: number }} */ (err);
|
|
679
|
-
return {
|
|
900
|
+
return {
|
|
901
|
+
command,
|
|
902
|
+
output: execError.stdout ?? "",
|
|
903
|
+
exitCode: execError.status ?? 1,
|
|
904
|
+
};
|
|
680
905
|
}
|
|
681
906
|
}
|
|
682
907
|
|
|
@@ -745,10 +970,27 @@ export function runCli(argv) {
|
|
|
745
970
|
return 1;
|
|
746
971
|
}
|
|
747
972
|
|
|
748
|
-
// --- Run
|
|
973
|
+
// --- Run the detected manager's audit (production graph only) ------------
|
|
974
|
+
|
|
975
|
+
// --- Detect the package manager ------------------------------------------
|
|
976
|
+
//
|
|
977
|
+
// From the committed lockfile, not from `packageManager` / `engines`: the
|
|
978
|
+
// lockfile is what the audit reads, and metadata can disagree with it.
|
|
979
|
+
const detected = detectPackageManager(dirname(packageJsonPath));
|
|
980
|
+
if (detected.error) {
|
|
981
|
+
console.error(`[audit-check] ERROR: ${detected.error}`);
|
|
982
|
+
return 1;
|
|
983
|
+
}
|
|
984
|
+
const manager = detected.manager;
|
|
749
985
|
|
|
750
|
-
console.log(
|
|
751
|
-
|
|
986
|
+
console.log(
|
|
987
|
+
`[audit-check] Detected ${manager} from its lockfile; running ${AUDIT_COMMANDS[manager]} ...`,
|
|
988
|
+
);
|
|
989
|
+
const {
|
|
990
|
+
command: auditCommand,
|
|
991
|
+
output: auditOutput,
|
|
992
|
+
exitCode: auditExitCode,
|
|
993
|
+
} = runAudit(manager);
|
|
752
994
|
|
|
753
995
|
// --- Parse audit JSON ----------------------------------------------------
|
|
754
996
|
|
|
@@ -763,7 +1005,7 @@ export function runCli(argv) {
|
|
|
763
1005
|
return 0;
|
|
764
1006
|
}
|
|
765
1007
|
console.error(
|
|
766
|
-
|
|
1008
|
+
`[audit-check] ERROR: ${auditCommand} produced non-JSON output (exit code ` +
|
|
767
1009
|
auditExitCode +
|
|
768
1010
|
").",
|
|
769
1011
|
);
|
|
@@ -773,11 +1015,11 @@ export function runCli(argv) {
|
|
|
773
1015
|
|
|
774
1016
|
// --- Evaluate: fail closed on an uninterpretable report + non-zero exit --
|
|
775
1017
|
//
|
|
776
|
-
// The report parsed as JSON. If it
|
|
777
|
-
//
|
|
778
|
-
// the graph is clean
|
|
779
|
-
// the genuine
|
|
780
|
-
const { exitCode, reason, blocking } = evaluateReport(
|
|
1018
|
+
// The report parsed as JSON. If it matches NEITHER known schema — a legacy
|
|
1019
|
+
// `advisories` map or an npm `vulnerabilities` map — the gate cannot prove
|
|
1020
|
+
// the graph is clean, so it fails closed no matter what the audit exited
|
|
1021
|
+
// with. An empty-but-recognized report is the genuine clean case and passes.
|
|
1022
|
+
const { exitCode, reason, schema, blocking } = evaluateReport(
|
|
781
1023
|
report,
|
|
782
1024
|
auditExitCode,
|
|
783
1025
|
suppressed,
|
|
@@ -785,22 +1027,19 @@ export function runCli(argv) {
|
|
|
785
1027
|
|
|
786
1028
|
if (reason === "uninterpretable-failclosed") {
|
|
787
1029
|
console.error(
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
"
|
|
1030
|
+
`[audit-check] ERROR: ${auditCommand} (exit ${auditExitCode}) produced a ` +
|
|
1031
|
+
"report matching no known audit schema — neither a legacy `advisories` " +
|
|
1032
|
+
"map nor an npm `vulnerabilities` map. Failing closed: a report that " +
|
|
1033
|
+
"cannot be read cannot show the graph is clean.",
|
|
791
1034
|
);
|
|
792
1035
|
console.error(auditOutput.slice(0, 2000));
|
|
793
1036
|
return exitCode;
|
|
794
1037
|
}
|
|
795
1038
|
|
|
796
|
-
if (reason === "clean-no-advisories") {
|
|
797
|
-
console.log("[audit-check] No vulnerabilities found. Exit 0.");
|
|
798
|
-
return exitCode;
|
|
799
|
-
}
|
|
800
|
-
|
|
801
1039
|
if (blocking.length === 0) {
|
|
802
1040
|
console.log(
|
|
803
|
-
`[audit-check] No unsuppressed High/Critical vulnerabilities in the prod graph
|
|
1041
|
+
`[audit-check] No unsuppressed High/Critical vulnerabilities in the prod graph ` +
|
|
1042
|
+
`(${manager}, ${schema} schema). Exit 0.`,
|
|
804
1043
|
);
|
|
805
1044
|
return exitCode;
|
|
806
1045
|
}
|