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
|
@@ -46,6 +46,9 @@ import {
|
|
|
46
46
|
isBoundedOverride,
|
|
47
47
|
findUnboundedOverrides,
|
|
48
48
|
lintOverrides,
|
|
49
|
+
detectPackageManager,
|
|
50
|
+
ghsaIdFromUrl,
|
|
51
|
+
recognizeReport,
|
|
49
52
|
} from "./audit-check.mjs";
|
|
50
53
|
|
|
51
54
|
const TODAY = "2026-07-02";
|
|
@@ -319,10 +322,29 @@ test("evaluateReport: uninterpretable report + non-zero pnpm exit → exit 1 (fa
|
|
|
319
322
|
assert.equal(result.reason, "uninterpretable-failclosed");
|
|
320
323
|
});
|
|
321
324
|
|
|
322
|
-
test("evaluateReport:
|
|
325
|
+
test("evaluateReport: unrecognized report + ZERO exit → exit 1 (fail closed)", () => {
|
|
326
|
+
// This assertion is the inverse of the one it replaces (Story #475), and the
|
|
327
|
+
// inversion is the point. The old contract read "no advisories key" as "no
|
|
328
|
+
// advisories" and passed on a zero exit. That is safe only while every report
|
|
329
|
+
// this gate can meet is the legacy shape — and it is not: `npm audit --json`
|
|
330
|
+
// reports v7+ advisories under `vulnerabilities` and exits 0 when clean, so
|
|
331
|
+
// the old branch would have reported an npm graph clean without reading one
|
|
332
|
+
// advisory, and would have kept doing so as highs landed.
|
|
333
|
+
//
|
|
334
|
+
// A report is clean only when a schema was RECOGNIZED and found nothing.
|
|
323
335
|
const result = evaluateReport({ metadata: {} }, 0, new Set());
|
|
324
|
-
assert.equal(result.exitCode,
|
|
325
|
-
assert.equal(result.reason, "
|
|
336
|
+
assert.equal(result.exitCode, 1);
|
|
337
|
+
assert.equal(result.reason, "uninterpretable-failclosed");
|
|
338
|
+
assert.equal(result.schema, null);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("evaluateReport: an unrecognized report fails closed on EVERY exit code", () => {
|
|
342
|
+
// Exit code must not be able to rescue an unreadable report from either side.
|
|
343
|
+
for (const exitCode of [0, 1, 2, 127]) {
|
|
344
|
+
const result = evaluateReport({ nonsense: true }, exitCode, new Set());
|
|
345
|
+
assert.equal(result.exitCode, 1, `exit ${exitCode}`);
|
|
346
|
+
assert.equal(result.reason, "uninterpretable-failclosed", `exit ${exitCode}`);
|
|
347
|
+
}
|
|
326
348
|
});
|
|
327
349
|
|
|
328
350
|
test("evaluateReport: validly-suppressed high (GHSA) → exit 0", () => {
|
|
@@ -837,3 +859,186 @@ test("parseArgs defaults the package.json path alongside the allowlist path", ()
|
|
|
837
859
|
assert.equal(allowlistPath, "/tmp/proj/audit-allowlist.json");
|
|
838
860
|
assert.equal(packageJsonPath, "/tmp/proj/package.json");
|
|
839
861
|
});
|
|
862
|
+
|
|
863
|
+
// ---------------------------------------------------------------------------
|
|
864
|
+
// Package-manager detection (Story #475)
|
|
865
|
+
//
|
|
866
|
+
// The lockfile is the discriminator, not `packageManager` / `engines`: it is
|
|
867
|
+
// what the audit actually reads, and this very repo declares one manager in
|
|
868
|
+
// metadata while committing the other's lockfile.
|
|
869
|
+
// ---------------------------------------------------------------------------
|
|
870
|
+
|
|
871
|
+
/** existsSync stub answering true for exactly the named basenames. */
|
|
872
|
+
function lockfilesPresent(...names) {
|
|
873
|
+
return (path) => names.some((name) => path.endsWith(`/${name}`));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
test("detectPackageManager: a pnpm lockfile selects pnpm", () => {
|
|
877
|
+
const result = detectPackageManager("/proj", {
|
|
878
|
+
existsSyncImpl: lockfilesPresent("pnpm-lock.yaml"),
|
|
879
|
+
});
|
|
880
|
+
assert.equal(result.manager, "pnpm");
|
|
881
|
+
assert.equal(result.error, undefined);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
test("detectPackageManager: an npm lockfile selects npm", () => {
|
|
885
|
+
const result = detectPackageManager("/proj", {
|
|
886
|
+
existsSyncImpl: lockfilesPresent("package-lock.json"),
|
|
887
|
+
});
|
|
888
|
+
assert.equal(result.manager, "npm");
|
|
889
|
+
assert.equal(result.error, undefined);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test("detectPackageManager: no lockfile is a loud error, never a default", () => {
|
|
893
|
+
// Defaulting to either manager would make the gate's verdict a claim about a
|
|
894
|
+
// graph nobody chose.
|
|
895
|
+
const result = detectPackageManager("/proj", { existsSyncImpl: () => false });
|
|
896
|
+
assert.equal(result.manager, undefined);
|
|
897
|
+
assert.match(result.error, /No lockfile found/);
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
test("detectPackageManager: two lockfiles are a loud error, never a guess", () => {
|
|
901
|
+
const result = detectPackageManager("/proj", {
|
|
902
|
+
existsSyncImpl: lockfilesPresent("pnpm-lock.yaml", "package-lock.json"),
|
|
903
|
+
});
|
|
904
|
+
assert.equal(result.manager, undefined);
|
|
905
|
+
assert.match(result.error, /Ambiguous lockfiles/);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
// ---------------------------------------------------------------------------
|
|
909
|
+
// GHSA id extraction — npm exposes the id ONLY inside the advisory url
|
|
910
|
+
// ---------------------------------------------------------------------------
|
|
911
|
+
|
|
912
|
+
test("ghsaIdFromUrl: reads the id from a GitHub advisory url", () => {
|
|
913
|
+
assert.equal(
|
|
914
|
+
ghsaIdFromUrl("https://github.com/advisories/GHSA-aaaa-bbbb-cccc"),
|
|
915
|
+
"GHSA-AAAA-BBBB-CCCC",
|
|
916
|
+
);
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
test("ghsaIdFromUrl: only the last path segment counts, never the host", () => {
|
|
920
|
+
// Matching a GHSA-shaped substring anywhere in a caller-controlled URL would
|
|
921
|
+
// let a hostile host or query string mint an id that silences an allowlist
|
|
922
|
+
// lookup. Only the final path segment is tested.
|
|
923
|
+
assert.equal(ghsaIdFromUrl("https://GHSA-aaaa-bbbb-cccc.example.com/x"), null);
|
|
924
|
+
assert.equal(
|
|
925
|
+
ghsaIdFromUrl("https://example.com/p?id=GHSA-aaaa-bbbb-cccc"),
|
|
926
|
+
null,
|
|
927
|
+
);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
test("ghsaIdFromUrl: rejects non-urls, empty values and non-GHSA segments", () => {
|
|
931
|
+
for (const value of ["", "not a url", null, undefined, 42, "https://example.com/x"]) {
|
|
932
|
+
assert.equal(ghsaIdFromUrl(value), null, String(value));
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
// ---------------------------------------------------------------------------
|
|
937
|
+
// The npm v7+ (`auditReportVersion` 2) report shape
|
|
938
|
+
//
|
|
939
|
+
// Fixture-driven of necessity: this repo has zero vulnerabilities tree-wide,
|
|
940
|
+
// so no live npm sample carrying an advisory can be captured from it.
|
|
941
|
+
// ---------------------------------------------------------------------------
|
|
942
|
+
|
|
943
|
+
const NPM_HIGH_URL = "https://github.com/advisories/GHSA-dddd-eeee-ffff";
|
|
944
|
+
|
|
945
|
+
/** An npm v2 report with one high advisory reached through `pkg`. */
|
|
946
|
+
function npmReportWithHigh({ severity = "high", cve = [] } = {}) {
|
|
947
|
+
return {
|
|
948
|
+
auditReportVersion: 2,
|
|
949
|
+
vulnerabilities: {
|
|
950
|
+
pkg: {
|
|
951
|
+
name: "pkg",
|
|
952
|
+
severity,
|
|
953
|
+
via: [
|
|
954
|
+
{
|
|
955
|
+
source: 123456,
|
|
956
|
+
name: "pkg",
|
|
957
|
+
title: "Prototype pollution in pkg",
|
|
958
|
+
url: NPM_HIGH_URL,
|
|
959
|
+
severity,
|
|
960
|
+
cve,
|
|
961
|
+
},
|
|
962
|
+
],
|
|
963
|
+
},
|
|
964
|
+
},
|
|
965
|
+
metadata: { vulnerabilities: { high: 1, total: 1 } },
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
test("recognizeReport: identifies each schema and normalizes its advisories", () => {
|
|
970
|
+
assert.equal(recognizeReport(reportWith({ 1: HIGH_GHSA })).schema, "legacy");
|
|
971
|
+
|
|
972
|
+
const npm = recognizeReport(npmReportWithHigh());
|
|
973
|
+
assert.equal(npm.schema, "npm");
|
|
974
|
+
assert.equal(npm.advisories.length, 1);
|
|
975
|
+
assert.deepEqual(npm.advisories[0].ids, ["GHSA-DDDD-EEEE-FFFF"]);
|
|
976
|
+
assert.equal(npm.advisories[0].severity, "high");
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
test("recognizeReport: an empty npm report is RECOGNIZED, not unreadable", () => {
|
|
980
|
+
// The real shape this repo produces today. It must read as a genuine clean —
|
|
981
|
+
// reached by inspecting `vulnerabilities`, not by failing to find
|
|
982
|
+
// `advisories`.
|
|
983
|
+
const result = recognizeReport({
|
|
984
|
+
auditReportVersion: 2,
|
|
985
|
+
vulnerabilities: {},
|
|
986
|
+
metadata: { vulnerabilities: { total: 0 } },
|
|
987
|
+
});
|
|
988
|
+
assert.equal(result.schema, "npm");
|
|
989
|
+
assert.deepEqual(result.advisories, []);
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
test("evaluateReport: an unsuppressed high in the NPM shape blocks", () => {
|
|
993
|
+
const result = evaluateReport(npmReportWithHigh(), 1, new Set());
|
|
994
|
+
assert.equal(result.exitCode, 1);
|
|
995
|
+
assert.equal(result.reason, "unsuppressed");
|
|
996
|
+
assert.equal(result.schema, "npm");
|
|
997
|
+
assert.equal(result.blocking.length, 1);
|
|
998
|
+
assert.equal(result.blocking[0].id, "GHSA-DDDD-EEEE-FFFF");
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
test("evaluateReport: an allowlisted GHSA id suppresses in the NPM shape", () => {
|
|
1002
|
+
// The id exists only inside `via[].url` here, so this is the assertion that
|
|
1003
|
+
// the allowlist still means something once the schema changes.
|
|
1004
|
+
const result = evaluateReport(
|
|
1005
|
+
npmReportWithHigh(),
|
|
1006
|
+
1,
|
|
1007
|
+
new Set(["GHSA-DDDD-EEEE-FFFF"]),
|
|
1008
|
+
);
|
|
1009
|
+
assert.equal(result.exitCode, 0);
|
|
1010
|
+
assert.equal(result.reason, "clean");
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
test("evaluateReport: a CVE id suppresses in the NPM shape too", () => {
|
|
1014
|
+
const report = npmReportWithHigh({ cve: ["CVE-2026-9999"] });
|
|
1015
|
+
assert.equal(
|
|
1016
|
+
evaluateReport(report, 1, new Set(["CVE-2026-9999"])).exitCode,
|
|
1017
|
+
0,
|
|
1018
|
+
);
|
|
1019
|
+
assert.equal(evaluateReport(report, 1, new Set()).exitCode, 1);
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
test("evaluateReport: below-gate npm severities never block", () => {
|
|
1023
|
+
for (const severity of ["moderate", "low", "info"]) {
|
|
1024
|
+
const result = evaluateReport(npmReportWithHigh({ severity }), 0, new Set());
|
|
1025
|
+
assert.equal(result.exitCode, 0, severity);
|
|
1026
|
+
assert.equal(result.reason, "clean", severity);
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
test("recognizeReport: a transitive npm chain counts its advisory once", () => {
|
|
1031
|
+
// `via` carries STRING edges for transitive chains alongside advisory
|
|
1032
|
+
// objects. Counting an edge as an advisory would inflate the finding set;
|
|
1033
|
+
// counting the same advisory once per reaching package would duplicate it.
|
|
1034
|
+
const report = {
|
|
1035
|
+
auditReportVersion: 2,
|
|
1036
|
+
vulnerabilities: {
|
|
1037
|
+
pkg: npmReportWithHigh().vulnerabilities.pkg,
|
|
1038
|
+
dependent: { name: "dependent", severity: "high", via: ["pkg"] },
|
|
1039
|
+
other: npmReportWithHigh().vulnerabilities.pkg,
|
|
1040
|
+
},
|
|
1041
|
+
metadata: {},
|
|
1042
|
+
};
|
|
1043
|
+
assert.equal(recognizeReport(report).advisories.length, 1);
|
|
1044
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-advisory-scan-setup.test.mjs — regression guard for advisory-scan.yml's
|
|
4
|
+
* `setup` input (Story #471).
|
|
5
|
+
*
|
|
6
|
+
* The bug this pins: `advisory-scan.yml` provisioned Node one way only — the
|
|
7
|
+
* `setup-toolchain` composite, which is pnpm-only (`actions/setup-node` with
|
|
8
|
+
* `cache: pnpm`, then `pnpm install --frozen-lockfile`). This repo is npm
|
|
9
|
+
* (`package-lock.json`, `npm ci` in ci.yml, no `pnpm-lock.yaml`), so the
|
|
10
|
+
* scheduled dogfood caller died at setup-node — "Dependencies lock file is not
|
|
11
|
+
* found ... Supported file patterns: pnpm-lock.yaml" — on EVERY run from the
|
|
12
|
+
* day it shipped. The job never reached the scan, so a workflow whose whole
|
|
13
|
+
* job is to notice things silently noticed nothing for seven weeks.
|
|
14
|
+
*
|
|
15
|
+
* That failure mode is why the assertions below are behavioural rather than
|
|
16
|
+
* textual wherever they can be: the `if:` expressions are EXTRACTED and RUN
|
|
17
|
+
* under GitHub's own truthiness rules (`scripts/lib/actions-expression.mjs`),
|
|
18
|
+
* because the defect class here is an expression that reads correctly and
|
|
19
|
+
* evaluates wrong.
|
|
20
|
+
*
|
|
21
|
+
* Run: node --test scripts/check-advisory-scan-setup.test.mjs
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import assert from "node:assert/strict";
|
|
25
|
+
import { test } from "node:test";
|
|
26
|
+
import { readFileSync } from "node:fs";
|
|
27
|
+
import { evaluate } from "./lib/actions-expression.mjs";
|
|
28
|
+
|
|
29
|
+
const ADVISORY = ".github/workflows/advisory-scan.yml";
|
|
30
|
+
const SCHEDULE = ".github/workflows/advisory-scan-schedule.yml";
|
|
31
|
+
const DOCS = "docs/reusable-workflows.md";
|
|
32
|
+
const ARCHITECTURE = "docs/architecture.md";
|
|
33
|
+
|
|
34
|
+
const advisory = readFileSync(ADVISORY, "utf8");
|
|
35
|
+
const schedule = readFileSync(SCHEDULE, "utf8");
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Extraction — the same read-then-execute approach as
|
|
39
|
+
// check-toolchain-cache-default.test.mjs, so a guarded expression is never
|
|
40
|
+
// asserted by its spelling.
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The block of one `- name: <name>` step, up to the next step at the same
|
|
45
|
+
* indent. Scans lines rather than building a `new RegExp` around `name`: a
|
|
46
|
+
* dynamically-constructed regex is a SAST finding and buys nothing here.
|
|
47
|
+
*/
|
|
48
|
+
function stepByName(text, name) {
|
|
49
|
+
const lines = text.split("\n");
|
|
50
|
+
const start = lines.findIndex((l) => l.trim() === `- name: ${name}`);
|
|
51
|
+
assert.notEqual(start, -1, `${ADVISORY}: step "${name}" not found`);
|
|
52
|
+
const indent = lines[start].match(/^(\s*)/)[1].length;
|
|
53
|
+
const out = [lines[start]];
|
|
54
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
55
|
+
const trimmed = lines[i].trim();
|
|
56
|
+
if (trimmed.startsWith("- ") && lines[i].match(/^(\s*)/)[1].length <= indent) break;
|
|
57
|
+
out.push(lines[i]);
|
|
58
|
+
}
|
|
59
|
+
return out.join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The `${{ … }}`-free body of a step's `if:` condition. */
|
|
63
|
+
function ifExpression(step, name) {
|
|
64
|
+
const m = step.match(/^\s*if:\s*(.+)$/m);
|
|
65
|
+
assert.ok(m, `step "${name}" has no \`if:\` guard`);
|
|
66
|
+
return m[1].trim().replace(/^\$\{\{/, "").replace(/\}\}$/, "").trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The literal `default:` of the named workflow_call input. */
|
|
70
|
+
function inputDefault(text, name) {
|
|
71
|
+
const lines = text.split("\n");
|
|
72
|
+
const start = lines.indexOf(` ${name}:`);
|
|
73
|
+
assert.notEqual(start, -1, `${ADVISORY}: workflow_call input \`${name}\` not found`);
|
|
74
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
75
|
+
if (lines[i].trim() === "") continue;
|
|
76
|
+
if (lines[i].match(/^(\s*)/)[1].length <= 6) break;
|
|
77
|
+
const d = lines[i].match(/^\s*default:\s*(.+)$/);
|
|
78
|
+
if (d) return d[1].trim();
|
|
79
|
+
}
|
|
80
|
+
return assert.fail(`${ADVISORY}: input \`${name}\` has no default`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const TOOLCHAIN_STEP = "Setup toolchain";
|
|
84
|
+
const NODE_STEP = "Setup Node.js (install-free)";
|
|
85
|
+
const GUARD_STEP = "Validate setup input";
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// AC-1 — the default is a literal, so no consumer moves.
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
test("the `setup` default is a literal 'toolchain', not an expression", () => {
|
|
92
|
+
// An expression here is what portability lint Rule 2 rejects and what GitHub
|
|
93
|
+
// silently fails to evaluate at interface-validation time. The value itself
|
|
94
|
+
// matters just as much: every consumer on today's pinned SHA passes no
|
|
95
|
+
// `setup` at all, so the default IS their behaviour.
|
|
96
|
+
const value = inputDefault(advisory, "setup");
|
|
97
|
+
assert.equal(value, "'toolchain'");
|
|
98
|
+
assert.doesNotMatch(value, /\$\{\{/, "a workflow_call default may not hold an expression");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// AC-3 — one cache site, and none on the install-free path.
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
test("exactly one cache expression exists, and it is the toolchain step's", () => {
|
|
106
|
+
// check-toolchain-cache-default.test.mjs extracts the FIRST `cache: ${{ … }}`
|
|
107
|
+
// in this file and asserts it against pr-quality.yml's. A second site would
|
|
108
|
+
// silently pin the wrong expression while that whole suite stayed green — so
|
|
109
|
+
// the count, not just the value, is the invariant.
|
|
110
|
+
const sites = advisory.split("\n").filter((l) => /^\s*cache:\s*\$\{\{.+\}\}\s*$/.test(l));
|
|
111
|
+
assert.equal(sites.length, 1, "expected exactly one cache: call site");
|
|
112
|
+
assert.ok(
|
|
113
|
+
stepByName(advisory, TOOLCHAIN_STEP).includes(sites[0]),
|
|
114
|
+
"the surviving cache site must belong to the setup-toolchain step",
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("the install-free step declares no cache key at all", () => {
|
|
119
|
+
// Not even `cache: ''` — an empty value is still a second site for a reader,
|
|
120
|
+
// and there is nothing to cache on a path that installs nothing.
|
|
121
|
+
assert.doesNotMatch(stepByName(advisory, NODE_STEP), /^\s*cache:/m);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// AC-4 — the install-free path provisions a pinned Node and installs nothing.
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
test("the install-free step pins Node from .nvmrc via a SHA-pinned setup-node", () => {
|
|
129
|
+
const step = stepByName(advisory, NODE_STEP);
|
|
130
|
+
assert.match(step, /node-version-file:\s*\.nvmrc/, "Node must come from .nvmrc, not a literal");
|
|
131
|
+
assert.match(
|
|
132
|
+
step,
|
|
133
|
+
/uses:\s*actions\/setup-node@[0-9a-f]{40}/,
|
|
134
|
+
"setup-node must be SHA-pinned",
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("no dependency install runs on the install-free path", () => {
|
|
139
|
+
// The whole point of the path: osv-scanner reads lockfiles off disk, and both
|
|
140
|
+
// composites' gate scripts import only node builtins and relative siblings.
|
|
141
|
+
const step = stepByName(advisory, NODE_STEP);
|
|
142
|
+
assert.doesNotMatch(step, /pnpm install/, "the install-free path must not install");
|
|
143
|
+
assert.doesNotMatch(step, /npm ci|npm install/, "the install-free path must not install");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// AC-5 — exhaustive selection, and a loud failure on anything else.
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
test("each recognized `setup` value selects exactly one provisioning step", () => {
|
|
151
|
+
// Evaluated, not grepped: `&&`/`||` in an Actions expression yield OPERANDS
|
|
152
|
+
// and every non-empty string is truthy, so a guard can read right and select
|
|
153
|
+
// both branches — or neither.
|
|
154
|
+
const guards = [
|
|
155
|
+
{ name: TOOLCHAIN_STEP, expr: ifExpression(stepByName(advisory, TOOLCHAIN_STEP), TOOLCHAIN_STEP) },
|
|
156
|
+
{ name: NODE_STEP, expr: ifExpression(stepByName(advisory, NODE_STEP), NODE_STEP) },
|
|
157
|
+
];
|
|
158
|
+
for (const setup of ["toolchain", "node"]) {
|
|
159
|
+
const selected = guards.filter(({ expr }) => evaluate(expr, { setup }) === true);
|
|
160
|
+
assert.equal(
|
|
161
|
+
selected.length,
|
|
162
|
+
1,
|
|
163
|
+
`setup '${setup}' selected ${selected.length} step(s): ${selected.map((s) => s.name).join(", ")}`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("an unrecognized `setup` value selects NO provisioning step — so a guard must reject it first", () => {
|
|
169
|
+
// This is the fail-closed half. With both branches skipped and no guard, the
|
|
170
|
+
// job would run the composites' gate scripts against whatever ambient Node
|
|
171
|
+
// the runner image carries: green, unpinned, and wrong.
|
|
172
|
+
const guards = [TOOLCHAIN_STEP, NODE_STEP].map((name) =>
|
|
173
|
+
ifExpression(stepByName(advisory, name), name),
|
|
174
|
+
);
|
|
175
|
+
for (const setup of ["", "nodejs", "Toolchain ", "true"]) {
|
|
176
|
+
assert.equal(
|
|
177
|
+
guards.filter((expr) => evaluate(expr, { setup }) === true).length,
|
|
178
|
+
0,
|
|
179
|
+
`setup '${setup}' must not select a provisioning step`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const guard = stepByName(advisory, GUARD_STEP);
|
|
184
|
+
assert.match(guard, /toolchain\|node\)/, "the guard must accept exactly the two known values");
|
|
185
|
+
assert.match(guard, /::error::/, "the guard must fail loudly, not warn");
|
|
186
|
+
assert.match(guard, /exit 1/, "the guard must fail the job");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("the guard runs before either provisioning step", () => {
|
|
190
|
+
// A guard that ran after the branches would report the typo only once the
|
|
191
|
+
// damage — a scan on ambient Node — had already been done.
|
|
192
|
+
const order = [GUARD_STEP, TOOLCHAIN_STEP, NODE_STEP].map((name) =>
|
|
193
|
+
advisory.indexOf(`- name: ${name}`),
|
|
194
|
+
);
|
|
195
|
+
assert.ok(order.every((i) => i !== -1), "every named step must exist");
|
|
196
|
+
assert.ok(order[0] < order[1] && order[0] < order[2], "the guard must come first");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// AC-6 — the dogfood caller is on the new path.
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
test("the scheduled dogfood caller passes setup: node", () => {
|
|
204
|
+
// This repo commits package-lock.json and has no pnpm-lock.yaml, so the
|
|
205
|
+
// pnpm default cannot survive its own setup step here.
|
|
206
|
+
assert.match(schedule, /^\s*setup:\s*node\s*$/m, `${SCHEDULE} must pass setup: node`);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("this repo really is the npm case the caller claims", () => {
|
|
210
|
+
// The assertion above is only correct while the premise holds. If this repo
|
|
211
|
+
// ever adopts pnpm, this test fails and the caller gets revisited — rather
|
|
212
|
+
// than silently keeping an install-free path it no longer needs.
|
|
213
|
+
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
|
214
|
+
assert.ok(pkg, "package.json must parse");
|
|
215
|
+
assert.doesNotThrow(
|
|
216
|
+
() => readFileSync("package-lock.json", "utf8"),
|
|
217
|
+
"package-lock.json must exist for the npm premise to hold",
|
|
218
|
+
);
|
|
219
|
+
assert.throws(
|
|
220
|
+
() => readFileSync("pnpm-lock.yaml", "utf8"),
|
|
221
|
+
"a pnpm-lock.yaml would mean setup: node is no longer the right call here",
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// AC-8 / AC-9 — the documented contract.
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
test("the advisory-scan Inputs table documents the `setup` input", () => {
|
|
230
|
+
// The table is the consumer-facing contract; an input absent from it is one
|
|
231
|
+
// no consumer can be expected to find.
|
|
232
|
+
const docs = readFileSync(DOCS, "utf8");
|
|
233
|
+
const rows = docs.split("\n").filter((l) => /^\|\s*`setup`\s*\|\s*string\s*\|/.test(l));
|
|
234
|
+
assert.equal(rows.length, 1, "expected exactly one documented `setup` row");
|
|
235
|
+
assert.match(rows[0], /`'toolchain'`/, "row does not state the 'toolchain' default");
|
|
236
|
+
assert.match(rows[0], /\.nvmrc/, "row does not state the node path's .nvmrc requirement");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("the Tech Stack row names this repo's real package manager", () => {
|
|
240
|
+
// The row claiming pnpm is what made the pnpm-only setup look correct when
|
|
241
|
+
// advisory-scan.yml was pointed at this repo.
|
|
242
|
+
const arch = readFileSync(ARCHITECTURE, "utf8");
|
|
243
|
+
const rows = arch.split("\n").filter((l) => /^\|\s*Package manager\s*\|/.test(l));
|
|
244
|
+
assert.equal(rows.length, 1, "expected exactly one Package manager row");
|
|
245
|
+
assert.match(rows[0], /npm/, "row must name npm");
|
|
246
|
+
assert.match(rows[0], /package-lock\.json/, "row must name the committed lockfile");
|
|
247
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-semgrep-lockfile.test.mjs — guards the SAST toolchain lockfile
|
|
4
|
+
* (Story #477).
|
|
5
|
+
*
|
|
6
|
+
* What this pins, and why each part earned a test:
|
|
7
|
+
*
|
|
8
|
+
* 1. **The advisories stay gone.** The lockfile carried protobuf 4.25.9
|
|
9
|
+
* (CVE-2026-0994, CVSS 8.2) and setuptools 80.9.0 (GHSA-h35f-9h28-mq5c).
|
|
10
|
+
* Neither could be fixed in place: every protobuf 4.x is affected, and
|
|
11
|
+
* semgrep 1.97.0's `opentelemetry-*~=1.25.0` pin capped protobuf below
|
|
12
|
+
* every patched release. A future bump that lands back inside an affected
|
|
13
|
+
* range would reintroduce a high with no other signal until the next
|
|
14
|
+
* scheduled OSV scan.
|
|
15
|
+
*
|
|
16
|
+
* 2. **The pin sites cannot drift.** The semgrep version lives in THREE
|
|
17
|
+
* places — this lockfile, `SEMGREP_PIN` in pr-quality.yml, and
|
|
18
|
+
* `DEFAULT_SEMGREP_PIN` in update-semgrep-rules.mjs — plus the
|
|
19
|
+
* `SEMGREP_HASHES` map that must carry digests for whatever the default is.
|
|
20
|
+
* Nothing detected disagreement between them before this file.
|
|
21
|
+
*
|
|
22
|
+
* 3. **`--require-hashes` stays satisfiable.** Every entry must be `==`-pinned
|
|
23
|
+
* with a sha256, or the install fails at CI time rather than here.
|
|
24
|
+
*
|
|
25
|
+
* The end-to-end proof (a real `pip install --require-hashes` on linux/cp312)
|
|
26
|
+
* cannot run in this suite — it needs that platform and a network. It is a
|
|
27
|
+
* `verify[]` step on the Story instead; these are the invariants checkable
|
|
28
|
+
* from the tree.
|
|
29
|
+
*
|
|
30
|
+
* Run: node --test scripts/check-semgrep-lockfile.test.mjs
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import assert from "node:assert/strict";
|
|
34
|
+
import { test } from "node:test";
|
|
35
|
+
import { readFileSync } from "node:fs";
|
|
36
|
+
|
|
37
|
+
const LOCKFILE = "scripts/semgrep-requirements.txt";
|
|
38
|
+
const WORKFLOW = ".github/workflows/pr-quality.yml";
|
|
39
|
+
const UPDATER = "scripts/update-semgrep-rules.mjs";
|
|
40
|
+
|
|
41
|
+
const lockfile = readFileSync(LOCKFILE, "utf8");
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse `name==version` requirement lines, ignoring comments and hash
|
|
45
|
+
* continuations. Keyed by lowercased name.
|
|
46
|
+
*/
|
|
47
|
+
function requirements(text) {
|
|
48
|
+
/** @type {Map<string, string>} */
|
|
49
|
+
const out = new Map();
|
|
50
|
+
for (const line of text.split("\n")) {
|
|
51
|
+
const trimmed = line.trim();
|
|
52
|
+
if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("--hash")) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const m = trimmed.match(/^([A-Za-z0-9._-]+)==([^\s\\]+)/);
|
|
56
|
+
if (m) {
|
|
57
|
+
out.set(m[1].toLowerCase(), m[2]);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const REQS = requirements(lockfile);
|
|
64
|
+
|
|
65
|
+
/** Compare dotted release segments numerically. Returns -1 / 0 / 1. */
|
|
66
|
+
function compareVersions(a, b) {
|
|
67
|
+
const pa = a.split(".").map((n) => Number.parseInt(n, 10));
|
|
68
|
+
const pb = b.split(".").map((n) => Number.parseInt(n, 10));
|
|
69
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
70
|
+
const x = Number.isNaN(pa[i]) || pa[i] === undefined ? 0 : pa[i];
|
|
71
|
+
const y = Number.isNaN(pb[i]) || pb[i] === undefined ? 0 : pb[i];
|
|
72
|
+
if (x !== y) return x < y ? -1 : 1;
|
|
73
|
+
}
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// 1. The advisories that put this Story on the board
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
test("protobuf is outside every CVE-2026-0994 affected range", () => {
|
|
82
|
+
// Affected: `< 5.29.6` and `>= 6.30.0rc1, <= 6.33.4`. Patched: 5.29.6, 6.33.5.
|
|
83
|
+
// A DoS in google.protobuf.json_format.ParseDict — 8.2, and the finding that
|
|
84
|
+
// opened tracking issue #472.
|
|
85
|
+
const version = REQS.get("protobuf");
|
|
86
|
+
assert.ok(version, `${LOCKFILE}: protobuf must be pinned`);
|
|
87
|
+
assert.doesNotMatch(version, /[a-zA-Z]/, "expected a final release, not a pre-release");
|
|
88
|
+
|
|
89
|
+
assert.ok(
|
|
90
|
+
compareVersions(version, "5.29.6") >= 0,
|
|
91
|
+
`protobuf ${version} is below the 5.29.6 patch — inside the "< 5.29.6" affected range`,
|
|
92
|
+
);
|
|
93
|
+
const inSixLine = compareVersions(version, "6.0.0") >= 0;
|
|
94
|
+
if (inSixLine) {
|
|
95
|
+
assert.ok(
|
|
96
|
+
compareVersions(version, "6.33.5") >= 0,
|
|
97
|
+
`protobuf ${version} is inside the ">= 6.30.0rc1, <= 6.33.4" affected range`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("setuptools is absent from the closure", () => {
|
|
103
|
+
// It was pinned to 80.9.0 only because semgrep 1.97.0's transitive
|
|
104
|
+
// opentelemetry-instrumentation imported pkg_resources at load. 0.58b0 does
|
|
105
|
+
// not, so the package — and its own advisory — leaves the graph entirely.
|
|
106
|
+
assert.equal(
|
|
107
|
+
REQS.has("setuptools"),
|
|
108
|
+
false,
|
|
109
|
+
"setuptools carries its own advisories and is no longer needed; do not re-add it without a stated reason",
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// 2. Drift between the three pin sites
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
test("the lockfile, the workflow, and the rules updater pin the same semgrep", () => {
|
|
118
|
+
const lockVersion = REQS.get("semgrep");
|
|
119
|
+
assert.ok(lockVersion, `${LOCKFILE}: semgrep must be pinned`);
|
|
120
|
+
|
|
121
|
+
const workflow = readFileSync(WORKFLOW, "utf8");
|
|
122
|
+
const wf = workflow.match(/SEMGREP_PIN='semgrep==([^']+)'/);
|
|
123
|
+
assert.ok(wf, `${WORKFLOW}: SEMGREP_PIN not found`);
|
|
124
|
+
assert.equal(wf[1], lockVersion, "workflow SEMGREP_PIN disagrees with the lockfile");
|
|
125
|
+
|
|
126
|
+
const updater = readFileSync(UPDATER, "utf8");
|
|
127
|
+
const up = updater.match(/const DEFAULT_SEMGREP_PIN = "semgrep==([^"]+)";/);
|
|
128
|
+
assert.ok(up, `${UPDATER}: DEFAULT_SEMGREP_PIN not found`);
|
|
129
|
+
assert.equal(up[1], lockVersion, "updater DEFAULT_SEMGREP_PIN disagrees with the lockfile");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("the rules updater carries artifact hashes for the version it defaults to", () => {
|
|
133
|
+
// SEMGREP_HASHES is a fail-fast supply-chain guard: a version with no entry
|
|
134
|
+
// is rejected rather than installed unverified. A bump that moved the default
|
|
135
|
+
// without adding digests would turn that guard into a hard stop.
|
|
136
|
+
const updater = readFileSync(UPDATER, "utf8");
|
|
137
|
+
const version = REQS.get("semgrep");
|
|
138
|
+
const map = updater.slice(updater.indexOf("const SEMGREP_HASHES = {"));
|
|
139
|
+
const block = map.slice(0, map.indexOf("\n};"));
|
|
140
|
+
assert.ok(
|
|
141
|
+
block.includes(`"${version}": [`),
|
|
142
|
+
`${UPDATER}: SEMGREP_HASHES has no entry for ${version}`,
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// 3. --require-hashes remains satisfiable
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
test("every requirement is == pinned and carries a sha256 hash", () => {
|
|
151
|
+
const pins = lockfile
|
|
152
|
+
.split("\n")
|
|
153
|
+
.filter((l) => /^[A-Za-z0-9._-]+==/.test(l.trim())).length;
|
|
154
|
+
const hashes = lockfile.split("\n").filter((l) => l.trim().startsWith("--hash=sha256:")).length;
|
|
155
|
+
|
|
156
|
+
assert.ok(pins > 0, "expected at least one pinned requirement");
|
|
157
|
+
assert.equal(REQS.size, pins, "every pinned line must parse to a requirement");
|
|
158
|
+
assert.ok(
|
|
159
|
+
hashes >= pins,
|
|
160
|
+
`${hashes} hash line(s) for ${pins} requirement(s) — --require-hashes needs at least one each`,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("no requirement is pinned with a loose operator", () => {
|
|
165
|
+
// `--require-hashes` rejects these at install time; catching it here names
|
|
166
|
+
// the offending line instead of failing inside CI's pip.
|
|
167
|
+
//
|
|
168
|
+
// The operator is matched by string comparison, NOT by a regex alternating
|
|
169
|
+
// `<` and `>`. CodeQL reads such a pattern as an attempted HTML-tag filter
|
|
170
|
+
// and raises js/bad-tag-filter at HIGH — which blocks the merge, since
|
|
171
|
+
// code-scanning gates on high. Comparing prefixes says the same thing with
|
|
172
|
+
// nothing for that query to match on.
|
|
173
|
+
const LOOSE_OPERATORS = [">=", "<=", "~=", "!=", ">", "<"];
|
|
174
|
+
for (const line of lockfile.split("\n")) {
|
|
175
|
+
const trimmed = line.trim();
|
|
176
|
+
if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("--hash")) continue;
|
|
177
|
+
const name = trimmed.match(/^[A-Za-z0-9._-]+/);
|
|
178
|
+
if (!name) continue;
|
|
179
|
+
const operator = trimmed.slice(name[0].length).trimStart();
|
|
180
|
+
for (const loose of LOOSE_OPERATORS) {
|
|
181
|
+
assert.ok(
|
|
182
|
+
!operator.startsWith(loose),
|
|
183
|
+
`loose pin (${loose}) — --require-hashes needs an exact ==: ${trimmed}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// 4. The regeneration trap
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
test("the header warns about the manylinux_2_34 wheel tag", () => {
|
|
194
|
+
// semgrep moved its Linux wheel tag after 1.157.0. A `pip download` whose
|
|
195
|
+
// --platform list omits the new tag resolves NOTHING newer and reports only
|
|
196
|
+
// "No matching distribution found", never naming the tag as the cause.
|
|
197
|
+
const header = lockfile.slice(0, lockfile.indexOf("\n\n\n") + 1 || 4000);
|
|
198
|
+
assert.match(header, /manylinux_2_34_x86_64/, "the header must name the current wheel tag");
|
|
199
|
+
assert.match(header, /1\.157\.0/, "the header must say which version the tag changed after");
|
|
200
|
+
assert.match(
|
|
201
|
+
lockfile,
|
|
202
|
+
/pip download semgrep/,
|
|
203
|
+
"the header must carry a regeneration command",
|
|
204
|
+
);
|
|
205
|
+
});
|