mandrel-platform 1.0.1 → 1.2.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 +1 -1
- package/scripts/audit-check.mjs +112 -12
- package/scripts/audit-check.test.mjs +195 -0
- package/scripts/check-action-pins.mjs +87 -15
- package/scripts/check-action-pins.test.mjs +103 -4
- package/scripts/check-cancelled-provenance.test.mjs +493 -1
- package/scripts/check-docs-staleness.mjs +15 -2
- package/scripts/check-docs-staleness.test.mjs +114 -9
- package/scripts/check-first-party-pin-freshness.mjs +532 -0
- package/scripts/check-first-party-pin-freshness.test.mjs +489 -0
- package/scripts/job-cleanup-hook.test.mjs +234 -0
- package/scripts/runner-env-drift.test.mjs +554 -0
- package/templates/runbooks/runner-provisioning.md +62 -9
- package/templates/runner/.env.example +8 -2
- package/templates/runner/check-runner-env-drift.sh +248 -0
- package/templates/runner/job-cleanup.sh +49 -20
package/package.json
CHANGED
package/scripts/audit-check.mjs
CHANGED
|
@@ -34,10 +34,16 @@
|
|
|
34
34
|
* {
|
|
35
35
|
* "id": "GHSA-xxxx-xxxx-xxxx", // GitHub Advisory ID or CVE ID
|
|
36
36
|
* "reason": "No fix available; mitigated by X",
|
|
37
|
-
* "expires": "2026-12-31" //
|
|
37
|
+
* "expires": "2026-12-31" // REQUIRED — strictly YYYY-MM-DD
|
|
38
38
|
* }
|
|
39
39
|
* ]
|
|
40
40
|
*
|
|
41
|
+
* `expires` is validated, not merely read: it must be exactly `YYYY-MM-DD` and
|
|
42
|
+
* a real calendar date. Anything else (a `<YYYY-MM-DD>` placeholder, a
|
|
43
|
+
* `12/31/2026`, an impossible `2026-02-30`) is a hard configuration error that
|
|
44
|
+
* exits 1 — it is NEVER treated as a distant future date, which would suppress
|
|
45
|
+
* the advisory forever.
|
|
46
|
+
*
|
|
41
47
|
* The allowlist file path defaults to `audit-allowlist.json` in the
|
|
42
48
|
* directory from which this script is invoked (i.e. the project root).
|
|
43
49
|
* Override with `--allowlist <path>`.
|
|
@@ -57,31 +63,117 @@ const BLOCKING_SEVERITIES = new Set(["high", "critical"]);
|
|
|
57
63
|
* @typedef {{ id: string; reason?: string; expires: string }} AllowlistEntry
|
|
58
64
|
*/
|
|
59
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Strictly parse a `YYYY-MM-DD` calendar date, returning UTC midnight in ms —
|
|
68
|
+
* or `null` when the value is not exactly that.
|
|
69
|
+
*
|
|
70
|
+
* Deliberately strict, because this validates a *config field* that decides
|
|
71
|
+
* whether a High/Critical CVE stays suppressed. Anything unparseable must be
|
|
72
|
+
* rejected outright rather than coerced, so:
|
|
73
|
+
*
|
|
74
|
+
* - The regex is anchored. A value that merely CONTAINS a date is not a date,
|
|
75
|
+
* which is what rejects `<YYYY-MM-DD>`, `expires 2026-01-01`, `12/31/2026`
|
|
76
|
+
* and `2026-01-01T00:00:00Z`.
|
|
77
|
+
* - The result is round-tripped. `Date.UTC` silently rolls overflow over
|
|
78
|
+
* (`2026-13-45` → 2027-01-14, `2026-02-30` → 2026-03-02) and never returns
|
|
79
|
+
* NaN for it, so comparing the parsed instant's calendar fields back
|
|
80
|
+
* against the input is the only way to reject an impossible date.
|
|
81
|
+
*
|
|
82
|
+
* One consequence of the round-trip, noted rather than worked around: years
|
|
83
|
+
* 0–99 are rejected, because `Date.UTC` maps them into 1900–1999 (legacy
|
|
84
|
+
* two-digit-year behaviour) and so fail the comparison. No real expiry lands
|
|
85
|
+
* there, and rejecting is the fail-closed direction.
|
|
86
|
+
*
|
|
87
|
+
* @param {unknown} value
|
|
88
|
+
* @returns {number|null} UTC ms at midnight, or null when not a valid date
|
|
89
|
+
*/
|
|
90
|
+
export function parseIsoDateUtc(value) {
|
|
91
|
+
if (typeof value !== "string") {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
96
|
+
if (!m) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const year = Number(m[1]);
|
|
101
|
+
const month = Number(m[2]);
|
|
102
|
+
const day = Number(m[3]);
|
|
103
|
+
const ms = Date.UTC(year, month - 1, day);
|
|
104
|
+
const back = new Date(ms);
|
|
105
|
+
|
|
106
|
+
if (
|
|
107
|
+
back.getUTCFullYear() !== year ||
|
|
108
|
+
back.getUTCMonth() !== month - 1 ||
|
|
109
|
+
back.getUTCDate() !== day
|
|
110
|
+
) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return ms;
|
|
115
|
+
}
|
|
116
|
+
|
|
60
117
|
/**
|
|
61
118
|
* Partition allowlist entries into the active (non-expired) suppression set
|
|
62
119
|
* and the list of expired entries, relative to `today` (a `YYYY-MM-DD`
|
|
63
|
-
* string).
|
|
64
|
-
*
|
|
120
|
+
* string).
|
|
121
|
+
*
|
|
122
|
+
* Entries that are unusable — missing/non-string `id`, or an `expires` that is
|
|
123
|
+
* not a valid `YYYY-MM-DD` calendar date — are surfaced in `invalid` with the
|
|
124
|
+
* specific `problem`, so the caller can fail closed and name what is wrong.
|
|
125
|
+
* Treating `expires` as an opaque string was a fail-OPEN: the comparison was
|
|
126
|
+
* lexicographic, and every non-date value a caller might plausibly write
|
|
127
|
+
* (`<YYYY-MM-DD>` copy-pasted from the runbook, `not-a-date`, a typo) sorts
|
|
128
|
+
* ABOVE a real `20xx-..-..` date and so read as "not yet expired" — turning a
|
|
129
|
+
* malformed field into a permanent, silent CVE suppression.
|
|
65
130
|
*
|
|
66
131
|
* @param {AllowlistEntry[]} allowlist
|
|
67
132
|
* @param {string} today `YYYY-MM-DD`
|
|
68
|
-
* @returns {{ suppressed: Set<string>; expired: AllowlistEntry[]; invalid:
|
|
133
|
+
* @returns {{ suppressed: Set<string>; expired: AllowlistEntry[]; invalid: Array<{ entry: unknown; problem: string }> }}
|
|
69
134
|
*/
|
|
70
135
|
export function partitionAllowlist(allowlist, today) {
|
|
71
136
|
/** @type {Set<string>} */
|
|
72
137
|
const suppressed = new Set();
|
|
73
138
|
/** @type {AllowlistEntry[]} */
|
|
74
139
|
const expired = [];
|
|
75
|
-
/** @type {
|
|
140
|
+
/** @type {Array<{ entry: unknown; problem: string }>} */
|
|
76
141
|
const invalid = [];
|
|
77
142
|
|
|
143
|
+
const todayMs = parseIsoDateUtc(today);
|
|
144
|
+
if (todayMs === null) {
|
|
145
|
+
// Caller bug, not user data: `today` is derived from the system clock via
|
|
146
|
+
// toISOString(). Throwing beats any fallback, both of which would silently
|
|
147
|
+
// mis-classify every entry.
|
|
148
|
+
throw new Error(
|
|
149
|
+
`partitionAllowlist: "today" must be a YYYY-MM-DD date, got ${JSON.stringify(today)}.`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
78
153
|
for (const entry of allowlist) {
|
|
79
|
-
if (!entry ||
|
|
80
|
-
invalid.push(entry);
|
|
154
|
+
if (!entry || typeof entry !== "object") {
|
|
155
|
+
invalid.push({ entry, problem: "entry is not an object" });
|
|
81
156
|
continue;
|
|
82
157
|
}
|
|
83
158
|
|
|
84
|
-
if (entry.
|
|
159
|
+
if (typeof entry.id !== "string" || entry.id.trim() === "") {
|
|
160
|
+
invalid.push({ entry, problem: 'missing or non-string "id"' });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const expiresMs = parseIsoDateUtc(entry.expires);
|
|
165
|
+
if (expiresMs === null) {
|
|
166
|
+
invalid.push({
|
|
167
|
+
entry,
|
|
168
|
+
problem:
|
|
169
|
+
entry.expires === undefined || entry.expires === null
|
|
170
|
+
? 'missing required "expires"'
|
|
171
|
+
: `"expires" is not a valid YYYY-MM-DD date: ${JSON.stringify(entry.expires)}`,
|
|
172
|
+
});
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (expiresMs < todayMs) {
|
|
85
177
|
expired.push(entry);
|
|
86
178
|
} else {
|
|
87
179
|
suppressed.add(entry.id);
|
|
@@ -299,11 +391,19 @@ export function runCli(argv) {
|
|
|
299
391
|
const { suppressed, expired, invalid } = partitionAllowlist(allowlist, today);
|
|
300
392
|
|
|
301
393
|
if (invalid.length > 0) {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
394
|
+
console.error("[audit-check] INVALID allowlist entries detected:");
|
|
395
|
+
for (const { entry, problem } of invalid) {
|
|
396
|
+
const id =
|
|
397
|
+
entry && typeof entry === "object" && typeof entry.id === "string"
|
|
398
|
+
? entry.id
|
|
399
|
+
: "<no id>";
|
|
400
|
+
console.error(` - ${id}: ${problem}`);
|
|
306
401
|
}
|
|
402
|
+
console.error(
|
|
403
|
+
"[audit-check] An entry whose expiry cannot be read is never suppressed. " +
|
|
404
|
+
'Fix each entry to carry a non-empty "id" and an "expires" of the form ' +
|
|
405
|
+
"YYYY-MM-DD. Exit 1.",
|
|
406
|
+
);
|
|
307
407
|
return 1;
|
|
308
408
|
}
|
|
309
409
|
|
|
@@ -8,6 +8,16 @@
|
|
|
8
8
|
* - a validly-suppressed high advisory (by GHSA id and by CVE id) passes
|
|
9
9
|
* - an expired allowlist entry fails closed (exit 1)
|
|
10
10
|
* - an unsuppressed critical fails closed (exit 1)
|
|
11
|
+
* - a MALFORMED `expires` fails closed rather than suppressing forever
|
|
12
|
+
*
|
|
13
|
+
* That last one was a fail-OPEN. `expires` was compared as an opaque string
|
|
14
|
+
* (`entry.expires < today`), so any non-date value sorted lexicographically
|
|
15
|
+
* above a real `20xx-..-..` date and read as "not yet expired" — making a
|
|
16
|
+
* typo'd or placeholder expiry a permanent, silent CVE suppression. The
|
|
17
|
+
* `<YYYY-MM-DD>` placeholder the dependency-update runbook shows as its example
|
|
18
|
+
* value is the realistic way in. `parseIsoDateUtc` now validates the field, and
|
|
19
|
+
* the tests below assert the invariant (nothing unreadable is ever suppressed)
|
|
20
|
+
* across every malformed shape rather than the one spelling that motivated it.
|
|
11
21
|
*
|
|
12
22
|
* The suppression/expiry/interpretation logic is exercised through the pure
|
|
13
23
|
* functions (`partitionAllowlist`, `isInterpretableReport`,
|
|
@@ -26,6 +36,7 @@ import { join } from "node:path";
|
|
|
26
36
|
|
|
27
37
|
import {
|
|
28
38
|
partitionAllowlist,
|
|
39
|
+
parseIsoDateUtc,
|
|
29
40
|
isInterpretableReport,
|
|
30
41
|
extractBlockingAdvisories,
|
|
31
42
|
evaluateReport,
|
|
@@ -99,6 +110,154 @@ test("partitionAllowlist: entry missing id or expires is invalid", () => {
|
|
|
99
110
|
assert.equal(invalid.length, 2);
|
|
100
111
|
});
|
|
101
112
|
|
|
113
|
+
test("partitionAllowlist: an expiry equal to today is still active, not expired", () => {
|
|
114
|
+
const { suppressed, expired } = partitionAllowlist(
|
|
115
|
+
[{ id: "GHSA-today", expires: TODAY }],
|
|
116
|
+
TODAY,
|
|
117
|
+
);
|
|
118
|
+
assert.ok(suppressed.has("GHSA-today"));
|
|
119
|
+
assert.equal(expired.length, 0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── partitionAllowlist: the malformed-expiry fail-open ──────────────────────
|
|
123
|
+
//
|
|
124
|
+
// `expires` used to be compared as an opaque string (`entry.expires < today`),
|
|
125
|
+
// so any non-date value sorted lexicographically ABOVE a real `20xx-..-..`
|
|
126
|
+
// date and read as "not yet expired". A malformed field therefore became a
|
|
127
|
+
// PERMANENT, SILENT CVE suppression — the exact inverse of what an expiry is
|
|
128
|
+
// for. The realistic path in: `docs/runbooks/dependency-update.md` shows
|
|
129
|
+
// `"expires": "<YYYY-MM-DD>"` as the example value, and `<` (0x3C) sorts above
|
|
130
|
+
// `2` (0x32).
|
|
131
|
+
//
|
|
132
|
+
// The assertion below is on the invariant — nothing unreadable is EVER
|
|
133
|
+
// suppressed — rather than on the handful of spellings that motivated it.
|
|
134
|
+
|
|
135
|
+
const MALFORMED_EXPIRIES = [
|
|
136
|
+
["<YYYY-MM-DD>", "runbook placeholder, copy-pasted verbatim"],
|
|
137
|
+
["YYYY-MM-DD", "placeholder without brackets"],
|
|
138
|
+
["not-a-date", "free text"],
|
|
139
|
+
["expres 2026", "typo'd key/value smashed together"],
|
|
140
|
+
["12/31/2026", "US-style separators"],
|
|
141
|
+
["31-12-2026", "day-first ordering"],
|
|
142
|
+
["2026-1-1", "unpadded month/day"],
|
|
143
|
+
["2026-01-01T00:00:00Z", "full ISO 8601 timestamp, not a bare date"],
|
|
144
|
+
[" 2026-01-01 ", "surrounding whitespace"],
|
|
145
|
+
["2026-01-01extra", "trailing junk"],
|
|
146
|
+
["2026-13-01", "impossible month"],
|
|
147
|
+
["2026-02-30", "impossible day for the month"],
|
|
148
|
+
["", "empty string"],
|
|
149
|
+
[0, "number"],
|
|
150
|
+
[20261231, "date-ish number"],
|
|
151
|
+
[null, "null"],
|
|
152
|
+
[undefined, "undefined"],
|
|
153
|
+
[{ year: 2026 }, "object"],
|
|
154
|
+
[["2026-01-01"], "array"],
|
|
155
|
+
[true, "boolean"],
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
test("partitionAllowlist: no malformed expiry is ever suppressed (fail closed)", () => {
|
|
159
|
+
for (const [expires, label] of MALFORMED_EXPIRIES) {
|
|
160
|
+
const { suppressed, expired, invalid } = partitionAllowlist(
|
|
161
|
+
[{ id: "GHSA-aaaa-bbbb-cccc", reason: "why", expires }],
|
|
162
|
+
TODAY,
|
|
163
|
+
);
|
|
164
|
+
assert.equal(
|
|
165
|
+
suppressed.size,
|
|
166
|
+
0,
|
|
167
|
+
`${label} (${JSON.stringify(expires)}) must not suppress`,
|
|
168
|
+
);
|
|
169
|
+
assert.equal(
|
|
170
|
+
expired.length,
|
|
171
|
+
0,
|
|
172
|
+
`${label} (${JSON.stringify(expires)}) is unreadable, not expired`,
|
|
173
|
+
);
|
|
174
|
+
assert.equal(
|
|
175
|
+
invalid.length,
|
|
176
|
+
1,
|
|
177
|
+
`${label} (${JSON.stringify(expires)}) must be reported invalid`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("partitionAllowlist: an invalid entry names the offending id and problem", () => {
|
|
183
|
+
const { invalid } = partitionAllowlist(
|
|
184
|
+
[{ id: "GHSA-aaaa-bbbb-cccc", expires: "<YYYY-MM-DD>" }],
|
|
185
|
+
TODAY,
|
|
186
|
+
);
|
|
187
|
+
assert.equal(invalid.length, 1);
|
|
188
|
+
assert.equal(invalid[0].entry.id, "GHSA-aaaa-bbbb-cccc");
|
|
189
|
+
assert.match(invalid[0].problem, /expires/);
|
|
190
|
+
assert.match(invalid[0].problem, /YYYY-MM-DD/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("partitionAllowlist: a valid entry alongside a malformed one still fails closed", () => {
|
|
194
|
+
// The malformed entry must not be quietly skipped while the good one passes:
|
|
195
|
+
// the CLI gates on `invalid.length`, so the run has to stop.
|
|
196
|
+
const { suppressed, invalid } = partitionAllowlist(
|
|
197
|
+
[
|
|
198
|
+
{ id: "GHSA-good", expires: FUTURE },
|
|
199
|
+
{ id: "GHSA-bad", expires: "<YYYY-MM-DD>" },
|
|
200
|
+
],
|
|
201
|
+
TODAY,
|
|
202
|
+
);
|
|
203
|
+
assert.ok(suppressed.has("GHSA-good"));
|
|
204
|
+
assert.equal(invalid.length, 1);
|
|
205
|
+
assert.equal(invalid[0].entry.id, "GHSA-bad");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("partitionAllowlist: a non-string id is invalid, never a suppression key", () => {
|
|
209
|
+
for (const id of [42, null, undefined, "", " ", {}, ["GHSA-x"]]) {
|
|
210
|
+
const { suppressed, invalid } = partitionAllowlist(
|
|
211
|
+
[{ id, expires: FUTURE }],
|
|
212
|
+
TODAY,
|
|
213
|
+
);
|
|
214
|
+
assert.equal(suppressed.size, 0, `id ${JSON.stringify(id)} must not suppress`);
|
|
215
|
+
assert.equal(invalid.length, 1);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("partitionAllowlist: throws when `today` is not a YYYY-MM-DD date", () => {
|
|
220
|
+
assert.throws(
|
|
221
|
+
() => partitionAllowlist([{ id: "GHSA-x", expires: FUTURE }], "not-a-date"),
|
|
222
|
+
/must be a YYYY-MM-DD date/,
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ── parseIsoDateUtc ─────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
test("parseIsoDateUtc: accepts a real calendar date and returns UTC midnight", () => {
|
|
229
|
+
assert.equal(parseIsoDateUtc("2026-07-02"), Date.UTC(2026, 6, 2));
|
|
230
|
+
assert.equal(parseIsoDateUtc("2024-02-29"), Date.UTC(2024, 1, 29)); // leap year
|
|
231
|
+
assert.equal(parseIsoDateUtc("2999-12-31"), Date.UTC(2999, 11, 31));
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("parseIsoDateUtc: rejects overflow dates that Date.UTC would roll over", () => {
|
|
235
|
+
// Date.UTC(2026, 12, 45) is 2027-01-14, not NaN — only a round-trip catches it.
|
|
236
|
+
assert.equal(parseIsoDateUtc("2026-13-45"), null);
|
|
237
|
+
assert.equal(parseIsoDateUtc("2026-02-30"), null);
|
|
238
|
+
assert.equal(parseIsoDateUtc("2025-02-29"), null); // 2025 is not a leap year
|
|
239
|
+
assert.equal(parseIsoDateUtc("2026-00-10"), null);
|
|
240
|
+
assert.equal(parseIsoDateUtc("2026-01-00"), null);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("parseIsoDateUtc: rejects every malformed shape", () => {
|
|
244
|
+
for (const [value, label] of MALFORMED_EXPIRIES) {
|
|
245
|
+
assert.equal(
|
|
246
|
+
parseIsoDateUtc(value),
|
|
247
|
+
null,
|
|
248
|
+
`${label} (${JSON.stringify(value)}) must not parse`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("parseIsoDateUtc: ordering is chronological, not lexicographic", () => {
|
|
254
|
+
// The bug in one line: as strings, "<YYYY-MM-DD>" > "2026-07-02".
|
|
255
|
+
assert.ok("<YYYY-MM-DD>" > "2026-07-02");
|
|
256
|
+
// Parsed, it has no ordering at all — it is simply not a date.
|
|
257
|
+
assert.equal(parseIsoDateUtc("<YYYY-MM-DD>"), null);
|
|
258
|
+
assert.ok(parseIsoDateUtc("2026-01-01") < parseIsoDateUtc("2026-07-02"));
|
|
259
|
+
});
|
|
260
|
+
|
|
102
261
|
// ── isInterpretableReport ───────────────────────────────────────────────────
|
|
103
262
|
|
|
104
263
|
test("isInterpretableReport: true for a report with an advisories object", () => {
|
|
@@ -219,6 +378,42 @@ test("runCli: malformed allowlist entry (missing expires) → exit non-zero", ()
|
|
|
219
378
|
}
|
|
220
379
|
});
|
|
221
380
|
|
|
381
|
+
test("runCli: placeholder expiry copy-pasted from the runbook → exit non-zero", () => {
|
|
382
|
+
// The end-to-end shape of the fail-open: before validation this entry was
|
|
383
|
+
// suppressed forever, and runCli went on to pass the advisory it covered.
|
|
384
|
+
const dir = mkdtempSync(join(tmpdir(), "audit-check-placeholder-"));
|
|
385
|
+
try {
|
|
386
|
+
const allowlistPath = join(dir, "audit-allowlist.json");
|
|
387
|
+
writeFileSync(
|
|
388
|
+
allowlistPath,
|
|
389
|
+
JSON.stringify([
|
|
390
|
+
{
|
|
391
|
+
id: "GHSA-aaaa-bbbb-cccc",
|
|
392
|
+
reason: "No fix available; upstream tracking issue: <URL>",
|
|
393
|
+
expires: "<YYYY-MM-DD>",
|
|
394
|
+
},
|
|
395
|
+
]),
|
|
396
|
+
);
|
|
397
|
+
assert.equal(runCli(["--allowlist", allowlistPath]), 1);
|
|
398
|
+
} finally {
|
|
399
|
+
rmSync(dir, { recursive: true, force: true });
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("runCli: impossible calendar date in expires → exit non-zero", () => {
|
|
404
|
+
const dir = mkdtempSync(join(tmpdir(), "audit-check-impossible-"));
|
|
405
|
+
try {
|
|
406
|
+
const allowlistPath = join(dir, "audit-allowlist.json");
|
|
407
|
+
writeFileSync(
|
|
408
|
+
allowlistPath,
|
|
409
|
+
JSON.stringify([{ id: "GHSA-aaaa-bbbb-cccc", expires: "2026-02-30" }]),
|
|
410
|
+
);
|
|
411
|
+
assert.equal(runCli(["--allowlist", allowlistPath]), 1);
|
|
412
|
+
} finally {
|
|
413
|
+
rmSync(dir, { recursive: true, force: true });
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
222
417
|
test("runCli: non-array allowlist → exit non-zero", () => {
|
|
223
418
|
const dir = mkdtempSync(join(tmpdir(), "audit-check-nonarray-"));
|
|
224
419
|
try {
|
|
@@ -27,13 +27,29 @@
|
|
|
27
27
|
* `pnpm/action-setup`.) A non-SHA ref (a tag like `v4`, a branch, a short
|
|
28
28
|
* SHA) FAILS the lint.
|
|
29
29
|
*
|
|
30
|
-
* • FIRST-PARTY self-references — `dsj1984/mandrel-platform/...@<ref>` —
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
30
|
+
* • FIRST-PARTY self-references — `dsj1984/mandrel-platform/...@<ref>` — MUST
|
|
31
|
+
* ALSO be a 40-char hex SHA, and are reported as their own violation class.
|
|
32
|
+
* They were exempt until Story #354's audit: the exemption's stated
|
|
33
|
+
* justification was that `check-workflow-portability.mjs` Rule 3 governs
|
|
34
|
+
* them, but Rule 3's `collectInternalPins` skips any ref that is not
|
|
35
|
+
* already a 40-hex SHA (`if (!isSha40(cls.ref)) return`), so a
|
|
36
|
+
* branch-pinned self-reference was validated by NOTHING. The two other
|
|
37
|
+
* first-party guards had the same hole — the single-pin invariant below
|
|
38
|
+
* compares whatever refs it finds without requiring a SHA, and
|
|
39
|
+
* `check-first-party-pin-freshness.mjs` files a non-SHA ref under an
|
|
40
|
+
* informational `unpinnedRefs` note that never fails. So
|
|
41
|
+
* `…/gitleaks-scan@main` was green on all three.
|
|
42
|
+
*
|
|
43
|
+
* A moving self-ref is the same supply-chain risk the third-party ratchet
|
|
44
|
+
* exists to close, with a wider blast radius: `pr-quality.yml` is inherited
|
|
45
|
+
* by every consumer. It is also invisible to `platform-sync.mjs`, whose
|
|
46
|
+
* rewrite regex matches `@[0-9a-fA-F]{40}` only — a branch-pinned consumer
|
|
47
|
+
* workflow is silently skipped on every platform bump.
|
|
48
|
+
*
|
|
49
|
+
* The land-then-bump flow is unaffected: it always pins full SHAs (see
|
|
50
|
+
* docs/reusable-workflows.md § First-party self-pin freshness). The
|
|
51
|
+
* first-party owner is overridable via `--first-party-owner` for a fork,
|
|
52
|
+
* and first-party refs remain subject to the single-pin invariant below.
|
|
37
53
|
*
|
|
38
54
|
* • LOCAL `./path` references and `docker://image` references are EXEMPT —
|
|
39
55
|
* a local path has no upstream tag to move, and a docker ref is pinned by
|
|
@@ -125,10 +141,18 @@ export function parseArgs(argv) {
|
|
|
125
141
|
// ---------------------------------------------------------------------------
|
|
126
142
|
|
|
127
143
|
/**
|
|
128
|
-
* Scan a single file's TEXT for `uses:` step keys and evaluate
|
|
129
|
-
* reference
|
|
130
|
-
* `{
|
|
131
|
-
*
|
|
144
|
+
* Scan a single file's TEXT for `uses:` step keys and evaluate every REMOTE
|
|
145
|
+
* reference against the 40-hex SHA ratchet. Returns
|
|
146
|
+
* `{ violations, scanned, firstPartyViolations, firstPartyScanned }` — the two
|
|
147
|
+
* owner classes are counted and reported separately because their remediation
|
|
148
|
+
* differs (bump a vendored third-party pin vs. re-pin one of this repo's own
|
|
149
|
+
* call sites, which must move at every call site together to keep the
|
|
150
|
+
* single-pin invariant). A violation is `{ file, line, ref, owner, reason }`;
|
|
151
|
+
* `file` is left as passed-in (the caller supplies a display path).
|
|
152
|
+
*
|
|
153
|
+
* LOCAL (`./path`) and `docker://` references stay exempt: a local path has no
|
|
154
|
+
* upstream ref that can move, and a docker ref carries its own digest
|
|
155
|
+
* convention.
|
|
132
156
|
*
|
|
133
157
|
* Only lines whose first non-space token is `uses:` (a YAML mapping key) are
|
|
134
158
|
* inspected — `uses:` appearing inside a comment or a `run:` heredoc never
|
|
@@ -138,13 +162,33 @@ export function parseArgs(argv) {
|
|
|
138
162
|
*/
|
|
139
163
|
export function scanContent(content, displayFile, firstPartyOwner = DEFAULT_FIRST_PARTY_OWNER) {
|
|
140
164
|
const violations = [];
|
|
165
|
+
const firstPartyViolations = [];
|
|
141
166
|
let scanned = 0;
|
|
167
|
+
let firstPartyScanned = 0;
|
|
142
168
|
const lines = String(content).split(/\r?\n/);
|
|
143
169
|
for (let i = 0; i < lines.length; i++) {
|
|
144
170
|
const bareRef = parseUsesLine(lines[i]);
|
|
145
171
|
if (bareRef === null) continue;
|
|
146
172
|
const cls = classifyUses(bareRef, firstPartyOwner);
|
|
147
|
-
|
|
173
|
+
|
|
174
|
+
// First-party self-references (Story #354 audit). A bare `owner/repo@ref`
|
|
175
|
+
// carrying no subpath is ratcheted too: the hazard is the MOVING REF, and
|
|
176
|
+
// it moves whether or not the reference names a subpath.
|
|
177
|
+
if (cls.kind === "first-party") {
|
|
178
|
+
firstPartyScanned++;
|
|
179
|
+
if (!isSha40(cls.ref)) {
|
|
180
|
+
firstPartyViolations.push({
|
|
181
|
+
file: displayFile,
|
|
182
|
+
line: i + 1,
|
|
183
|
+
ref: bareRef,
|
|
184
|
+
owner: cls.owner,
|
|
185
|
+
reason: `first-party self-reference "${cls.owner}" is pinned to "${cls.ref}", not a full 40-char commit SHA`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (cls.kind !== "third-party") continue; // local/docker/unparseable → exempt
|
|
148
192
|
scanned++;
|
|
149
193
|
if (!isSha40(cls.ref)) {
|
|
150
194
|
violations.push({
|
|
@@ -156,7 +200,7 @@ export function scanContent(content, displayFile, firstPartyOwner = DEFAULT_FIRS
|
|
|
156
200
|
});
|
|
157
201
|
}
|
|
158
202
|
}
|
|
159
|
-
return { violations, scanned };
|
|
203
|
+
return { violations, scanned, firstPartyViolations, firstPartyScanned };
|
|
160
204
|
}
|
|
161
205
|
|
|
162
206
|
// ---------------------------------------------------------------------------
|
|
@@ -178,7 +222,9 @@ export function runLint(opts) {
|
|
|
178
222
|
const files = [...workflowFiles, ...listActionFiles(acDir)];
|
|
179
223
|
|
|
180
224
|
const violations = [];
|
|
225
|
+
const firstPartyViolations = [];
|
|
181
226
|
let scanned = 0;
|
|
227
|
+
let firstPartyScanned = 0;
|
|
182
228
|
// Keep the raw workflow-file contents for the single-pin pass so we read
|
|
183
229
|
// each file from disk once.
|
|
184
230
|
const workflowRecords = [];
|
|
@@ -192,7 +238,9 @@ export function runLint(opts) {
|
|
|
192
238
|
const display = relative(cwd, file) || file;
|
|
193
239
|
const res = scanContent(content, display, opts.firstPartyOwner);
|
|
194
240
|
violations.push(...res.violations);
|
|
241
|
+
firstPartyViolations.push(...res.firstPartyViolations);
|
|
195
242
|
scanned += res.scanned;
|
|
243
|
+
firstPartyScanned += res.firstPartyScanned;
|
|
196
244
|
if (workflowFiles.includes(file)) {
|
|
197
245
|
workflowRecords.push({ file: display, content });
|
|
198
246
|
}
|
|
@@ -204,9 +252,14 @@ export function runLint(opts) {
|
|
|
204
252
|
: [];
|
|
205
253
|
|
|
206
254
|
return {
|
|
207
|
-
ok:
|
|
255
|
+
ok:
|
|
256
|
+
violations.length === 0 &&
|
|
257
|
+
firstPartyViolations.length === 0 &&
|
|
258
|
+
singlePinViolations.length === 0,
|
|
208
259
|
violations,
|
|
260
|
+
firstPartyViolations,
|
|
209
261
|
scanned,
|
|
262
|
+
firstPartyScanned,
|
|
210
263
|
files,
|
|
211
264
|
singlePinViolations,
|
|
212
265
|
};
|
|
@@ -242,6 +295,24 @@ export function runCli(argv, { log = console.log, err = console.error } = {}) {
|
|
|
242
295
|
);
|
|
243
296
|
}
|
|
244
297
|
|
|
298
|
+
if (result.firstPartyViolations.length > 0) {
|
|
299
|
+
failed = true;
|
|
300
|
+
err(
|
|
301
|
+
`[action-pins] ❌ ${result.firstPartyViolations.length} first-party self-reference(s) pinned to a moving ref:`
|
|
302
|
+
);
|
|
303
|
+
for (const v of result.firstPartyViolations) {
|
|
304
|
+
err(` • ${v.file}:${v.line} — ${v.reason}`);
|
|
305
|
+
}
|
|
306
|
+
err(
|
|
307
|
+
"[action-pins] Pin every first-party `uses:` to a full 40-char commit SHA " +
|
|
308
|
+
"too (keep the `# vX.Y.Z` tag note as a comment). A branch or tag ref " +
|
|
309
|
+
"means the revision that runs can change with no diff here — and " +
|
|
310
|
+
"`pr-quality.yml` is inherited by every consumer. It is also invisible " +
|
|
311
|
+
"to platform-sync.mjs, whose rewrite matches 40-hex SHAs only, so it " +
|
|
312
|
+
"would be skipped on every platform bump."
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
245
316
|
if (result.singlePinViolations.length > 0) {
|
|
246
317
|
failed = true;
|
|
247
318
|
err(
|
|
@@ -263,7 +334,8 @@ export function runCli(argv, { log = console.log, err = console.error } = {}) {
|
|
|
263
334
|
if (failed) return 1;
|
|
264
335
|
|
|
265
336
|
log(
|
|
266
|
-
`[action-pins] ✅ all ${result.scanned} third-party
|
|
337
|
+
`[action-pins] ✅ all ${result.scanned} third-party and ${result.firstPartyScanned} ` +
|
|
338
|
+
`first-party action reference(s) are SHA-pinned ` +
|
|
267
339
|
`(${result.files.length} file(s) scanned); first-party single-pin invariant holds.`
|
|
268
340
|
);
|
|
269
341
|
return 0;
|