mandrel-platform 0.29.1 → 1.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "0.29.1",
3
+ "version": "1.0.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": {
@@ -0,0 +1,489 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-osv-scan-mode.test.mjs — YAML-level regression guard for the
4
+ * diff-aware OSV wiring (Story #325).
5
+ *
6
+ * The gate LOGIC has unit coverage in `osv-report-gate.test.mjs`, but the
7
+ * logic is inert unless the workflow actually feeds it a baseline. The wiring
8
+ * is where this design silently degrades, and every degradation looks like a
9
+ * green build:
10
+ *
11
+ * • a shallow checkout → `git merge-base` and the baseline worktree cannot
12
+ * reach the fork point → every run silently gates whole-tree, and the
13
+ * incident this Story fixes comes straight back;
14
+ * • a dropped `baseline-ref` input → the composite resolves 'auto' to full
15
+ * with no error, for the same silent outcome;
16
+ * • `advisory-scan.yml` inheriting the diff-aware default → NOTHING owns
17
+ * base-branch advisories, because the PR tier deliberately stopped
18
+ * blocking on them. That is the one true regression here: a real
19
+ * main-level advisory would go completely unreported.
20
+ *
21
+ * This suite pins all three, modelled on the sibling
22
+ * `check-affected-mode.test.mjs` (read the real workflow, extract blocks by
23
+ * indentation, assert — then execute the extracted `run:` bodies against real
24
+ * bash so the fail-closed branches are proven, not just read).
25
+ *
26
+ * Run: node --test scripts/check-osv-scan-mode.test.mjs
27
+ */
28
+
29
+ import assert from "node:assert/strict";
30
+ import { test } from "node:test";
31
+ import { readFileSync, mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
32
+ import { execFileSync, spawnSync } from "node:child_process";
33
+ import { join, resolve, dirname } from "node:path";
34
+ import { fileURLToPath } from "node:url";
35
+ import { tmpdir } from "node:os";
36
+
37
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
38
+ const prQuality = readFileSync(join(repoRoot, ".github/workflows/pr-quality.yml"), "utf8");
39
+ const advisoryScan = readFileSync(join(repoRoot, ".github/workflows/advisory-scan.yml"), "utf8");
40
+ const composite = readFileSync(join(repoRoot, ".github/actions/osv-scan/action.yml"), "utf8");
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Minimal indentation-based extraction (dependency-free, mirrors
44
+ // check-affected-mode.test.mjs / check-ci-required-aggregator.test.mjs).
45
+ // ---------------------------------------------------------------------------
46
+
47
+ function stepByName(text, name) {
48
+ const lines = text.split("\n");
49
+ const nameIdx = lines.findIndex((l) => /^\s+(- )?name:\s/.test(l) && l.includes(name));
50
+ assert.notEqual(nameIdx, -1, `step "${name}" not found`);
51
+ let start = -1;
52
+ for (let i = nameIdx; i >= 0; i--) {
53
+ if (/^\s*-\s/.test(lines[i])) {
54
+ start = i;
55
+ break;
56
+ }
57
+ }
58
+ assert.notEqual(start, -1, `opening bullet for step "${name}" not found`);
59
+ const bulletIndent = lines[start].match(/^(\s*)/)[1].length;
60
+ let end = lines.length;
61
+ for (let i = start + 1; i < lines.length; i++) {
62
+ if (/^\s*$/.test(lines[i])) continue;
63
+ const indent = lines[i].match(/^(\s*)/)[1].length;
64
+ if (indent < bulletIndent) {
65
+ end = i;
66
+ break;
67
+ }
68
+ if (indent === bulletIndent && /^\s*-\s/.test(lines[i])) {
69
+ end = i;
70
+ break;
71
+ }
72
+ }
73
+ return lines.slice(start, end).join("\n");
74
+ }
75
+
76
+ /** The dedented body of a step's `run: |` block scalar. */
77
+ function runScript(stepBlock) {
78
+ const lines = stepBlock.split("\n");
79
+ const start = lines.findIndex((l) => /^\s+run:\s*\|\s*$/.test(l));
80
+ assert.notEqual(start, -1, "`run: |` block not found");
81
+ const runIndent = lines[start].match(/^(\s*)/)[1].length;
82
+ const body = [];
83
+ for (let i = start + 1; i < lines.length; i++) {
84
+ if (/^\s*$/.test(lines[i])) {
85
+ body.push("");
86
+ continue;
87
+ }
88
+ const indent = lines[i].match(/^(\s*)/)[1].length;
89
+ if (indent <= runIndent) break;
90
+ body.push(lines[i].slice(runIndent + 2));
91
+ }
92
+ return body.join("\n");
93
+ }
94
+
95
+ /**
96
+ * The body of a mapping key at a given indent, up to the next sibling key or
97
+ * end of input. Terminating on end-of-input matters: the LAST declared input
98
+ * has no following sibling, and a regex that requires one silently matches
99
+ * nothing.
100
+ */
101
+ function blockUnder(text, key, indent) {
102
+ const lines = text.split("\n");
103
+ const start = lines.findIndex((l) => l === `${" ".repeat(indent)}${key}:`);
104
+ assert.notEqual(start, -1, `key "${key}" not found at indent ${indent}`);
105
+ let end = lines.length;
106
+ for (let i = start + 1; i < lines.length; i++) {
107
+ if (/^\s*$/.test(lines[i])) continue;
108
+ // The block ends at the next sibling key (same indent) or any dedent —
109
+ // both are just "leading whitespace no deeper than the key's own".
110
+ if (lines[i].match(/^(\s*)/)[1].length <= indent) {
111
+ end = i;
112
+ break;
113
+ }
114
+ }
115
+ return lines.slice(start + 1, end).join("\n");
116
+ }
117
+
118
+ /** The `osv-scan` job block, so step lookups cannot match a same-named step elsewhere. */
119
+ function osvScanJob() {
120
+ const lines = prQuality.split("\n");
121
+ const start = lines.findIndex((l) => /^ {2}osv-scan:\s*$/.test(l));
122
+ assert.notEqual(start, -1, "osv-scan job not found");
123
+ let end = lines.length;
124
+ for (let i = start + 1; i < lines.length; i++) {
125
+ if (/^ {2}\S/.test(lines[i])) {
126
+ end = i;
127
+ break;
128
+ }
129
+ }
130
+ return lines.slice(start, end).join("\n");
131
+ }
132
+
133
+ const job = osvScanJob();
134
+
135
+ function bashAvailable() {
136
+ try {
137
+ execFileSync("bash", ["--version"], { stdio: "ignore" });
138
+ return true;
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
143
+ const exec = { skip: bashAvailable() ? false : "bash not available on this host" };
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // 1. INPUT SURFACE — the reusable workflow exposes the knobs, defaulted so the
147
+ // diff-aware behaviour is on and the grace window is off.
148
+ // ---------------------------------------------------------------------------
149
+
150
+ test("pr-quality declares osv-scan-mode defaulting to 'auto'", () => {
151
+ const block = blockUnder(prQuality, "osv-scan-mode", 6);
152
+ assert.match(block, /type:\s*string/, "osv-scan-mode must be a string input");
153
+ assert.match(
154
+ block,
155
+ /default:\s*'auto'/,
156
+ "osv-scan-mode must default to 'auto' (the diff-aware default)",
157
+ );
158
+ });
159
+
160
+ test("pr-quality declares osv-grace-days defaulting to 0 (window off)", () => {
161
+ const block = blockUnder(prQuality, "osv-grace-days", 6);
162
+ assert.match(block, /type:\s*number/, "osv-grace-days must be a number input");
163
+ assert.match(
164
+ block,
165
+ /default:\s*0\b/,
166
+ "osv-grace-days must default to 0 — no consumer's gate may soften without opting in",
167
+ );
168
+ });
169
+
170
+ test("the composite declares the diff-aware surface with matching defaults", () => {
171
+ assert.match(
172
+ blockUnder(composite, "scan-mode", 2),
173
+ /default:\s*'auto'/,
174
+ "composite scan-mode must default to 'auto'",
175
+ );
176
+ assert.match(
177
+ blockUnder(composite, "grace-days", 2),
178
+ /default:\s*'0'/,
179
+ "composite grace-days must default to '0' — the window is opt-in",
180
+ );
181
+ assert.match(
182
+ blockUnder(composite, "baseline-ref", 2),
183
+ /default:\s*''/,
184
+ "composite baseline-ref must default to empty (no baseline → whole-tree)",
185
+ );
186
+
187
+ const compositeLines = composite.split("\n");
188
+ for (const out of ["preexisting-count", "grace-count", "resolved-scan-mode", "baseline-scanned"]) {
189
+ assert.ok(
190
+ compositeLines.includes(` ${out}:`),
191
+ `composite must expose the ${out} output`,
192
+ );
193
+ }
194
+ });
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // 2. WIRING — the osv-scan job can actually reach the merge base, and forwards
198
+ // every input. A shallow checkout here degrades to whole-tree silently.
199
+ // ---------------------------------------------------------------------------
200
+
201
+ test("the osv-scan job checks out full history unless scan-mode is 'full'", () => {
202
+ const checkout = stepByName(job, "Checkout");
203
+ const m = checkout.match(/fetch-depth:\s*(.+)/);
204
+ assert.ok(m, "the osv-scan checkout must pin fetch-depth explicitly");
205
+ const expr = m[1].trim();
206
+ assert.match(
207
+ expr,
208
+ /inputs\.osv-scan-mode == 'full'/,
209
+ "fetch-depth must key off osv-scan-mode — a shallow clone cannot reach the merge base",
210
+ );
211
+ // The strings are load-bearing: a bare numeric 0 is FALSY in a GitHub
212
+ // expression, so `... && 0 || 1` collapses to 1 and silently re-shallows.
213
+ assert.match(expr, /'1'/, "the full-mode arm must be the STRING '1'");
214
+ assert.match(expr, /'0'/, "the diff-mode arm must be the STRING '0'");
215
+ assert.match(checkout, /persist-credentials:\s*false/, "must not persist the token");
216
+ });
217
+
218
+ for (const step of [
219
+ "Checkout diff-range resolver (mandrel-platform@resolved-sha)",
220
+ "Resolve OSV diff baseline (merge base)",
221
+ ]) {
222
+ test(`"${step}" is skipped only under scan-mode: full`, () => {
223
+ const block = stepByName(job, step);
224
+ const m = block.match(/^\s+if:\s*(.+?)\s*$/m);
225
+ assert.ok(m, `step "${step}" has no if: condition`);
226
+ assert.match(
227
+ m[1],
228
+ /inputs\.osv-scan-mode != 'full'/,
229
+ "the baseline steps must run for every mode except full",
230
+ );
231
+ });
232
+ }
233
+
234
+ test("the diff-range resolver is side-checked-out from the pinned platform SHA", () => {
235
+ const block = stepByName(job, "Checkout diff-range resolver (mandrel-platform@resolved-sha)");
236
+ assert.match(block, /repository:\s*dsj1984\/mandrel-platform/);
237
+ assert.match(
238
+ block,
239
+ /job_workflow_sha/,
240
+ "must resolve THIS repo at the SHA the caller pinned, not a floating ref",
241
+ );
242
+ assert.match(
243
+ block,
244
+ /scripts\/resolve-diff-range\.sh/,
245
+ "must reuse the canonical derivation rather than re-deriving base/head",
246
+ );
247
+ });
248
+
249
+ test("the composite step forwards scan-mode, baseline-ref and grace-days", () => {
250
+ const step = stepByName(job, "OSV advisory scan (pinned binary)");
251
+ assert.match(step, /scan-mode:\s*\$\{\{\s*inputs\.osv-scan-mode\s*\}\}/);
252
+ assert.match(
253
+ step,
254
+ /baseline-ref:\s*\$\{\{\s*steps\.osv-baseline\.outputs\.baseline-ref\s*\}\}/,
255
+ "the resolved merge base must reach the composite, or 'auto' silently degrades to full",
256
+ );
257
+ assert.match(step, /grace-days:\s*\$\{\{\s*inputs\.osv-grace-days\s*\}\}/);
258
+ // The existing contract must survive.
259
+ assert.match(step, /fail-on-severity:\s*\$\{\{\s*inputs\.osv-fail-on-severity\s*\}\}/);
260
+ assert.match(step, /allowlist-path:\s*\$\{\{\s*inputs\.osv-allowlist-path\s*\}\}/);
261
+ });
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // 3. THE SCHEDULED OWNER — advisory-scan.yml must stay whole-tree. This is the
265
+ // regression that would make the whole design unsafe.
266
+ // ---------------------------------------------------------------------------
267
+
268
+ test("advisory-scan pins scan-mode: full and cannot inherit the diff-aware default", () => {
269
+ const step = stepByName(advisoryScan, "OSV advisory scan (scheduled, non-blocking)");
270
+ assert.match(
271
+ step,
272
+ /scan-mode:\s*'full'/,
273
+ "the scheduled scan owns base-branch advisories — diff-aware there would leave them unowned",
274
+ );
275
+ });
276
+
277
+ test("advisory-scan still reports findings and still upserts its tracking issue", () => {
278
+ const step = stepByName(advisoryScan, "OSV advisory scan (scheduled, non-blocking)");
279
+ assert.match(step, /non-blocking:\s*'true'/, "the tracking issue is the signal, not a red job");
280
+ assert.match(step, /findings-out:/, "the upsert needs the machine-readable findings");
281
+ assert.match(
282
+ advisoryScan,
283
+ /actions\/osv-track-issue@/,
284
+ "the tracking-issue upsert step must still run",
285
+ );
286
+ assert.match(advisoryScan, /issues:\s*write/, "the upsert needs issues: write");
287
+ });
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // 4. EXECUTE the composite's mode-resolution prologue. 'auto' with no baseline
291
+ // and 'diff' with no baseline must BOTH land on full (blocking) — the
292
+ // fail-closed direction.
293
+ // ---------------------------------------------------------------------------
294
+
295
+ const compositeRun = runScript(stepByName(composite, "OSV advisory scan (pinned binary)"));
296
+ // The prologue is everything before the binary download begins.
297
+ const MODE_PROLOGUE = compositeRun.split('\nos="$(uname -s)"')[0];
298
+
299
+ function resolveMode({ mode, baselineRef = "" }) {
300
+ const dir = mkdtempSync(join(tmpdir(), "osv-mode-"));
301
+ try {
302
+ const scriptFile = join(dir, "prologue.sh");
303
+ writeFileSync(scriptFile, MODE_PROLOGUE);
304
+ const outFile = join(dir, "github_output");
305
+ writeFileSync(outFile, "");
306
+ const r = spawnSync("bash", [scriptFile], {
307
+ encoding: "utf8",
308
+ env: {
309
+ ...process.env,
310
+ GITHUB_OUTPUT: outFile,
311
+ OSV_SCAN_MODE: mode,
312
+ OSV_BASELINE_REF: baselineRef,
313
+ },
314
+ });
315
+ return {
316
+ status: r.status,
317
+ stdout: r.stdout,
318
+ outputs: readFileSync(outFile, "utf8"),
319
+ };
320
+ } finally {
321
+ rmSync(dir, { recursive: true, force: true });
322
+ }
323
+ }
324
+
325
+ test("scan-mode 'full' never resolves to diff, even with a baseline available", exec, () => {
326
+ const r = resolveMode({ mode: "full", baselineRef: "deadbeef" });
327
+ assert.equal(r.status, 0, r.stdout);
328
+ assert.match(r.outputs, /^resolved-scan-mode=full$/m);
329
+ });
330
+
331
+ test("scan-mode 'auto' resolves to diff when a baseline was supplied", exec, () => {
332
+ const r = resolveMode({ mode: "auto", baselineRef: "deadbeef" });
333
+ assert.equal(r.status, 0, r.stdout);
334
+ assert.match(r.outputs, /^resolved-scan-mode=diff$/m);
335
+ });
336
+
337
+ test("scan-mode 'auto' falls back to full when no baseline is resolvable", exec, () => {
338
+ // push / schedule / a merge-queue-less event resolve no base — today's
339
+ // whole-tree behaviour must survive untouched there.
340
+ const r = resolveMode({ mode: "auto", baselineRef: "" });
341
+ assert.equal(r.status, 0, r.stdout);
342
+ assert.match(r.outputs, /^resolved-scan-mode=full$/m);
343
+ });
344
+
345
+ test("scan-mode 'diff' with no baseline degrades to full and says so", exec, () => {
346
+ const r = resolveMode({ mode: "diff", baselineRef: "" });
347
+ assert.equal(r.status, 0, r.stdout);
348
+ assert.match(
349
+ r.outputs,
350
+ /^resolved-scan-mode=full$/m,
351
+ "an unbaselined diff run must BLOCK whole-tree, never pass everything as pre-existing",
352
+ );
353
+ assert.match(r.stdout, /::warning::/, "the degradation must be visible, not silent");
354
+ });
355
+
356
+ test("an invalid scan-mode fails the step closed", exec, () => {
357
+ const r = resolveMode({ mode: "sideways", baselineRef: "deadbeef" });
358
+ assert.notEqual(r.status, 0, "an unrecognized mode must not fall through to a default");
359
+ assert.doesNotMatch(r.outputs, /resolved-scan-mode=/, "no partial output on a rejected mode");
360
+ });
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // 5. EXECUTE the baseline-resolution run script against real bash + real git,
364
+ // so the merge-base shaping and the injection guard are proven.
365
+ // ---------------------------------------------------------------------------
366
+
367
+ const BASELINE_SCRIPT = runScript(stepByName(job, "Resolve OSV diff baseline (merge base)"));
368
+
369
+ /**
370
+ * Run the extracted baseline step with a stubbed resolve-diff-range.sh (the
371
+ * sourced derivation), inside a real throwaway git repo so `git merge-base`
372
+ * resolves for the pull_request shaping.
373
+ */
374
+ function runBaseline({ mode = "", base = "", head = "", realRepo = false } = {}) {
375
+ const dir = mkdtempSync(join(tmpdir(), "osv-baseline-"));
376
+ try {
377
+ const workspace = join(dir, "ws");
378
+ mkdirSync(join(workspace, "_mandrel-platform-osv-range", "scripts"), { recursive: true });
379
+ writeFileSync(
380
+ join(workspace, "_mandrel-platform-osv-range", "scripts", "resolve-diff-range.sh"),
381
+ [
382
+ 'RESOLVED_EVENT_MODE="${STUB_MODE-}"',
383
+ 'RESOLVED_BASE_SHA="${STUB_BASE-}"',
384
+ 'RESOLVED_HEAD_SHA="${STUB_HEAD-}"',
385
+ "",
386
+ ].join("\n"),
387
+ );
388
+
389
+ let baseSha = base;
390
+ let headSha = head;
391
+ if (realRepo) {
392
+ const git = (...args) =>
393
+ execFileSync(
394
+ "git",
395
+ ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args],
396
+ { cwd: workspace, encoding: "utf8" },
397
+ ).trim();
398
+ git("init", "-q", "-b", "main");
399
+ writeFileSync(join(workspace, "a.txt"), "a\n");
400
+ git("add", "a.txt");
401
+ git("commit", "-qm", "root");
402
+ const fork = git("rev-parse", "HEAD");
403
+ // Advance main past the fork point — this drift is exactly why the PR
404
+ // shaping takes the merge base rather than base.sha.
405
+ writeFileSync(join(workspace, "b.txt"), "b\n");
406
+ git("add", "b.txt");
407
+ git("commit", "-qm", "main advances");
408
+ baseSha = git("rev-parse", "HEAD");
409
+ git("checkout", "-q", "-b", "feature", fork);
410
+ writeFileSync(join(workspace, "c.txt"), "c\n");
411
+ git("add", "c.txt");
412
+ git("commit", "-qm", "feature work");
413
+ headSha = git("rev-parse", "HEAD");
414
+ // The merge base of base..head is the fork point, NOT the advanced tip.
415
+ return { ...spawnBaseline(), forkSha: fork };
416
+ }
417
+ return spawnBaseline();
418
+
419
+ function spawnBaseline() {
420
+ const scriptFile = join(dir, "baseline.sh");
421
+ writeFileSync(scriptFile, BASELINE_SCRIPT);
422
+ const outFile = join(dir, "github_output");
423
+ writeFileSync(outFile, "");
424
+ const r = spawnSync("bash", [scriptFile], {
425
+ cwd: workspace,
426
+ encoding: "utf8",
427
+ env: {
428
+ ...process.env,
429
+ GITHUB_WORKSPACE: workspace,
430
+ GITHUB_OUTPUT: outFile,
431
+ STUB_MODE: mode,
432
+ STUB_BASE: baseSha,
433
+ STUB_HEAD: headSha,
434
+ },
435
+ });
436
+ return {
437
+ status: r.status,
438
+ stdout: r.stdout,
439
+ stderr: r.stderr,
440
+ outputs: readFileSync(outFile, "utf8"),
441
+ };
442
+ }
443
+ } finally {
444
+ rmSync(dir, { recursive: true, force: true });
445
+ }
446
+ }
447
+
448
+ test("mode=none exports an EMPTY baseline-ref rather than skipping the output", exec, () => {
449
+ const r = runBaseline({ mode: "" });
450
+ assert.equal(r.status, 0, r.stderr);
451
+ assert.match(
452
+ r.outputs,
453
+ /^baseline-ref=$/m,
454
+ "an unresolvable base must still emit the (empty) output — the composite reads that as 'gate whole-tree'",
455
+ );
456
+ });
457
+
458
+ test("pull_request baselines on the MERGE BASE, not the drifted base tip", exec, () => {
459
+ const r = runBaseline({ mode: "pull_request", realRepo: true });
460
+ assert.equal(r.status, 0, r.stderr);
461
+ const m = r.outputs.match(/^baseline-ref=(.+)$/m);
462
+ assert.ok(m, "no baseline-ref emitted");
463
+ assert.equal(
464
+ m[1],
465
+ r.forkSha,
466
+ "must be the fork point — base.sha drifts forward once main advances, which would baseline against commits the PR never saw",
467
+ );
468
+ });
469
+
470
+ for (const mode of ["merge_group", "push"]) {
471
+ test(`${mode} baselines on the derived base directly (already the fork point)`, exec, () => {
472
+ const r = runBaseline({ mode, base: "cafebabe", head: "deadbeef" });
473
+ assert.equal(r.status, 0, r.stderr);
474
+ assert.match(r.outputs, /^baseline-ref=cafebabe$/m);
475
+ });
476
+ }
477
+
478
+ test("a newline-bearing base is rejected before it reaches $GITHUB_OUTPUT", exec, () => {
479
+ const r = runBaseline({ mode: "push", base: "cafebabe\nMALICIOUS=pwned", head: "deadbeef" });
480
+ assert.notEqual(r.status, 0, "a multiline base must fail the step, not export");
481
+ assert.doesNotMatch(r.outputs, /MALICIOUS/, "the injected line must never reach $GITHUB_OUTPUT");
482
+ assert.doesNotMatch(r.outputs, /baseline-ref=/, "no partial export on a rejected base");
483
+ });
484
+
485
+ test("a carriage-return-bearing base is rejected too", exec, () => {
486
+ const r = runBaseline({ mode: "push", base: "cafebabe\rMALICIOUS=pwned", head: "deadbeef" });
487
+ assert.notEqual(r.status, 0);
488
+ assert.doesNotMatch(r.outputs, /MALICIOUS/);
489
+ });
@@ -16,17 +16,25 @@ import {
16
16
  classify,
17
17
  findingsDigest,
18
18
  renderSummary,
19
+ normalizeSource,
20
+ rowKey,
21
+ buildBaselineSet,
19
22
  OsvGateError,
20
23
  } from "../.github/actions/osv-scan/osv-report-gate.mjs";
21
24
 
22
- // Build an OSV-scanner-shaped report for one grouped advisory.
23
- const reportWith = (groups) => ({
25
+ // Build an OSV-scanner-shaped report for one grouped advisory. `published`
26
+ // rides on the per-vulnerability entries, mirroring the real OSV schema —
27
+ // group rows carry the severity, never the date.
28
+ const reportWith = (groups, { sourcePath = "pnpm-lock.yaml" } = {}) => ({
24
29
  results: [
25
30
  {
26
- source: { path: "pnpm-lock.yaml" },
31
+ source: { path: sourcePath },
27
32
  packages: groups.map((g) => ({
28
33
  package: { name: g.name, version: g.version || "1.0.0", ecosystem: g.ecosystem || "npm" },
29
34
  groups: [{ ids: g.ids, max_severity: g.score }],
35
+ ...(g.published
36
+ ? { vulnerabilities: g.ids.map((id) => ({ id, published: g.published })) }
37
+ : {}),
30
38
  })),
31
39
  },
32
40
  ],
@@ -225,3 +233,307 @@ test("renderSummary reports a clean scan and a blocked scan distinctly", () => {
225
233
  assert.match(blocked.join("\n"), /❌ BLOCKED/);
226
234
  assert.match(blocked.join("\n"), /GHSA-x/);
227
235
  });
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Diff-aware baseline + publish grace window (Story #325)
239
+ //
240
+ // The failure these close: a newly-published advisory against a dependency
241
+ // that has been on `main` for weeks reds EVERY open PR simultaneously — the
242
+ // postcss GHSA-r28c-9q8g-f849 / brace-expansion GHSA-mh99-v99m-4gvg incidents
243
+ // on the swarm-os consumer. The gate must tell "this PR introduced it" from
244
+ // "this was already here", without ever letting a real PR-introduced advisory
245
+ // through and without neutering the operator-authored `revisitBy` re-gate.
246
+ // ---------------------------------------------------------------------------
247
+
248
+ // A baseline built from the SAME tree, as the merge-base worktree scan yields.
249
+ const baselineOf = (groups, opts) => buildBaselineSet(collectRows(reportWith(groups, opts), opts));
250
+
251
+ test("a finding already present at the baseline is demoted, not blocked", () => {
252
+ const groups = [{ name: "postcss", ids: ["GHSA-r28c-9q8g-f849"], score: "7.5" }];
253
+ const v = classify(collectRows(reportWith(groups)), {
254
+ failOn: "high",
255
+ baseline: baselineOf(groups),
256
+ });
257
+ assert.equal(v.blocking.length, 0);
258
+ assert.equal(v.preexisting.length, 1);
259
+ assert.equal(v.preexisting[0].ids[0], "GHSA-r28c-9q8g-f849");
260
+ });
261
+
262
+ test("a head-only finding is PR-introduced and still blocks", () => {
263
+ const v = classify(
264
+ collectRows(
265
+ reportWith([
266
+ { name: "postcss", ids: ["GHSA-r28c-9q8g-f849"], score: "7.5" },
267
+ { name: "brand-new-dep", ids: ["GHSA-new"], score: "8.2" },
268
+ ]),
269
+ ),
270
+ {
271
+ failOn: "high",
272
+ baseline: baselineOf([{ name: "postcss", ids: ["GHSA-r28c-9q8g-f849"], score: "7.5" }]),
273
+ },
274
+ );
275
+ assert.equal(v.blocking.length, 1);
276
+ assert.equal(v.blocking[0].name, "brand-new-dep");
277
+ assert.equal(v.preexisting.length, 1);
278
+ assert.equal(v.preexisting[0].name, "postcss");
279
+ });
280
+
281
+ test("bumping an advisory-bearing dep to another vulnerable version still blocks", () => {
282
+ // Same package, same advisory id — only the version moved. The baseline key
283
+ // carries @version precisely so this cannot inherit the demotion.
284
+ const v = classify(
285
+ collectRows(reportWith([{ name: "postcss", ids: ["GHSA-r28c"], score: "7.5", version: "8.4.0" }])),
286
+ {
287
+ failOn: "high",
288
+ baseline: baselineOf([
289
+ { name: "postcss", ids: ["GHSA-r28c"], score: "7.5", version: "8.3.0" },
290
+ ]),
291
+ },
292
+ );
293
+ assert.equal(v.blocking.length, 1);
294
+ assert.equal(v.blocking[0].version, "8.4.0");
295
+ assert.equal(v.preexisting.length, 0);
296
+ });
297
+
298
+ test("normalizeSource reduces worktree-rooted and workspace-rooted paths alike", () => {
299
+ assert.equal(normalizeSource("/tmp/osv-baseline/pnpm-lock.yaml", "/tmp/osv-baseline"), "pnpm-lock.yaml");
300
+ assert.equal(normalizeSource("/home/runner/work/repo/pnpm-lock.yaml", "/home/runner/work/repo"), "pnpm-lock.yaml");
301
+ assert.equal(normalizeSource("./pnpm-lock.yaml", "/tmp/osv-baseline"), "pnpm-lock.yaml");
302
+ assert.equal(normalizeSource("pnpm-lock.yaml", ""), "pnpm-lock.yaml");
303
+ // A trailing slash on the root must not leave a leading slash behind.
304
+ assert.equal(normalizeSource("/tmp/base/apps/web/pnpm-lock.yaml", "/tmp/base/"), "apps/web/pnpm-lock.yaml");
305
+ });
306
+
307
+ test("the baseline matches across differing scan roots", () => {
308
+ // The head scan runs in the workspace; the baseline scan runs in a git
309
+ // worktree at a different absolute path. Un-normalized, every head finding
310
+ // would look head-only and the diff-aware gate would block everything.
311
+ const groups = [{ name: "postcss", ids: ["GHSA-r28c"], score: "7.5" }];
312
+ const headRows = collectRows(
313
+ reportWith(groups, { sourcePath: "/home/runner/work/repo/pnpm-lock.yaml" }),
314
+ { scanRoot: "/home/runner/work/repo" },
315
+ );
316
+ const baseRows = collectRows(reportWith(groups, { sourcePath: "/tmp/osv-baseline/pnpm-lock.yaml" }), {
317
+ scanRoot: "/tmp/osv-baseline",
318
+ });
319
+ assert.equal(rowKey(headRows[0]), rowKey(baseRows[0]));
320
+
321
+ const v = classify(headRows, { failOn: "high", baseline: buildBaselineSet(baseRows) });
322
+ assert.equal(v.blocking.length, 0);
323
+ assert.equal(v.preexisting.length, 1);
324
+ });
325
+
326
+ test("an EXPIRED suppression outranks BOTH demotions and still re-gates", () => {
327
+ // The load-bearing precedence rule. An expired suppression is by
328
+ // construction on a pre-existing dependency, so letting either demotion
329
+ // apply would neuter `revisitBy` on PRs entirely.
330
+ //
331
+ // The fixture must be genuinely eligible for both demotions or this test
332
+ // passes vacuously: `published` is what puts the row inside the window, and
333
+ // without it `grace` is empty no matter how the precedence chain is wired.
334
+ const groups = [
335
+ {
336
+ name: "brace-expansion",
337
+ ids: ["GHSA-mh99-v99m-4gvg"],
338
+ score: "7.5",
339
+ published: "2026-07-22T00:00:00Z",
340
+ },
341
+ ];
342
+ const rows = collectRows(reportWith(groups));
343
+ const baseline = baselineOf(groups);
344
+ const opts = { failOn: "high", today: "2026-07-24", baseline, graceDays: 30 };
345
+
346
+ // Guard the fixture: with NO allow-list entry this row is demoted. If this
347
+ // ever stops holding, the assertion below has nothing left to prove.
348
+ const unsuppressed = classify(rows, opts);
349
+ assert.equal(unsuppressed.blocking.length, 0, "fixture must be demotable without an allow-list");
350
+ assert.equal(unsuppressed.preexisting.length, 1, "fixture must be baseline-eligible");
351
+ assert.notEqual(rows[0].published, null, "fixture must carry a publish date to be grace-eligible");
352
+
353
+ const v = classify(rows, {
354
+ ...opts,
355
+ allowlist: [{ id: "GHSA-mh99-v99m-4gvg", reason: "stale triage", revisitBy: "2026-01-01" }],
356
+ });
357
+ assert.equal(v.blocking.length, 1);
358
+ assert.equal(v.expired.length, 1);
359
+ assert.equal(v.preexisting.length, 0);
360
+ assert.equal(v.grace.length, 0);
361
+ });
362
+
363
+ test("an UNEXPIRED suppression stays suppressed and is not double-counted", () => {
364
+ const groups = [{ name: "brace-expansion", ids: ["GHSA-mh99-v99m-4gvg"], score: "7.5" }];
365
+ const v = classify(collectRows(reportWith(groups)), {
366
+ failOn: "high",
367
+ allowlist: [{ id: "GHSA-mh99-v99m-4gvg", reason: "not reachable", revisitBy: "2099-12-31" }],
368
+ today: "2026-07-24",
369
+ baseline: baselineOf(groups),
370
+ });
371
+ assert.equal(v.suppressed.length, 1);
372
+ assert.equal(v.blocking.length, 0);
373
+ assert.equal(v.preexisting.length, 0);
374
+ });
375
+
376
+ test("the grace window demotes a recent advisory and not an old one", () => {
377
+ const rows = collectRows(
378
+ reportWith([
379
+ { name: "fresh", ids: ["GHSA-fresh"], score: "8.0", published: "2026-07-21T00:00:00Z" },
380
+ { name: "stale", ids: ["GHSA-stale"], score: "8.0", published: "2026-06-24T00:00:00Z" },
381
+ ]),
382
+ );
383
+ const v = classify(rows, { failOn: "high", graceDays: 7, today: "2026-07-24" });
384
+ assert.deepEqual(
385
+ v.grace.map((r) => r.name),
386
+ ["fresh"],
387
+ );
388
+ assert.deepEqual(
389
+ v.blocking.map((r) => r.name),
390
+ ["stale"],
391
+ );
392
+ });
393
+
394
+ test("the grace window fails closed when the publish date is unresolvable", () => {
395
+ // No `published` on the vulnerability entries at all…
396
+ const undated = classify(collectRows(reportWith([{ name: "p", ids: ["GHSA-x"], score: "8.0" }])), {
397
+ failOn: "high",
398
+ graceDays: 7,
399
+ today: "2026-07-24",
400
+ });
401
+ assert.equal(undated.blocking.length, 1);
402
+ assert.equal(undated.grace.length, 0);
403
+
404
+ // …and a present-but-garbage date is equally not a free pass.
405
+ const garbage = classify(
406
+ collectRows(reportWith([{ name: "p", ids: ["GHSA-x"], score: "8.0", published: "not-a-date" }])),
407
+ { failOn: "high", graceDays: 7, today: "2026-07-24" },
408
+ );
409
+ assert.equal(garbage.blocking.length, 1);
410
+ assert.equal(garbage.grace.length, 0);
411
+ });
412
+
413
+ test("the grace window is off at the default of 0", () => {
414
+ const rows = collectRows(
415
+ reportWith([
416
+ { name: "fresh", ids: ["GHSA-fresh"], score: "8.0", published: "2026-07-23T00:00:00Z" },
417
+ ]),
418
+ );
419
+ const v = classify(rows, { failOn: "high", today: "2026-07-24" });
420
+ assert.equal(v.graceDays, 0);
421
+ assert.equal(v.grace.length, 0);
422
+ assert.equal(v.blocking.length, 1);
423
+ });
424
+
425
+ test("collectRows takes the EARLIEST published date across a group's aliased ids", () => {
426
+ const report = {
427
+ results: [
428
+ {
429
+ source: { path: "pnpm-lock.yaml" },
430
+ packages: [
431
+ {
432
+ package: { name: "p", version: "1.0.0", ecosystem: "npm" },
433
+ groups: [{ ids: ["GHSA-a", "CVE-b"], max_severity: "8.0" }],
434
+ vulnerabilities: [
435
+ { id: "GHSA-a", published: "2026-07-20T00:00:00Z" },
436
+ { id: "CVE-b", published: "2026-05-01T00:00:00Z" },
437
+ ],
438
+ },
439
+ ],
440
+ },
441
+ ],
442
+ };
443
+ assert.equal(collectRows(report)[0].published, "2026-05-01T00:00:00Z");
444
+
445
+ // …and the earliest date is what the window is judged against, so an alias
446
+ // published long ago cannot be laundered into the window by a fresh alias.
447
+ const v = classify(collectRows(report), { failOn: "high", graceDays: 7, today: "2026-07-24" });
448
+ assert.equal(v.blocking.length, 1);
449
+ assert.equal(v.grace.length, 0);
450
+ });
451
+
452
+ test("with no baseline and no grace window the partition is unchanged", () => {
453
+ // The backward-compatibility contract: default inputs must classify exactly
454
+ // as they did before diff-awareness existed.
455
+ const rows = collectRows(
456
+ reportWith([
457
+ { name: "crit", ids: ["C"], score: "9.9" },
458
+ { name: "hi", ids: ["H"], score: "7.1" },
459
+ { name: "med", ids: ["M"], score: "4.5" },
460
+ { name: "sup", ids: ["S"], score: "8.0" },
461
+ ]),
462
+ );
463
+ const allowlist = [{ id: "S", reason: "triaged", revisitBy: "2099-12-31" }];
464
+ const v = classify(rows, { failOn: "high", allowlist, today: "2026-07-24" });
465
+
466
+ assert.deepEqual(
467
+ v.blocking.map((r) => r.name),
468
+ ["crit", "hi"],
469
+ );
470
+ assert.deepEqual(
471
+ v.warning.map((r) => r.name),
472
+ ["med"],
473
+ );
474
+ assert.equal(v.suppressed.length, 1);
475
+ assert.equal(v.expired.length, 0);
476
+ // The new buckets exist but are inert.
477
+ assert.deepEqual(v.preexisting, []);
478
+ assert.deepEqual(v.grace, []);
479
+ assert.equal(v.baselineApplied, false);
480
+ });
481
+
482
+ test("findingsDigest ignores preexisting and grace rows entirely", () => {
483
+ // The scheduled tracking issue keys off this digest; a PR-side demotion must
484
+ // not rewrite the issue body or make an unchanged advisory set look new.
485
+ const groups = [
486
+ { name: "p1", ids: ["GHSA-a"], score: "7.5" },
487
+ { name: "p2", ids: ["GHSA-b"], score: "9.1", published: "2026-07-23T00:00:00Z" },
488
+ ];
489
+ const plain = classify(collectRows(reportWith(groups)), { failOn: "high", today: "2026-07-24" });
490
+ const demoted = classify(collectRows(reportWith(groups)), {
491
+ failOn: "high",
492
+ today: "2026-07-24",
493
+ baseline: baselineOf([groups[0]]),
494
+ graceDays: 7,
495
+ });
496
+
497
+ assert.equal(demoted.blocking.length, 0);
498
+ assert.equal(demoted.preexisting.length, 1);
499
+ assert.equal(demoted.grace.length, 1);
500
+ assert.equal(findingsDigest(demoted.blocking), findingsDigest([]));
501
+ assert.notEqual(findingsDigest(plain.blocking), findingsDigest(demoted.blocking));
502
+ });
503
+
504
+ test("renderSummary names both demotion buckets with one table row each", () => {
505
+ const groups = [
506
+ { name: "postcss", ids: ["GHSA-r28c-9q8g-f849"], score: "7.5" },
507
+ { name: "fresh", ids: ["GHSA-fresh"], score: "8.0", published: "2026-07-23T00:00:00Z" },
508
+ ];
509
+ const v = classify(collectRows(reportWith(groups)), {
510
+ failOn: "high",
511
+ today: "2026-07-24",
512
+ baseline: baselineOf([groups[0]]),
513
+ graceDays: 7,
514
+ });
515
+ const out = renderSummary(v).join("\n");
516
+
517
+ assert.match(out, /Pre-existing on the base branch — not introduced by this PR \(1\)/);
518
+ assert.match(out, /Within the publish grace window \(1\)/);
519
+ assert.match(out, /GHSA-r28c-9q8g-f849/);
520
+ assert.match(out, /GHSA-fresh/);
521
+ // Demotions are not a pass-with-nothing-to-say: the verdict line must not
522
+ // claim BLOCKED when everything was demoted.
523
+ assert.doesNotMatch(out, /❌ BLOCKED/);
524
+ // The grace table carries the publish date that justified the demotion.
525
+ assert.match(out, /\| 2026-07-23T00:00:00Z \|/);
526
+ });
527
+
528
+ test("a fully-demoted verdict set is not rendered as a clean scan", () => {
529
+ // Demoted findings still have to be visible — silently reporting "no known
530
+ // advisories" would hide exactly what the diff-aware mode chose not to gate.
531
+ const groups = [{ name: "postcss", ids: ["GHSA-r28c"], score: "7.5" }];
532
+ const v = classify(collectRows(reportWith(groups)), {
533
+ failOn: "high",
534
+ baseline: baselineOf(groups),
535
+ });
536
+ const out = renderSummary(v).join("\n");
537
+ assert.doesNotMatch(out, /no known advisories/);
538
+ assert.match(out, /GHSA-r28c/);
539
+ });