mandrel-platform 1.13.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -504,7 +504,9 @@ override, and vice versa:
504
504
  just fixable ones. The two managers report in different schemas (a legacy
505
505
  `advisories` map; npm v7+ nests advisories under `vulnerabilities`), and
506
506
  both are read — a report matching **neither** fails the gate closed rather
507
- than reading as clean.
507
+ than reading as clean, as does output that does not parse as JSON at all
508
+ (an audit that never ran exits 0 with an empty stdout, which is not a
509
+ clean graph).
508
510
  2. **Unbounded-override lint.** Blocks on any dependency override written
509
511
  without an upper bound — **independently of the CVE scan, and with zero
510
512
  CVEs present**. It runs *first*, before any audit is invoked at all.
@@ -516,7 +518,9 @@ override, and vice versa:
516
518
  Known/accepted CVEs are suppressed via a **dated, self-expiring allowlist**
517
519
  (`audit-allowlist.json` in the project root). Expired entries are treated as
518
520
  un-suppressed and cause the script to exit non-zero — forcing teams to
519
- periodically re-evaluate accepted risk.
521
+ periodically re-evaluate accepted risk. Advisory ids compare
522
+ case-insensitively, so the canonical `GHSA-7w5x-hrqm-74c2` form GitHub renders
523
+ suppresses under either package manager.
520
524
 
521
525
  **Consumer usage (`package.json`):**
522
526
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "1.13.0",
3
+ "version": "1.13.1",
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": {
@@ -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
- * That last clause is load-bearing. The two managers report differently — a
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, or the audit report
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-xxxx-xxxx-xxxx", // GitHub Advisory ID or CVE ID
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 { execSync } from "node:child_process";
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.toUpperCase()
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 = String(adv["ghsa_id"] ?? "");
569
- const cveIds = Array.isArray(adv["cve"]) ? adv["cve"].map((c) => String(c)) : [];
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
- const cveIds = Array.isArray(adv["cve"]) ? adv["cve"].map((c) => String(c)) : [];
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
- const key = ghsaId ?? source ?? url;
623
- if (key === "" || bySource.has(key)) {
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. 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.
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
- * 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
888
- * regardless of the exit code.
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 {{ command: string; output: string; exitCode: number }}
989
+ * @returns {string}
892
990
  */
893
- function runAudit(manager) {
894
- const command = AUDIT_COMMANDS[manager];
895
- try {
896
- const output = execSync(`${command} 2>/dev/null`, { encoding: "utf8" });
897
- return { command, output, exitCode: 0 };
898
- } catch (err) {
899
- const execError = /** @type {{ stdout?: string; status?: number }} */ (err);
900
- return {
901
- command,
902
- output: execError.stdout ?? "",
903
- exitCode: execError.status ?? 1,
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 detected = detectPackageManager(dirname(packageJsonPath));
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 ${AUDIT_COMMANDS[manager]} ...`,
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
- if (auditExitCode === 0) {
1003
- // No JSON and a clean exit means nothing to audit — clean.
1004
- console.log("[audit-check] No vulnerabilities found. Exit 0.");
1005
- return 0;
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} produced non-JSON output (exit code ` +
1009
- auditExitCode +
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
- console.error(auditOutput.slice(0, 2000));
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
  }