mandrel-platform 1.13.0 → 1.13.2
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 +27 -3
- package/config/stryker.base.json +2 -0
- package/package.json +4 -2
- package/scripts/audit-check.mjs +210 -47
- package/scripts/audit-check.test.mjs +479 -49
- package/scripts/check-action-download-retries.mjs +155 -11
- package/scripts/check-action-download-retries.test.mjs +168 -3
- package/scripts/check-advisory-scan-setup.test.mjs +186 -5
- package/scripts/check-coverage-threshold.mjs +112 -17
- package/scripts/check-coverage-threshold.test.mjs +335 -0
- package/scripts/check-husky-hook-modes.test.mjs +134 -0
- package/scripts/check-runner-runs-on.test.mjs +223 -2
- package/scripts/check-semgrep-lockfile.test.mjs +238 -7
- package/scripts/check-workflow-lint-tier.test.mjs +176 -0
- package/scripts/check-workflow-portability.mjs +6 -3
- package/scripts/check-workflow-portability.test.mjs +1 -1
- package/scripts/env-doctor.mjs +493 -62
- package/scripts/env-doctor.test.mjs +529 -3
- package/scripts/fixtures/npm-audit-v2.json +66 -0
- package/scripts/install-git-hooks.mjs +123 -0
- package/scripts/install-git-hooks.test.mjs +160 -0
- package/scripts/issue-intake.test.mjs +351 -21
- package/scripts/runner-toggle.test.mjs +407 -0
- package/scripts/select-semgrep-python.sh +98 -17
- package/scripts/select-semgrep-python.test.mjs +139 -12
- package/scripts/stryker-base-config.test.mjs +34 -0
- package/scripts/update-semgrep-rules.mjs +83 -5
- package/scripts/update-semgrep-rules.test.mjs +55 -1
- package/scripts/workflow-lint-gate.test.mjs +128 -0
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
detectPackageManager,
|
|
50
50
|
ghsaIdFromUrl,
|
|
51
51
|
recognizeReport,
|
|
52
|
+
normalizeAdvisoryId,
|
|
52
53
|
} from "./audit-check.mjs";
|
|
53
54
|
|
|
54
55
|
const TODAY = "2026-07-02";
|
|
@@ -909,10 +910,18 @@ test("detectPackageManager: two lockfiles are a loud error, never a guess", () =
|
|
|
909
910
|
// GHSA id extraction — npm exposes the id ONLY inside the advisory url
|
|
910
911
|
// ---------------------------------------------------------------------------
|
|
911
912
|
|
|
912
|
-
test("ghsaIdFromUrl: reads the id from a GitHub advisory url", () => {
|
|
913
|
+
test("ghsaIdFromUrl: reads the id from a GitHub advisory url, canonically", () => {
|
|
914
|
+
// Canonical = uppercase `GHSA-` prefix, lowercase body: what GitHub renders
|
|
915
|
+
// and what an operator pastes into the allowlist. This used to upper-case
|
|
916
|
+
// the whole id while the pnpm path kept its `ghsa_id` verbatim, so the two
|
|
917
|
+
// managers disagreed about what an id even looks like.
|
|
913
918
|
assert.equal(
|
|
914
919
|
ghsaIdFromUrl("https://github.com/advisories/GHSA-aaaa-bbbb-cccc"),
|
|
915
|
-
"GHSA-
|
|
920
|
+
"GHSA-aaaa-bbbb-cccc",
|
|
921
|
+
);
|
|
922
|
+
assert.equal(
|
|
923
|
+
ghsaIdFromUrl("https://github.com/advisories/GHSA-AAAA-BbBb-CCCC"),
|
|
924
|
+
"GHSA-aaaa-bbbb-cccc",
|
|
916
925
|
);
|
|
917
926
|
});
|
|
918
927
|
|
|
@@ -940,39 +949,86 @@ test("ghsaIdFromUrl: rejects non-urls, empty values and non-GHSA segments", () =
|
|
|
940
949
|
// so no live npm sample carrying an advisory can be captured from it.
|
|
941
950
|
// ---------------------------------------------------------------------------
|
|
942
951
|
|
|
943
|
-
|
|
952
|
+
// The committed sample of a REAL `npm audit --json` v2 report. Every other
|
|
953
|
+
// fixture in this suite is built inline, and this one deliberately is not:
|
|
954
|
+
// the npm schema is npm's, not ours, and an inline literal drifts toward
|
|
955
|
+
// whatever the test needed rather than what the tool emits. Committing the
|
|
956
|
+
// shape means a future reader can diff it against a live `npm audit --json`.
|
|
957
|
+
//
|
|
958
|
+
// It carries no `via[].cve` key, because npm's bundled advisory calculator
|
|
959
|
+
// never emits one. The normalizer still READS `cve` — another producer may
|
|
960
|
+
// write it — but nothing in this suite asserts a field npm does not emit,
|
|
961
|
+
// which is how a test can otherwise certify a code path against a schema that
|
|
962
|
+
// does not exist.
|
|
963
|
+
const NPM_FIXTURE_URL = new URL("./fixtures/npm-audit-v2.json", import.meta.url);
|
|
944
964
|
|
|
945
|
-
/**
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
|
|
965
|
+
/** The canonical id the fixture's single advisory carries. */
|
|
966
|
+
const NPM_FIXTURE_ID = "GHSA-fixt-ure0-0001";
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* A fresh parse of the committed fixture, optionally re-severitied so the
|
|
970
|
+
* below-gate cases exercise the same real shape.
|
|
971
|
+
*
|
|
972
|
+
* @param {{ severity?: string }} [opts]
|
|
973
|
+
*/
|
|
974
|
+
function npmFixtureReport({ severity } = {}) {
|
|
975
|
+
const report = JSON.parse(readFileSync(NPM_FIXTURE_URL, "utf8"));
|
|
976
|
+
if (severity !== undefined) {
|
|
977
|
+
for (const entry of Object.values(report.vulnerabilities)) {
|
|
978
|
+
entry.severity = severity;
|
|
979
|
+
for (const via of entry.via) {
|
|
980
|
+
if (typeof via === "object") {
|
|
981
|
+
via.severity = severity;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return report;
|
|
967
987
|
}
|
|
968
988
|
|
|
989
|
+
test("#488 AC-5: the committed fixture is the real npm audit v2 shape", () => {
|
|
990
|
+
const report = npmFixtureReport();
|
|
991
|
+
assert.equal(report.auditReportVersion, 2);
|
|
992
|
+
|
|
993
|
+
const via = report.vulnerabilities["fixture-lib"].via[0];
|
|
994
|
+
for (const key of [
|
|
995
|
+
"source",
|
|
996
|
+
"name",
|
|
997
|
+
"dependency",
|
|
998
|
+
"title",
|
|
999
|
+
"url",
|
|
1000
|
+
"severity",
|
|
1001
|
+
"cwe",
|
|
1002
|
+
"cvss",
|
|
1003
|
+
"range",
|
|
1004
|
+
]) {
|
|
1005
|
+
assert.ok(key in via, `via[] advisory must carry \`${key}\``);
|
|
1006
|
+
}
|
|
1007
|
+
assert.equal(
|
|
1008
|
+
"cve" in via,
|
|
1009
|
+
false,
|
|
1010
|
+
"npm's advisory calculator emits no `cve` on a via[] advisory",
|
|
1011
|
+
);
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
test("#488 AC-5: recognizeReport normalizes the committed npm fixture", () => {
|
|
1015
|
+
const { schema, advisories } = recognizeReport(npmFixtureReport());
|
|
1016
|
+
assert.equal(schema, "npm");
|
|
1017
|
+
// The fixture's second package reaches the same advisory through a STRING
|
|
1018
|
+
// edge, so this also pins the transitive chain to one finding.
|
|
1019
|
+
assert.equal(advisories.length, 1);
|
|
1020
|
+
assert.deepEqual(advisories[0].ids, [NPM_FIXTURE_ID]);
|
|
1021
|
+
assert.equal(advisories[0].severity, "high");
|
|
1022
|
+
assert.equal(advisories[0].title, "Prototype pollution in fixture-lib");
|
|
1023
|
+
});
|
|
1024
|
+
|
|
969
1025
|
test("recognizeReport: identifies each schema and normalizes its advisories", () => {
|
|
970
1026
|
assert.equal(recognizeReport(reportWith({ 1: HIGH_GHSA })).schema, "legacy");
|
|
971
1027
|
|
|
972
|
-
const npm = recognizeReport(
|
|
1028
|
+
const npm = recognizeReport(npmFixtureReport());
|
|
973
1029
|
assert.equal(npm.schema, "npm");
|
|
974
1030
|
assert.equal(npm.advisories.length, 1);
|
|
975
|
-
assert.deepEqual(npm.advisories[0].ids, [
|
|
1031
|
+
assert.deepEqual(npm.advisories[0].ids, [NPM_FIXTURE_ID]);
|
|
976
1032
|
assert.equal(npm.advisories[0].severity, "high");
|
|
977
1033
|
});
|
|
978
1034
|
|
|
@@ -990,55 +1046,429 @@ test("recognizeReport: an empty npm report is RECOGNIZED, not unreadable", () =>
|
|
|
990
1046
|
});
|
|
991
1047
|
|
|
992
1048
|
test("evaluateReport: an unsuppressed high in the NPM shape blocks", () => {
|
|
993
|
-
const result = evaluateReport(
|
|
1049
|
+
const result = evaluateReport(npmFixtureReport(), 1, new Set());
|
|
994
1050
|
assert.equal(result.exitCode, 1);
|
|
995
1051
|
assert.equal(result.reason, "unsuppressed");
|
|
996
1052
|
assert.equal(result.schema, "npm");
|
|
997
1053
|
assert.equal(result.blocking.length, 1);
|
|
998
|
-
assert.equal(result.blocking[0].id,
|
|
1054
|
+
assert.equal(result.blocking[0].id, NPM_FIXTURE_ID);
|
|
999
1055
|
});
|
|
1000
1056
|
|
|
1001
1057
|
test("evaluateReport: an allowlisted GHSA id suppresses in the NPM shape", () => {
|
|
1002
1058
|
// The id exists only inside `via[].url` here, so this is the assertion that
|
|
1003
1059
|
// the allowlist still means something once the schema changes.
|
|
1004
1060
|
const result = evaluateReport(
|
|
1005
|
-
|
|
1061
|
+
npmFixtureReport(),
|
|
1006
1062
|
1,
|
|
1007
|
-
new Set([
|
|
1063
|
+
new Set([NPM_FIXTURE_ID]),
|
|
1008
1064
|
);
|
|
1009
1065
|
assert.equal(result.exitCode, 0);
|
|
1010
1066
|
assert.equal(result.reason, "clean");
|
|
1011
1067
|
});
|
|
1012
1068
|
|
|
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
1069
|
test("evaluateReport: below-gate npm severities never block", () => {
|
|
1023
1070
|
for (const severity of ["moderate", "low", "info"]) {
|
|
1024
|
-
const result = evaluateReport(
|
|
1071
|
+
const result = evaluateReport(npmFixtureReport({ severity }), 0, new Set());
|
|
1025
1072
|
assert.equal(result.exitCode, 0, severity);
|
|
1026
1073
|
assert.equal(result.reason, "clean", severity);
|
|
1027
1074
|
}
|
|
1028
1075
|
});
|
|
1029
1076
|
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1077
|
+
// ---------------------------------------------------------------------------
|
|
1078
|
+
// Story #488 — id normalization
|
|
1079
|
+
//
|
|
1080
|
+
// The allowlist is this gate's ONLY sanctioned suppression mechanism, and it
|
|
1081
|
+
// was inert for the id form GitHub renders and every operator pastes: the npm
|
|
1082
|
+
// path upper-cased the id it parsed out of `via[].url`, the pnpm path kept
|
|
1083
|
+
// `ghsa_id` verbatim, and `partitionAllowlist` matched with an exact
|
|
1084
|
+
// `Set.has`. A canonical `GHSA-7w5x-hrqm-74c2` therefore suppressed under pnpm
|
|
1085
|
+
// and silently did nothing under npm — the worst shape of failure available,
|
|
1086
|
+
// because the entry LOOKS applied.
|
|
1087
|
+
// ---------------------------------------------------------------------------
|
|
1088
|
+
|
|
1089
|
+
test("normalizeAdvisoryId: GHSA ids fold to the canonical rendering", () => {
|
|
1090
|
+
for (const spelling of [
|
|
1091
|
+
"GHSA-7w5x-hrqm-74c2",
|
|
1092
|
+
"ghsa-7w5x-hrqm-74c2",
|
|
1093
|
+
"GHSA-7W5X-HRQM-74C2",
|
|
1094
|
+
"GHSA-7W5x-HrQm-74c2",
|
|
1095
|
+
" GHSA-7w5x-hrqm-74c2 ",
|
|
1096
|
+
]) {
|
|
1097
|
+
assert.equal(
|
|
1098
|
+
normalizeAdvisoryId(spelling),
|
|
1099
|
+
"GHSA-7w5x-hrqm-74c2",
|
|
1100
|
+
spelling,
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
test("normalizeAdvisoryId: a CVE id folds to its own canonical upper case", () => {
|
|
1106
|
+
assert.equal(normalizeAdvisoryId("cve-2026-0994"), "CVE-2026-0994");
|
|
1107
|
+
assert.equal(normalizeAdvisoryId("CVE-2026-0994"), "CVE-2026-0994");
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
test("normalizeAdvisoryId: nothing to normalize yields the empty string", () => {
|
|
1111
|
+
for (const value of ["", " ", null, undefined, 42, {}, []]) {
|
|
1112
|
+
assert.equal(normalizeAdvisoryId(value), "", JSON.stringify(value));
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
test("partitionAllowlist: a mixed-case allowlist id lands canonical", () => {
|
|
1117
|
+
const { suppressed } = partitionAllowlist(
|
|
1118
|
+
[{ id: "GHSA-7W5X-HRQM-74C2", reason: "accepted", expires: FUTURE }],
|
|
1119
|
+
TODAY,
|
|
1120
|
+
);
|
|
1121
|
+
assert.ok(suppressed.has("GHSA-7w5x-hrqm-74c2"));
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
// ---------------------------------------------------------------------------
|
|
1125
|
+
// Story #488 — the CLI, driven through its injectable subprocess seam
|
|
1126
|
+
//
|
|
1127
|
+
// `runCli(argv, { spawnImpl })` is the seam (`.agents/rules/test-seams.md`):
|
|
1128
|
+
// production keeps the real `spawnSync`, and these cases substitute a stub so
|
|
1129
|
+
// the whole CLI path — allowlist, manager detection, audit invocation, report
|
|
1130
|
+
// evaluation, exit code — is executable without spawning a package manager or
|
|
1131
|
+
// reaching a registry. Before it existed, every one of those paths was
|
|
1132
|
+
// reachable only by running a real audit, which is why the ones below shipped
|
|
1133
|
+
// broken.
|
|
1134
|
+
// ---------------------------------------------------------------------------
|
|
1135
|
+
|
|
1136
|
+
/** A recording `spawnSync` stub returning a canned result. */
|
|
1137
|
+
function stubSpawn(result, calls = []) {
|
|
1138
|
+
return (bin, args, options) => {
|
|
1139
|
+
calls.push({ bin, args, options });
|
|
1140
|
+
return { status: 0, stdout: "", stderr: "", ...result };
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Materialize a throwaway project — package.json, one lockfile, an optional
|
|
1146
|
+
* allowlist — and run `fn` against its absolute argv.
|
|
1147
|
+
*
|
|
1148
|
+
* @param {{ lockfile: string; allowlist?: unknown[] }} spec
|
|
1149
|
+
* @param {(ctx: { dir: string; argv: string[] }) => void} fn
|
|
1150
|
+
*/
|
|
1151
|
+
function withProject({ lockfile, allowlist }, fn) {
|
|
1152
|
+
const dir = mkdtempSync(join(tmpdir(), "audit-check-cli-"));
|
|
1153
|
+
try {
|
|
1154
|
+
const packageJsonPath = join(dir, "package.json");
|
|
1155
|
+
writeFileSync(
|
|
1156
|
+
packageJsonPath,
|
|
1157
|
+
JSON.stringify({ name: "fixture", version: "1.0.0" }),
|
|
1158
|
+
);
|
|
1159
|
+
writeFileSync(
|
|
1160
|
+
join(dir, lockfile),
|
|
1161
|
+
lockfile.endsWith(".json") ? "{}\n" : "lockfileVersion: '9.0'\n",
|
|
1162
|
+
);
|
|
1163
|
+
|
|
1164
|
+
const allowlistPath = join(dir, "audit-allowlist.json");
|
|
1165
|
+
if (allowlist !== undefined) {
|
|
1166
|
+
writeFileSync(allowlistPath, JSON.stringify(allowlist, null, 2));
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
fn({
|
|
1170
|
+
dir,
|
|
1171
|
+
argv: ["--package-json", packageJsonPath, "--allowlist", allowlistPath],
|
|
1172
|
+
});
|
|
1173
|
+
} finally {
|
|
1174
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/** Run `fn` with console output collected rather than printed. */
|
|
1179
|
+
function captureConsole(fn) {
|
|
1180
|
+
/** @type {string[]} */
|
|
1181
|
+
const lines = [];
|
|
1182
|
+
const originalLog = console.log;
|
|
1183
|
+
const originalError = console.error;
|
|
1184
|
+
console.log = (...args) => lines.push(args.map(String).join(" "));
|
|
1185
|
+
console.error = (...args) => lines.push(args.map(String).join(" "));
|
|
1186
|
+
try {
|
|
1187
|
+
return { value: fn(), output: lines.join("\n") };
|
|
1188
|
+
} finally {
|
|
1189
|
+
console.log = originalLog;
|
|
1190
|
+
console.error = originalError;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
test("#488 AC-1: a canonical lowercase allowlist id suppresses an npm advisory written in any case", () => {
|
|
1195
|
+
// The npm report exposes its id ONLY inside `via[].url`, and GitHub's own
|
|
1196
|
+
// links are mixed-case-tolerant. The allowlist entry is written exactly as
|
|
1197
|
+
// the runbook shows it.
|
|
1198
|
+
const report = npmFixtureReport();
|
|
1199
|
+
report.vulnerabilities["fixture-lib"].via[0].url =
|
|
1200
|
+
"https://github.com/advisories/GHSA-7W5X-HrQm-74C2";
|
|
1201
|
+
const stdout = JSON.stringify(report);
|
|
1202
|
+
|
|
1203
|
+
withProject(
|
|
1204
|
+
{
|
|
1205
|
+
lockfile: "package-lock.json",
|
|
1206
|
+
allowlist: [
|
|
1207
|
+
{
|
|
1208
|
+
id: "GHSA-7w5x-hrqm-74c2",
|
|
1209
|
+
reason: "no fix available upstream yet",
|
|
1210
|
+
expires: FUTURE,
|
|
1211
|
+
},
|
|
1212
|
+
],
|
|
1213
|
+
},
|
|
1214
|
+
({ argv }) => {
|
|
1215
|
+
const { value } = captureConsole(() =>
|
|
1216
|
+
runCli(argv, { spawnImpl: stubSpawn({ status: 1, stdout }) }),
|
|
1217
|
+
);
|
|
1218
|
+
assert.equal(value, 0, "the canonical id must suppress under npm");
|
|
1219
|
+
},
|
|
1220
|
+
);
|
|
1221
|
+
|
|
1222
|
+
// Control: the same advisory with nothing allowlisted still blocks, so the
|
|
1223
|
+
// exit 0 above is suppression rather than a report that was never read.
|
|
1224
|
+
withProject({ lockfile: "package-lock.json" }, ({ argv }) => {
|
|
1225
|
+
const { value } = captureConsole(() =>
|
|
1226
|
+
runCli(argv, { spawnImpl: stubSpawn({ status: 1, stdout }) }),
|
|
1227
|
+
);
|
|
1228
|
+
assert.equal(value, 1);
|
|
1229
|
+
});
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
test("#488 AC-1: the same canonical id suppresses a pnpm advisory written in any case", () => {
|
|
1233
|
+
const stdout = JSON.stringify(
|
|
1234
|
+
reportWith({
|
|
1235
|
+
1: {
|
|
1236
|
+
ghsa_id: "GHSA-7W5x-HrQm-74C2",
|
|
1237
|
+
severity: "high",
|
|
1238
|
+
title: "High severity in a transitive dep",
|
|
1239
|
+
url: "https://github.com/advisories/GHSA-7w5x-hrqm-74c2",
|
|
1240
|
+
},
|
|
1241
|
+
}),
|
|
1242
|
+
);
|
|
1243
|
+
|
|
1244
|
+
withProject(
|
|
1245
|
+
{
|
|
1246
|
+
lockfile: "pnpm-lock.yaml",
|
|
1247
|
+
allowlist: [
|
|
1248
|
+
{
|
|
1249
|
+
id: "GHSA-7w5x-hrqm-74c2",
|
|
1250
|
+
reason: "no fix available upstream yet",
|
|
1251
|
+
expires: FUTURE,
|
|
1252
|
+
},
|
|
1253
|
+
],
|
|
1254
|
+
},
|
|
1255
|
+
({ argv }) => {
|
|
1256
|
+
const { value } = captureConsole(() =>
|
|
1257
|
+
runCli(argv, { spawnImpl: stubSpawn({ status: 1, stdout }) }),
|
|
1258
|
+
);
|
|
1259
|
+
assert.equal(value, 0, "the canonical id must suppress under pnpm");
|
|
1260
|
+
},
|
|
1261
|
+
);
|
|
1262
|
+
|
|
1263
|
+
withProject({ lockfile: "pnpm-lock.yaml" }, ({ argv }) => {
|
|
1264
|
+
const { value } = captureConsole(() =>
|
|
1265
|
+
runCli(argv, { spawnImpl: stubSpawn({ status: 1, stdout }) }),
|
|
1266
|
+
);
|
|
1267
|
+
assert.equal(value, 1);
|
|
1268
|
+
});
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
test("#488 AC-2: the audit runs in the directory whose lockfile was detected, bounded", () => {
|
|
1272
|
+
// `detectPackageManager` reads `dirname(--package-json)`; the audit used to
|
|
1273
|
+
// run in `process.cwd()`. When those differ the gate reported on a graph it
|
|
1274
|
+
// never audited — an unfalsifiable claim, which is the one thing this gate
|
|
1275
|
+
// must not produce.
|
|
1276
|
+
const calls = [];
|
|
1277
|
+
const stdout = JSON.stringify({
|
|
1278
|
+
auditReportVersion: 2,
|
|
1279
|
+
vulnerabilities: {},
|
|
1280
|
+
metadata: {},
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
withProject({ lockfile: "package-lock.json" }, ({ dir, argv }) => {
|
|
1284
|
+
const { value } = captureConsole(() =>
|
|
1285
|
+
runCli(argv, { spawnImpl: stubSpawn({ status: 0, stdout }, calls) }),
|
|
1286
|
+
);
|
|
1287
|
+
assert.equal(value, 0);
|
|
1288
|
+
assert.equal(calls.length, 1);
|
|
1289
|
+
|
|
1290
|
+
const [call] = calls;
|
|
1291
|
+
assert.equal(call.bin, "npm");
|
|
1292
|
+
assert.deepEqual(call.args, ["audit", "--omit=dev", "--json"]);
|
|
1293
|
+
assert.equal(call.options.cwd, dir);
|
|
1294
|
+
assert.ok(
|
|
1295
|
+
typeof call.options.timeout === "number" && call.options.timeout > 0,
|
|
1296
|
+
"the audit must be time-bounded",
|
|
1297
|
+
);
|
|
1298
|
+
assert.ok(
|
|
1299
|
+
call.options.maxBuffer >= 64 * 1024 * 1024,
|
|
1300
|
+
`maxBuffer must be at least 64 MiB, got ${call.options.maxBuffer}`,
|
|
1301
|
+
);
|
|
1302
|
+
// No shell string, ever: the argv form is what keeps this call off an
|
|
1303
|
+
// injection surface, and what kept stderr out of `/dev/null`.
|
|
1304
|
+
assert.equal(call.options.shell, undefined);
|
|
1305
|
+
});
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
test("#488 AC-2: the pnpm branch spawns pnpm's own argv in the detected directory", () => {
|
|
1309
|
+
const calls = [];
|
|
1310
|
+
const stdout = JSON.stringify({ advisories: {}, metadata: {} });
|
|
1311
|
+
|
|
1312
|
+
withProject({ lockfile: "pnpm-lock.yaml" }, ({ dir, argv }) => {
|
|
1313
|
+
const { value } = captureConsole(() =>
|
|
1314
|
+
runCli(argv, { spawnImpl: stubSpawn({ status: 0, stdout }, calls) }),
|
|
1315
|
+
);
|
|
1316
|
+
assert.equal(value, 0);
|
|
1317
|
+
assert.equal(calls[0].bin, "pnpm");
|
|
1318
|
+
assert.deepEqual(calls[0].args, ["audit", "--prod", "--json"]);
|
|
1319
|
+
assert.equal(calls[0].options.cwd, dir);
|
|
1320
|
+
});
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
test("#488 AC-3: an audit that says nothing is never clean, and its stderr is shown", () => {
|
|
1324
|
+
// Exit 0 with unreadable stdout is how EVERY failure to run at all presents:
|
|
1325
|
+
// a missing binary, a killed child, an output ceiling hit mid-write. The old
|
|
1326
|
+
// contract printed "No vulnerabilities found" and exited 0 for all of them.
|
|
1327
|
+
const stderrText = "npm error code ENETUNREACH: registry unreachable";
|
|
1328
|
+
|
|
1329
|
+
for (const [label, stdout] of [
|
|
1330
|
+
["empty stdout", ""],
|
|
1331
|
+
["whitespace only", " \n"],
|
|
1332
|
+
["a human-readable notice", "up to date, audited 214 packages\n"],
|
|
1333
|
+
]) {
|
|
1334
|
+
withProject({ lockfile: "package-lock.json" }, ({ argv }) => {
|
|
1335
|
+
const { value, output } = captureConsole(() =>
|
|
1336
|
+
runCli(argv, {
|
|
1337
|
+
spawnImpl: stubSpawn({ status: 0, stdout, stderr: stderrText }),
|
|
1338
|
+
}),
|
|
1339
|
+
);
|
|
1340
|
+
assert.equal(value, 1, label);
|
|
1341
|
+
assert.match(output, /Failing closed/, label);
|
|
1342
|
+
assert.ok(output.includes(stderrText), `${label} must show the stderr`);
|
|
1343
|
+
assert.ok(
|
|
1344
|
+
!output.includes("No vulnerabilities found"),
|
|
1345
|
+
`${label} must not report clean`,
|
|
1346
|
+
);
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
test("#488 AC-3: a spawn that never ran at all fails closed with its reason", () => {
|
|
1352
|
+
// `spawnSync` reports a missing binary or a fired timeout through `error`,
|
|
1353
|
+
// with no exit status at all.
|
|
1354
|
+
withProject({ lockfile: "package-lock.json" }, ({ argv }) => {
|
|
1355
|
+
const { value, output } = captureConsole(() =>
|
|
1356
|
+
runCli(argv, {
|
|
1357
|
+
spawnImpl: () => ({
|
|
1358
|
+
status: null,
|
|
1359
|
+
stdout: "",
|
|
1360
|
+
stderr: "",
|
|
1361
|
+
error: new Error("spawnSync npm ENOENT"),
|
|
1362
|
+
}),
|
|
1363
|
+
}),
|
|
1364
|
+
);
|
|
1365
|
+
assert.equal(value, 1);
|
|
1366
|
+
assert.match(output, /ENOENT/);
|
|
1367
|
+
});
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
test("#488 AC-3: a parsed report matching no known schema still fails closed", () => {
|
|
1371
|
+
withProject({ lockfile: "package-lock.json" }, ({ argv }) => {
|
|
1372
|
+
const { value, output } = captureConsole(() =>
|
|
1373
|
+
runCli(argv, {
|
|
1374
|
+
spawnImpl: stubSpawn({
|
|
1375
|
+
status: 0,
|
|
1376
|
+
stdout: JSON.stringify({ auditReportVersion: 3, findings: [] }),
|
|
1377
|
+
stderr: "npm warn audit schema changed",
|
|
1378
|
+
}),
|
|
1379
|
+
}),
|
|
1380
|
+
);
|
|
1381
|
+
assert.equal(value, 1);
|
|
1382
|
+
assert.match(output, /matching no known audit schema/);
|
|
1383
|
+
assert.match(output, /npm warn audit schema changed/);
|
|
1384
|
+
});
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
test("#488 AC-4: a blocking advisory with no parseable id is reported, never dropped", () => {
|
|
1388
|
+
// The npm identity fallback was `ghsaId ?? source ?? url`, and `source` is
|
|
1389
|
+
// coerced to `""` when absent — so `"" ?? url` is `""`, the url arm was dead
|
|
1390
|
+
// code, and the empty key hit a `continue` that DISCARDED the advisory. A
|
|
1391
|
+
// Critical silently vanished for lacking a name.
|
|
1034
1392
|
const report = {
|
|
1035
1393
|
auditReportVersion: 2,
|
|
1036
1394
|
vulnerabilities: {
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1395
|
+
nameless: {
|
|
1396
|
+
name: "nameless",
|
|
1397
|
+
severity: "critical",
|
|
1398
|
+
via: [{ name: "nameless", title: "Critical with no id", severity: "critical" }],
|
|
1399
|
+
},
|
|
1040
1400
|
},
|
|
1041
1401
|
metadata: {},
|
|
1042
1402
|
};
|
|
1043
|
-
|
|
1403
|
+
|
|
1404
|
+
const result = evaluateReport(report, 0, new Set());
|
|
1405
|
+
assert.equal(result.exitCode, 1);
|
|
1406
|
+
assert.equal(result.reason, "unsuppressed");
|
|
1407
|
+
assert.equal(result.blocking.length, 1);
|
|
1408
|
+
assert.equal(result.blocking[0].id, "(unknown)");
|
|
1409
|
+
assert.equal(result.blocking[0].title, "Critical with no id");
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
test("#488 AC-4: two id-less advisories are two findings, not one collapsed key", () => {
|
|
1413
|
+
const report = {
|
|
1414
|
+
auditReportVersion: 2,
|
|
1415
|
+
vulnerabilities: {
|
|
1416
|
+
first: {
|
|
1417
|
+
name: "first",
|
|
1418
|
+
severity: "high",
|
|
1419
|
+
via: [{ name: "first", title: "First nameless high", severity: "high" }],
|
|
1420
|
+
},
|
|
1421
|
+
second: {
|
|
1422
|
+
name: "second",
|
|
1423
|
+
severity: "critical",
|
|
1424
|
+
via: [{ name: "second", title: "Second nameless critical", severity: "critical" }],
|
|
1425
|
+
},
|
|
1426
|
+
},
|
|
1427
|
+
metadata: {},
|
|
1428
|
+
};
|
|
1429
|
+
|
|
1430
|
+
assert.equal(evaluateReport(report, 0, new Set()).blocking.length, 2);
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
test("#488 AC-4: an id-less advisory blocks through the CLI and prints (unknown)", () => {
|
|
1434
|
+
const stdout = JSON.stringify({
|
|
1435
|
+
auditReportVersion: 2,
|
|
1436
|
+
vulnerabilities: {
|
|
1437
|
+
nameless: {
|
|
1438
|
+
name: "nameless",
|
|
1439
|
+
severity: "critical",
|
|
1440
|
+
via: [{ name: "nameless", title: "Critical with no id", severity: "critical" }],
|
|
1441
|
+
},
|
|
1442
|
+
},
|
|
1443
|
+
metadata: {},
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
withProject({ lockfile: "package-lock.json" }, ({ argv }) => {
|
|
1447
|
+
const { value, output } = captureConsole(() =>
|
|
1448
|
+
runCli(argv, { spawnImpl: stubSpawn({ status: 1, stdout }) }),
|
|
1449
|
+
);
|
|
1450
|
+
assert.equal(value, 1);
|
|
1451
|
+
assert.match(output, /\(unknown\)/);
|
|
1452
|
+
});
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1455
|
+
test("#488 AC-4: an id-less advisory in the legacy schema blocks too", () => {
|
|
1456
|
+
const report = reportWith({
|
|
1457
|
+
1: { severity: "critical", title: "Legacy critical with no id" },
|
|
1458
|
+
});
|
|
1459
|
+
const result = evaluateReport(report, 0, new Set());
|
|
1460
|
+
assert.equal(result.blocking.length, 1);
|
|
1461
|
+
assert.equal(result.blocking[0].id, "(unknown)");
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
test("#488 AC-5: the committed fixture blocks through the CLI, naming its canonical id", () => {
|
|
1465
|
+
const stdout = JSON.stringify(npmFixtureReport());
|
|
1466
|
+
|
|
1467
|
+
withProject({ lockfile: "package-lock.json" }, ({ argv }) => {
|
|
1468
|
+
const { value, output } = captureConsole(() =>
|
|
1469
|
+
runCli(argv, { spawnImpl: stubSpawn({ status: 1, stdout }) }),
|
|
1470
|
+
);
|
|
1471
|
+
assert.equal(value, 1);
|
|
1472
|
+
assert.ok(output.includes(NPM_FIXTURE_ID), output);
|
|
1473
|
+
});
|
|
1044
1474
|
});
|