mandrel-platform 1.1.0 → 1.3.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.
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * yaml-step.test.mjs — node:test suite for the shared YAML step extractor
4
+ * (Story #377).
5
+ *
6
+ * These two helpers decide WHICH BYTES five other suites execute, so they are
7
+ * the one piece of test infrastructure in this repository that needs tests of
8
+ * its own: a silent mis-extraction hands every caller a different script than
9
+ * the one under review, and every caller still reports green.
10
+ *
11
+ * The cases below are therefore about the boundaries — where a step block
12
+ * ends, where a block scalar ends, and what happens when the lookup misses —
13
+ * not about the happy path the callers already cover.
14
+ *
15
+ * Run: node --test scripts/lib/yaml-step.test.mjs
16
+ */
17
+
18
+ import assert from "node:assert/strict";
19
+ import { test } from "node:test";
20
+
21
+ import { stepByName, runScript } from "./yaml-step.mjs";
22
+
23
+ /**
24
+ * A workflow fragment with the shapes that matter: two sibling steps, a nested
25
+ * `with:` mapping, a `run: |` block scalar containing a blank line and its own
26
+ * deeper indentation, and a following job key at a shallower indent.
27
+ */
28
+ const WORKFLOW = [
29
+ "jobs:",
30
+ " build:",
31
+ " steps:",
32
+ " - name: Checkout",
33
+ " uses: actions/checkout@v4",
34
+ " with:",
35
+ " fetch-depth: 0",
36
+ "",
37
+ " - name: Run the thing",
38
+ " env:",
39
+ " MODE: strict",
40
+ " run: |",
41
+ " set -eu",
42
+ ' if [ "$MODE" = strict ]; then',
43
+ ' echo "strict"',
44
+ " fi",
45
+ "",
46
+ ' echo "done"',
47
+ "",
48
+ " - name: After",
49
+ ' run: echo "after"',
50
+ "",
51
+ " publish:",
52
+ " steps:",
53
+ " - name: Checkout",
54
+ " uses: actions/checkout@v4",
55
+ ].join("\n");
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // stepByName
59
+ // ---------------------------------------------------------------------------
60
+
61
+ test("stepByName returns the step from its bullet to the next sibling bullet", () => {
62
+ const block = stepByName(WORKFLOW, "Run the thing");
63
+
64
+ assert.match(block, /^\s+- name: Run the thing$/m);
65
+ assert.match(block, /MODE: strict/, "the step's own nested mapping is included");
66
+ assert.doesNotMatch(block, /name: After/, "the following sibling step is not included");
67
+ assert.doesNotMatch(block, /name: Checkout/, "the preceding sibling step is not included");
68
+ });
69
+
70
+ test("stepByName keeps a nested mapping and stops at the following dedent", () => {
71
+ // `Checkout` under `publish:` is the LAST step in the document, so its block
72
+ // is terminated by end-of-input rather than by a sibling — the case a reader
73
+ // that requires a following bullet silently returns nothing for.
74
+ const block = stepByName(WORKFLOW, "After");
75
+
76
+ assert.match(block, /- name: After/);
77
+ assert.match(block, /run: echo "after"/);
78
+ assert.doesNotMatch(block, /publish:/, "the next job key is below the bullet indent");
79
+ });
80
+
81
+ test("stepByName matches on a substring of the name, first occurrence wins", () => {
82
+ const block = stepByName(WORKFLOW, "Checkout");
83
+
84
+ assert.match(block, /fetch-depth: 0/, "the build job's Checkout is the first match");
85
+ });
86
+
87
+ test("stepByName throws naming the step when no step name contains the needle", () => {
88
+ assert.throws(
89
+ () => stepByName(WORKFLOW, "Deploy to production"),
90
+ /step "Deploy to production" not found/,
91
+ "a lookup miss must fail loudly rather than degrade to an empty block"
92
+ );
93
+ });
94
+
95
+ test("stepByName throws when a matching name has no opening bullet above it", () => {
96
+ const noBullet = ["runs:", " steps:", " name: Orphan", " run: echo hi"].join("\n");
97
+
98
+ assert.throws(() => stepByName(noBullet, "Orphan"), /opening bullet for step "Orphan" not found/);
99
+ });
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // runScript
103
+ // ---------------------------------------------------------------------------
104
+
105
+ test("runScript dedents a `run: |` block scalar and preserves its inner shape", () => {
106
+ const body = runScript(stepByName(WORKFLOW, "Run the thing"));
107
+
108
+ assert.equal(
109
+ body,
110
+ [
111
+ "set -eu",
112
+ 'if [ "$MODE" = strict ]; then',
113
+ ' echo "strict"',
114
+ "fi",
115
+ "",
116
+ 'echo "done"',
117
+ // The blank line separating this step from the next survives as a
118
+ // trailing newline. Harmless in a shell script, and dropping it would
119
+ // shift every line number after it out of sync with the workflow.
120
+ "",
121
+ ].join("\n")
122
+ );
123
+ });
124
+
125
+ test("runScript keeps blank lines rather than collapsing them", () => {
126
+ // Line numbers in the extracted script must still line up with the workflow,
127
+ // otherwise a `bash -x` trace of the executed body is unreadable against the
128
+ // source it came from.
129
+ const body = runScript(stepByName(WORKFLOW, "Run the thing"));
130
+
131
+ assert.equal(body.split("\n")[4], "", "the blank line inside the scalar survives");
132
+ });
133
+
134
+ test("runScript stops at the end of the block scalar, not the end of the step", () => {
135
+ const trailingKeys = [
136
+ " - name: Scoped",
137
+ " run: |",
138
+ ' echo "body"',
139
+ " shell: bash",
140
+ " continue-on-error: true",
141
+ ].join("\n");
142
+
143
+ assert.equal(runScript(stepByName(trailingKeys, "Scoped")), 'echo "body"');
144
+ });
145
+
146
+ test("runScript throws when the step has no `run: |` block scalar", () => {
147
+ // A single-line `run:` is a different shape; extracting it as a block scalar
148
+ // would hand the caller an empty script that passes every assertion made
149
+ // against it.
150
+ assert.throws(
151
+ () => runScript(stepByName(WORKFLOW, "After")),
152
+ /`run: \|` block not found/,
153
+ "a single-line `run:` is not a block scalar"
154
+ );
155
+ assert.throws(() => runScript(stepByName(WORKFLOW, "Checkout")), /`run: \|` block not found/);
156
+ });
@@ -18,7 +18,10 @@ import {
18
18
  renderSummary,
19
19
  normalizeSource,
20
20
  rowKey,
21
+ rowIdentity,
21
22
  buildBaselineSet,
23
+ compareVersions,
24
+ isVersionCovered,
22
25
  OsvGateError,
23
26
  } from "../.github/actions/osv-scan/osv-report-gate.mjs";
24
27
 
@@ -537,3 +540,289 @@ test("a fully-demoted verdict set is not rendered as a clean scan", () => {
537
540
  assert.doesNotMatch(out, /no known advisories/);
538
541
  assert.match(out, /GHSA-r28c/);
539
542
  });
543
+
544
+ // ---------------------------------------------------------------------------
545
+ // Version-aware attribution (Story #365)
546
+ //
547
+ // The failure these close: the baseline identity keyed on `@version`, so
548
+ // raising a floor on a package carrying an UNFIXABLE advisory re-reported that
549
+ // advisory as introduced by the PR purely because the version string moved. A
550
+ // consumer measured the same advisory demoted at one version and blocking at
551
+ // another after a strictly-forward bump. Narrowing the key outright would have
552
+ // removed a real guard (a bump from one vulnerable version to another must
553
+ // keep blocking), so attribution asks the advisory whether it still covers the
554
+ // new version instead.
555
+ // ---------------------------------------------------------------------------
556
+
557
+ // A report whose vulnerability entries carry the OSV `affected` ranges — the
558
+ // real schema shape, which the pre-#365 fixtures above omit entirely.
559
+ const reportWithRanges = (groups, { sourcePath = "pnpm-lock.yaml" } = {}) => ({
560
+ results: [
561
+ {
562
+ source: { path: sourcePath },
563
+ packages: groups.map((g) => ({
564
+ package: { name: g.name, version: g.version || "1.0.0", ecosystem: g.ecosystem || "npm" },
565
+ groups: [{ ids: g.ids, max_severity: g.score }],
566
+ vulnerabilities: g.ids.map((id) => ({
567
+ id,
568
+ ...(g.published ? { published: g.published } : {}),
569
+ affected: [
570
+ {
571
+ package: { name: g.name, ecosystem: g.ecosystem || "npm" },
572
+ ranges: [{ type: "SEMVER", events: g.events }],
573
+ },
574
+ ],
575
+ })),
576
+ })),
577
+ },
578
+ ],
579
+ });
580
+
581
+ const rangedBaseline = (groups, opts) =>
582
+ buildBaselineSet(collectRows(reportWithRanges(groups, opts), opts));
583
+
584
+ test("compareVersions orders releases, pads, and sinks prereleases", () => {
585
+ assert.equal(compareVersions("1.2.3", "1.2.4"), -1);
586
+ assert.equal(compareVersions("1.10.0", "1.9.0"), 1);
587
+ assert.equal(compareVersions("2.0", "2.0.0"), 0);
588
+ assert.equal(compareVersions("1.0.0-rc1", "1.0.0"), -1);
589
+ assert.equal(compareVersions("v1.0.0", "1.0.0+build7"), 0);
590
+ // Unreadable input is null, never a coincidental ordering — every caller
591
+ // treats null as unknown and fails closed.
592
+ assert.equal(compareVersions("not-a-version", "1.0.0"), null);
593
+ assert.equal(compareVersions("?", "1.0.0"), null);
594
+ });
595
+
596
+ test("isVersionCovered answers from the advisory's own ranges", () => {
597
+ const rows = collectRows(
598
+ reportWithRanges([
599
+ {
600
+ name: "postcss",
601
+ ids: ["GHSA-r28c"],
602
+ score: "7.5",
603
+ version: "8.3.0",
604
+ events: [{ introduced: "0" }, { fixed: "8.4.31" }],
605
+ },
606
+ ]),
607
+ );
608
+ assert.equal(isVersionCovered(rows[0]), true);
609
+
610
+ const fixed = collectRows(
611
+ reportWithRanges([
612
+ {
613
+ name: "postcss",
614
+ ids: ["GHSA-r28c"],
615
+ score: "7.5",
616
+ version: "8.4.31",
617
+ events: [{ introduced: "0" }, { fixed: "8.4.31" }],
618
+ },
619
+ ]),
620
+ );
621
+ assert.equal(isVersionCovered(fixed[0]), false);
622
+ });
623
+
624
+ test("isVersionCovered is null — not false — when the report carries no ranges", () => {
625
+ // Load-bearing: null means "unknown", and unknown must never demote. The
626
+ // pre-#365 fixtures produce exactly this shape.
627
+ const rows = collectRows(reportWith([{ name: "p", ids: ["GHSA-x"], score: "7.5" }]));
628
+ assert.equal(isVersionCovered(rows[0]), null);
629
+ });
630
+
631
+ test("AC-1: a bump past the advisory's fixed version is not attributed to the PR", () => {
632
+ // The reported incident. The advisory is still on the package's row (an
633
+ // unfixable sibling range keeps the finding alive), but it no longer reaches
634
+ // the version this PR moved to — so the PR did not introduce it.
635
+ const head = collectRows(
636
+ reportWithRanges([
637
+ {
638
+ name: "postcss",
639
+ ids: ["GHSA-r28c"],
640
+ score: "7.5",
641
+ version: "8.4.31",
642
+ events: [{ introduced: "0" }, { fixed: "8.4.31" }],
643
+ },
644
+ ]),
645
+ );
646
+ const v = classify(head, {
647
+ failOn: "high",
648
+ baseline: rangedBaseline([
649
+ {
650
+ name: "postcss",
651
+ ids: ["GHSA-r28c"],
652
+ score: "7.5",
653
+ version: "8.3.0",
654
+ events: [{ introduced: "0" }, { fixed: "8.4.31" }],
655
+ },
656
+ ]),
657
+ });
658
+
659
+ assert.equal(v.blocking.length, 0);
660
+ assert.equal(v.preexisting.length, 1);
661
+ assert.equal(v.preexisting[0].version, "8.4.31");
662
+ });
663
+
664
+ test("AC-2: a bump from one still-vulnerable version to another still blocks", () => {
665
+ // The guard the narrowing must not remove. Same advisory, same package, a
666
+ // strictly-forward move — but the advisory still covers where it landed.
667
+ const head = collectRows(
668
+ reportWithRanges([
669
+ {
670
+ name: "postcss",
671
+ ids: ["GHSA-r28c"],
672
+ score: "7.5",
673
+ version: "8.4.0",
674
+ events: [{ introduced: "0" }, { fixed: "8.4.31" }],
675
+ },
676
+ ]),
677
+ );
678
+ const v = classify(head, {
679
+ failOn: "high",
680
+ baseline: rangedBaseline([
681
+ {
682
+ name: "postcss",
683
+ ids: ["GHSA-r28c"],
684
+ score: "7.5",
685
+ version: "8.3.0",
686
+ events: [{ introduced: "0" }, { fixed: "8.4.31" }],
687
+ },
688
+ ]),
689
+ });
690
+
691
+ assert.equal(v.blocking.length, 1);
692
+ assert.equal(v.blocking[0].version, "8.4.0");
693
+ assert.equal(v.preexisting.length, 0);
694
+ });
695
+
696
+ test("AC-2: an unreadable range keeps the version bump blocking", () => {
697
+ // Fail closed. Without a readable range the gate cannot prove the bump left
698
+ // the advisory behind, and an unproven claim must not open a required check.
699
+ const head = collectRows(
700
+ reportWithRanges([
701
+ {
702
+ name: "postcss",
703
+ ids: ["GHSA-r28c"],
704
+ score: "7.5",
705
+ version: "8.4.0",
706
+ events: [{ introduced: "0" }, { fixed: "not-a-version" }],
707
+ },
708
+ ]),
709
+ );
710
+ const v = classify(head, {
711
+ failOn: "high",
712
+ baseline: rangedBaseline([
713
+ {
714
+ name: "postcss",
715
+ ids: ["GHSA-r28c"],
716
+ score: "7.5",
717
+ version: "8.3.0",
718
+ events: [{ introduced: "0" }, { fixed: "not-a-version" }],
719
+ },
720
+ ]),
721
+ });
722
+ assert.equal(v.blocking.length, 1);
723
+ assert.equal(v.preexisting.length, 0);
724
+ });
725
+
726
+ test("AC-3: a genuinely new advisory, and one reaching a new package, still block", () => {
727
+ const baseline = rangedBaseline([
728
+ {
729
+ name: "postcss",
730
+ ids: ["GHSA-known"],
731
+ score: "7.5",
732
+ version: "8.3.0",
733
+ events: [{ introduced: "0" }, { fixed: "9.0.0" }],
734
+ },
735
+ ]);
736
+
737
+ // A new advisory id on the SAME package: no identity match at all.
738
+ const newAdvisory = classify(
739
+ collectRows(
740
+ reportWithRanges([
741
+ {
742
+ name: "postcss",
743
+ ids: ["GHSA-brand-new"],
744
+ score: "8.1",
745
+ version: "8.9.9",
746
+ events: [{ introduced: "0" }, { fixed: "8.0.0" }],
747
+ },
748
+ ]),
749
+ ),
750
+ { failOn: "high", baseline },
751
+ );
752
+ assert.equal(newAdvisory.blocking.length, 1, "a new advisory id is never pre-existing");
753
+ assert.equal(newAdvisory.preexisting.length, 0);
754
+
755
+ // The SAME advisory reaching a package that did not previously carry it.
756
+ const newPackage = classify(
757
+ collectRows(
758
+ reportWithRanges([
759
+ {
760
+ name: "another-pkg",
761
+ ids: ["GHSA-known"],
762
+ score: "7.5",
763
+ version: "9.9.9",
764
+ events: [{ introduced: "0" }, { fixed: "1.0.0" }],
765
+ },
766
+ ]),
767
+ ),
768
+ { failOn: "high", baseline },
769
+ );
770
+ assert.equal(newPackage.blocking.length, 1, "a new package is never pre-existing");
771
+ assert.equal(newPackage.preexisting.length, 0);
772
+ });
773
+
774
+ test("AC-4: the digest derives from rowKey, so identity and digest cannot drift", () => {
775
+ const rows = collectRows(
776
+ reportWith([
777
+ { name: "p1", ids: ["GHSA-b", "GHSA-a"], score: "7.5" },
778
+ { name: "p2", ids: ["GHSA-c"], score: "9.1" },
779
+ ]),
780
+ );
781
+ const { blocking } = classify(rows, { failOn: "high" });
782
+
783
+ // Recomputing the digest from rowKey alone reproduces it exactly — the two
784
+ // spellings that used to be maintained separately are now one.
785
+ const fromKeys = blocking.map(rowKey).sort();
786
+ assert.equal(findingsDigest(blocking), findingsDigest([...blocking].reverse()));
787
+ assert.equal(fromKeys.length, 2);
788
+
789
+ // An unchanged finding set does not churn the tracking issue, whatever order
790
+ // the scanner emitted it in.
791
+ const reordered = collectRows(
792
+ reportWith([
793
+ { name: "p2", ids: ["GHSA-c"], score: "9.1" },
794
+ { name: "p1", ids: ["GHSA-a", "GHSA-b"], score: "7.5" },
795
+ ]),
796
+ );
797
+ assert.equal(
798
+ findingsDigest(blocking),
799
+ findingsDigest(classify(reordered, { failOn: "high" }).blocking),
800
+ );
801
+ });
802
+
803
+ test("rowIdentity drops the version and never collides with rowKey", () => {
804
+ const [row] = collectRows(reportWith([{ name: "p", ids: ["GHSA-x"], score: "7.5" }]));
805
+ assert.ok(rowKey(row).includes("@1.0.0"));
806
+ assert.ok(!rowIdentity(row).includes("@1.0.0"));
807
+ assert.notEqual(rowKey(row), rowIdentity(row));
808
+ });
809
+
810
+ test("a legacy key-only baseline still demotes exact matches and blocks bumps", () => {
811
+ // Backwards compatibility: a caller passing a bare list of rowKey strings
812
+ // (the pre-#365 buildBaselineSet contract) loses only the version-aware tier,
813
+ // and loses it in the fail-closed direction.
814
+ const groups = [{ name: "postcss", ids: ["GHSA-r28c"], score: "7.5", version: "8.3.0" }];
815
+ const baseRows = collectRows(reportWith(groups));
816
+ const legacy = new Set(baseRows.map(rowKey));
817
+
818
+ const same = classify(collectRows(reportWith(groups)), { failOn: "high", baseline: legacy });
819
+ assert.equal(same.preexisting.length, 1);
820
+
821
+ const bumped = classify(
822
+ collectRows(
823
+ reportWith([{ name: "postcss", ids: ["GHSA-r28c"], score: "7.5", version: "8.4.31" }]),
824
+ ),
825
+ { failOn: "high", baseline: legacy },
826
+ );
827
+ assert.equal(bumped.blocking.length, 1);
828
+ });