mandrel-platform 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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" // ISO 8601 date — REQUIRED
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). Entries missing a required `id` or `expires` field are surfaced
64
- * in `invalid` so the caller can fail closed on a malformed allowlist.
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: AllowlistEntry[] }}
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 {AllowlistEntry[]} */
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 || !entry.id || !entry.expires) {
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.expires < today) {
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
- for (const entry of invalid) {
303
- console.error(
304
- `[audit-check] ERROR: Allowlist entry missing required "id" or "expires" field: ${JSON.stringify(entry)}`,
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 {