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 +1 -1
- package/scripts/audit-check.mjs +112 -12
- package/scripts/audit-check.test.mjs +195 -0
- package/scripts/check-cancelled-provenance.test.mjs +986 -0
- package/scripts/check-ci-required-aggregator.test.mjs +169 -13
- package/scripts/check-destructive-migration.test.mjs +37 -0
- package/scripts/check-docs-staleness.mjs +15 -2
- package/scripts/check-docs-staleness.test.mjs +114 -9
- package/scripts/check-fail-fast-attribution.test.mjs +390 -0
- package/scripts/job-cleanup-hook.test.mjs +234 -0
- package/scripts/platform-sync.mjs +89 -15
- package/scripts/platform-sync.test.mjs +125 -0
- package/templates/runbooks/runner-provisioning.md +12 -3
- package/templates/runner/.env.example +8 -2
- package/templates/runner/job-cleanup.sh +49 -20
|
@@ -17,20 +17,34 @@
|
|
|
17
17
|
* 1. STRUCTURE — each aggregator's `steps:` derive results from
|
|
18
18
|
* `toJSON(needs)` and contain NO hardcoded reference to any job named in
|
|
19
19
|
* its own `needs:` array (the "no hardcoded tier-name list" AC).
|
|
20
|
-
* 2. PARITY — the two aggregators' `
|
|
21
|
-
* so a fix to one cannot drift from the
|
|
20
|
+
* 2. PARITY — the two aggregators' `run:` scripts are byte-identical and both
|
|
21
|
+
* declare the same `env:` keys, so a fix to one cannot drift from the
|
|
22
|
+
* other. (Story #333 loosened this from "the whole `steps:` blocks are
|
|
23
|
+
* identical": ci.yml has no `workflow_call` inputs, so it must source
|
|
24
|
+
* `CANCELLED_POLICY` as a literal where pr-quality.yml sources it from
|
|
25
|
+
* `inputs.cancelled-policy`. The shared script reads it from the
|
|
26
|
+
* environment, which is what keeps the logic itself byte-identical.)
|
|
22
27
|
* 3. SEMANTICS — the shared run script passes on `success`/`skipped` and
|
|
23
28
|
* fails on anything else, INCLUDING `cancelled` (load-bearing for #223's
|
|
24
29
|
* fail-fast design), while naming the failing jobs and their results.
|
|
25
30
|
* Executed against real bash+jq; skipped when jq is unavailable locally
|
|
26
|
-
* (CI's ubuntu runner always has it).
|
|
31
|
+
* (CI's ubuntu runner always has it). `gh` is stubbed to fail so these
|
|
32
|
+
* tests stay hermetic — provenance classification (Story #333) has its
|
|
33
|
+
* own suite in scripts/check-cancelled-provenance.test.mjs.
|
|
27
34
|
*
|
|
28
35
|
* Run: node --test scripts/check-ci-required-aggregator.test.mjs
|
|
29
36
|
*/
|
|
30
37
|
|
|
31
38
|
import assert from "node:assert/strict";
|
|
32
39
|
import { test } from "node:test";
|
|
33
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
readFileSync,
|
|
42
|
+
mkdtempSync,
|
|
43
|
+
mkdirSync,
|
|
44
|
+
writeFileSync,
|
|
45
|
+
chmodSync,
|
|
46
|
+
rmSync,
|
|
47
|
+
} from "node:fs";
|
|
34
48
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
35
49
|
import { join, resolve, dirname } from "node:path";
|
|
36
50
|
import { fileURLToPath } from "node:url";
|
|
@@ -155,14 +169,61 @@ for (const { rel, needs, steps } of blocks) {
|
|
|
155
169
|
// 2. PARITY — the two implementations are textually identical
|
|
156
170
|
// ---------------------------------------------------------------------------
|
|
157
171
|
|
|
158
|
-
|
|
172
|
+
// Story #333 re-shaped this from "the whole `steps:` blocks are byte-identical"
|
|
173
|
+
// to "the `run:` scripts are byte-identical AND both declare the same `env:`
|
|
174
|
+
// keys". ci.yml has no `workflow_call` inputs, so it cannot source
|
|
175
|
+
// `CANCELLED_POLICY` from `inputs.cancelled-policy` the way pr-quality.yml
|
|
176
|
+
// does — but the LOGIC is what must not drift, and the script reads the policy
|
|
177
|
+
// from the environment precisely so the two can share it verbatim.
|
|
178
|
+
|
|
179
|
+
/** The `KEY:` names declared under a steps block's `env:` mapping. */
|
|
180
|
+
function extractEnvKeys(steps) {
|
|
181
|
+
const stepLines = steps.split("\n");
|
|
182
|
+
const start = stepLines.findIndex((l) => /^\s+env:\s*$/.test(l));
|
|
183
|
+
assert.notEqual(start, -1, "`env:` block not found");
|
|
184
|
+
const envIndent = stepLines[start].match(/^(\s*)/)[1].length;
|
|
185
|
+
const keys = [];
|
|
186
|
+
for (let i = start + 1; i < stepLines.length; i++) {
|
|
187
|
+
if (/^\s*$/.test(stepLines[i])) continue;
|
|
188
|
+
if (/^\s*#/.test(stepLines[i])) continue;
|
|
189
|
+
const indent = stepLines[i].match(/^(\s*)/)[1].length;
|
|
190
|
+
if (indent <= envIndent) break;
|
|
191
|
+
const key = stepLines[i].match(/^\s+([A-Za-z_][A-Za-z0-9_]*):/);
|
|
192
|
+
if (key) keys.push(key[1]);
|
|
193
|
+
}
|
|
194
|
+
assert.ok(keys.length > 0, "`env:` block declared no keys");
|
|
195
|
+
return keys.sort();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
test("pr-quality.yml and ci.yml aggregator run scripts are byte-identical", () => {
|
|
159
199
|
assert.equal(
|
|
160
|
-
blocks[0].steps,
|
|
161
|
-
blocks[1].steps,
|
|
162
|
-
"the two `ci-required`
|
|
200
|
+
extractRunScript(blocks[0].steps),
|
|
201
|
+
extractRunScript(blocks[1].steps),
|
|
202
|
+
"the two `ci-required` run scripts must not drift — apply every logic change to both"
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("pr-quality.yml and ci.yml aggregators declare the same env keys", () => {
|
|
207
|
+
assert.deepEqual(
|
|
208
|
+
extractEnvKeys(blocks[0].steps),
|
|
209
|
+
extractEnvKeys(blocks[1].steps),
|
|
210
|
+
"the shared run script reads its inputs from the environment — a key present " +
|
|
211
|
+
"in only one workflow would leave the other running the same script unconfigured"
|
|
163
212
|
);
|
|
164
213
|
});
|
|
165
214
|
|
|
215
|
+
test("the shared aggregator script never references workflow_call inputs", () => {
|
|
216
|
+
// ci.yml has none, so an `inputs.*` reference could not be mirrored and
|
|
217
|
+
// would break the byte-identical run-script guarantee above.
|
|
218
|
+
for (const { rel, steps } of blocks) {
|
|
219
|
+
assert.doesNotMatch(
|
|
220
|
+
extractRunScript(steps),
|
|
221
|
+
/inputs\./,
|
|
222
|
+
`${rel}: the run script must read configuration from \`env:\`, not \`inputs.*\``
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
166
227
|
// ---------------------------------------------------------------------------
|
|
167
228
|
// 3. SEMANTICS — pass on success/skipped, fail (naming jobs) on anything else
|
|
168
229
|
// ---------------------------------------------------------------------------
|
|
@@ -176,7 +237,7 @@ function jqAvailable() {
|
|
|
176
237
|
}
|
|
177
238
|
}
|
|
178
239
|
|
|
179
|
-
function runAggregator(needsResults) {
|
|
240
|
+
function runAggregator(needsResults, { captureSummary = false } = {}) {
|
|
180
241
|
const script = extractRunScript(blocks[0].steps);
|
|
181
242
|
const dir = mkdtempSync(join(tmpdir(), "ci-required-"));
|
|
182
243
|
try {
|
|
@@ -185,10 +246,36 @@ function runAggregator(needsResults) {
|
|
|
185
246
|
const needsJson = Object.fromEntries(
|
|
186
247
|
Object.entries(needsResults).map(([k, result]) => [k, { result, outputs: {} }])
|
|
187
248
|
);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
249
|
+
// Neutralize the provenance lookup (Story #333): shadow `gh` with a stub
|
|
250
|
+
// that always fails, so these tests stay hermetic and exercise the
|
|
251
|
+
// `unknown`-provenance path. Provenance classification itself is covered
|
|
252
|
+
// by scripts/check-cancelled-provenance.test.mjs.
|
|
253
|
+
const bin = join(dir, "bin");
|
|
254
|
+
mkdirSync(bin);
|
|
255
|
+
const ghStub = join(bin, "gh");
|
|
256
|
+
writeFileSync(ghStub, "#!/usr/bin/env bash\nexit 1\n");
|
|
257
|
+
chmodSync(ghStub, 0o755);
|
|
258
|
+
|
|
259
|
+
const env = {
|
|
260
|
+
...process.env,
|
|
261
|
+
PATH: `${bin}:${process.env.PATH}`,
|
|
262
|
+
NEEDS_JSON: JSON.stringify(needsJson),
|
|
263
|
+
};
|
|
264
|
+
// The step-summary write must degrade when GITHUB_STEP_SUMMARY is unset
|
|
265
|
+
// (this harness, and any `act`-style local runner), so only define it when
|
|
266
|
+
// a test is actually asserting on the summary body.
|
|
267
|
+
const summaryPath = join(dir, "summary.md");
|
|
268
|
+
if (captureSummary) {
|
|
269
|
+
writeFileSync(summaryPath, "");
|
|
270
|
+
env.GITHUB_STEP_SUMMARY = summaryPath;
|
|
271
|
+
} else {
|
|
272
|
+
delete env.GITHUB_STEP_SUMMARY;
|
|
273
|
+
}
|
|
274
|
+
const r = spawnSync("bash", [file], { encoding: "utf8", env });
|
|
275
|
+
return {
|
|
276
|
+
...r,
|
|
277
|
+
summary: captureSummary ? readFileSync(summaryPath, "utf8") : "",
|
|
278
|
+
};
|
|
192
279
|
} finally {
|
|
193
280
|
rmSync(dir, { recursive: true, force: true });
|
|
194
281
|
}
|
|
@@ -225,3 +312,72 @@ test("run script: every non-passing job is named", semantics, () => {
|
|
|
225
312
|
assert.match(r.stderr, /unit\(cancelled\)/);
|
|
226
313
|
assert.doesNotMatch(r.stderr, /e2e/);
|
|
227
314
|
});
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// 4. TRIAGE PARTITION (Story #331) — a fail-fast run reds N jobs but only one
|
|
318
|
+
// of them actually failed. The aggregate must say WHICH, so triage does not
|
|
319
|
+
// land on a collateral cancel.
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
test("run script: partitions own-failures from collateral cancels", semantics, () => {
|
|
323
|
+
const r = runAggregator({
|
|
324
|
+
lint: "success",
|
|
325
|
+
security: "failure",
|
|
326
|
+
unit: "cancelled",
|
|
327
|
+
e2e: "cancelled",
|
|
328
|
+
});
|
|
329
|
+
assert.equal(r.status, 1);
|
|
330
|
+
|
|
331
|
+
const own = r.stderr
|
|
332
|
+
.split("\n")
|
|
333
|
+
.find((l) => l.includes("Failed on their own"));
|
|
334
|
+
const collateral = r.stderr
|
|
335
|
+
.split("\n")
|
|
336
|
+
.find((l) => l.includes("do not triage"));
|
|
337
|
+
|
|
338
|
+
assert.ok(own, `no own-failure line in stderr:\n${r.stderr}`);
|
|
339
|
+
assert.ok(collateral, `no collateral line in stderr:\n${r.stderr}`);
|
|
340
|
+
|
|
341
|
+
// The tier that actually failed is named ONLY on the triage line, and the
|
|
342
|
+
// cancelled siblings ONLY on the collateral line — a partition, not two
|
|
343
|
+
// copies of the same flat list.
|
|
344
|
+
assert.match(own, /security\(failure\)/);
|
|
345
|
+
assert.doesNotMatch(own, /unit|e2e/);
|
|
346
|
+
assert.match(collateral, /unit\(cancelled\)/);
|
|
347
|
+
assert.match(collateral, /e2e\(cancelled\)/);
|
|
348
|
+
assert.doesNotMatch(collateral, /security/);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("run script: an all-cancelled aggregate says no job failed on its own", semantics, () => {
|
|
352
|
+
const r = runAggregator({ lint: "success", unit: "cancelled" }, { captureSummary: true });
|
|
353
|
+
assert.equal(r.status, 1);
|
|
354
|
+
assert.doesNotMatch(r.stderr, /Failed on their own/);
|
|
355
|
+
assert.match(r.summary, /No job reported its own failure/);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("run script: writes the triage partition to the job summary", semantics, () => {
|
|
359
|
+
const r = runAggregator(
|
|
360
|
+
{ security: "failure", unit: "cancelled" },
|
|
361
|
+
{ captureSummary: true }
|
|
362
|
+
);
|
|
363
|
+
assert.equal(r.status, 1);
|
|
364
|
+
assert.match(r.summary, /ci-required failed/);
|
|
365
|
+
assert.match(r.summary, /Failed on their own[^\n]*security\(failure\)/);
|
|
366
|
+
assert.match(r.summary, /Cancelled[^\n]*do not triage:[^\n]*unit\(cancelled\)/);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("run script: a green aggregate writes no summary", semantics, () => {
|
|
370
|
+
const r = runAggregator({ lint: "success", unit: "success" }, { captureSummary: true });
|
|
371
|
+
assert.equal(r.status, 0, r.stderr);
|
|
372
|
+
assert.equal(r.summary, "");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("run script: survives an unset GITHUB_STEP_SUMMARY", semantics, () => {
|
|
376
|
+
// The failure path writes a summary; with the variable unset (local runners,
|
|
377
|
+
// `act`, this harness) the redirect must degrade rather than error out and
|
|
378
|
+
// swallow the exit-1 verdict.
|
|
379
|
+
const r = runAggregator({ unit: "failure" });
|
|
380
|
+
assert.equal(r.status, 1);
|
|
381
|
+
assert.match(r.stderr, /unit\(failure\)/);
|
|
382
|
+
assert.doesNotMatch(r.stderr, /ambiguous redirect|No such file or directory/);
|
|
383
|
+
});
|
|
@@ -56,6 +56,43 @@ test("globToRegExp: `**` crosses path separators, `*` does not", () => {
|
|
|
56
56
|
assert.ok(globToRegExp("*.sql").test("x.sql"));
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
// The glob set reaches this function from a CLI argument, so CodeQL reports
|
|
60
|
+
// `js/regex-injection` (high) on the `new RegExp` it builds. That alert is a
|
|
61
|
+
// false positive ONLY for as long as the translator escapes every regex
|
|
62
|
+
// metacharacter — the input must be able to control `*` / `**` semantics and
|
|
63
|
+
// nothing else. These two tests are the evidence that claim rests on; if a
|
|
64
|
+
// future edit drops a character from the escape set, they fail rather than
|
|
65
|
+
// letting a stale dismissal stand.
|
|
66
|
+
test("globToRegExp: escapes every regex metacharacter to a literal", () => {
|
|
67
|
+
// `*` is excluded deliberately — it is the one character the glob syntax
|
|
68
|
+
// gives meaning to. `/` is not a metacharacter in the RegExp *constructor*
|
|
69
|
+
// (only in literal notation), but is asserted anyway.
|
|
70
|
+
const METACHARS = [".", "+", "?", "^", "$", "{", "}", "(", ")", "|", "[", "]", "\\", "/"];
|
|
71
|
+
for (const meta of METACHARS) {
|
|
72
|
+
const glob = `a${meta}b`;
|
|
73
|
+
const re = globToRegExp(glob);
|
|
74
|
+
assert.ok(re.test(glob), `\`${meta}\` must match itself literally (got ${re})`);
|
|
75
|
+
assert.ok(
|
|
76
|
+
!re.test("aXb"),
|
|
77
|
+
`\`${meta}\` leaked regex meaning — it matched an arbitrary character (got ${re})`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("globToRegExp: a regex-injection payload translates to a literal pattern", () => {
|
|
83
|
+
// The classic catastrophic-backtracking payload. If any of it survived
|
|
84
|
+
// unescaped, this pattern would carry regex semantics into the matcher.
|
|
85
|
+
const re = globToRegExp("(a+)+$");
|
|
86
|
+
// Compared as a STRING, not against a regex literal: writing the expected
|
|
87
|
+
// pattern as `/^\(a\+\)\+\$$/` puts a (wholly escaped, quantifier-free)
|
|
88
|
+
// regex literal in the file that CodeQL's js/redos analysis reads as a live
|
|
89
|
+
// backtracking hazard. The assertion is identical in strength — this test
|
|
90
|
+
// has always been about the produced source text.
|
|
91
|
+
assert.equal(String(re), "/^\\(a\\+\\)\\+\\$$/");
|
|
92
|
+
assert.ok(re.test("(a+)+$"), "the payload matches only its literal self");
|
|
93
|
+
assert.ok(!re.test("aaaaaaaa"), "the payload carries no quantifier semantics");
|
|
94
|
+
});
|
|
95
|
+
|
|
59
96
|
test("isMigrationFile: matches migration globs and any .sql tail", () => {
|
|
60
97
|
assert.ok(isMigrationFile("apps/api/migrations/0007_drop.ts"));
|
|
61
98
|
assert.ok(isMigrationFile("drizzle/0001_init.sql"));
|
|
@@ -90,7 +90,14 @@ export const RULES = [
|
|
|
90
90
|
{
|
|
91
91
|
id: 'quality-yml-ref',
|
|
92
92
|
description: 'References `quality.yml` — verify this file exists in the project (swarm-os ships `ci.yml` instead)',
|
|
93
|
-
|
|
93
|
+
// The bare filename only — the lookbehind stops the platform's OWN
|
|
94
|
+
// `pr-quality.yml` (and any other `<prefix>-quality.yml`) from matching as
|
|
95
|
+
// a substring. Without it this rule produced 58 findings against
|
|
96
|
+
// mandrel-platform's docs and 1 was a real bare reference, drowning a
|
|
97
|
+
// genuine `expired-placeholder` error in known-benign warnings. Guarding on
|
|
98
|
+
// `[-\w]` rather than spelling out `pr-` keeps it correct for a consumer
|
|
99
|
+
// that names its own caller `ci-quality.yml`.
|
|
100
|
+
pattern: /(?<![-\w])quality\.yml/g,
|
|
94
101
|
severity: 'warning',
|
|
95
102
|
},
|
|
96
103
|
{
|
|
@@ -102,7 +109,13 @@ export const RULES = [
|
|
|
102
109
|
// 4-digit 20xx year and defer the "is it actually in the past?" decision
|
|
103
110
|
// to `matchFilter`, so the rule stays correct as the calendar advances and
|
|
104
111
|
// never flags a still-valid FUTURE expiry.
|
|
105
|
-
|
|
112
|
+
// The optional quotes either side of the separator are load-bearing: the
|
|
113
|
+
// CVE allowlist's own shape is JSON (`"expires": "2026-12-31"` — see
|
|
114
|
+
// audit-check.mjs), and `expires"` is neither `:` nor whitespace, so the
|
|
115
|
+
// unquoted-only form skipped every documented allowlist entry. That is the
|
|
116
|
+
// same fail-open class as the 202[0-4] year window this rule already fixed;
|
|
117
|
+
// it was hiding a second lapsed date in docs/runbooks/dependency-update.md.
|
|
118
|
+
pattern: /expires['"]?[:\s]+['"]?(20\d{2}-\d{2}-\d{2})/gi,
|
|
106
119
|
severity: 'error',
|
|
107
120
|
// Only flag when the captured date is strictly before today (UTC). Future
|
|
108
121
|
// expiries are still valid and must not be reported.
|
|
@@ -3,16 +3,23 @@
|
|
|
3
3
|
* check-docs-staleness.test.mjs — node:test suite for the docs-staleness lint
|
|
4
4
|
* (Story #197).
|
|
5
5
|
*
|
|
6
|
-
* Focus: the
|
|
7
|
-
* years 2020–2024 (`/expires[:\s]+202[0-4]-\d{2}-\d{2}/i`), so an expiry that
|
|
8
|
-
* lapsed in 2025, 2026, or any later year sailed through the gate — a
|
|
9
|
-
* fail-open. The fix broadens the pattern to any 20xx year and defers the
|
|
10
|
-
* "is it actually in the past?" decision to `isExpiredDate`, so the rule stays
|
|
11
|
-
* correct as the calendar advances and never flags a still-valid future date.
|
|
6
|
+
* Focus: rule PRECISION — the two ways a staleness rule stops being useful.
|
|
12
7
|
*
|
|
13
|
-
*
|
|
14
|
-
* (`
|
|
15
|
-
*
|
|
8
|
+
* 1. Fail-open (`expired-placeholder` under-fires). The rule first hardcoded
|
|
9
|
+
* the years 2020–2024 (`/expires[:\s]+202[0-4]-\d{2}-\d{2}/i`), so an expiry
|
|
10
|
+
* that lapsed in 2025 or later sailed through. The fix broadens to any 20xx
|
|
11
|
+
* year and defers "is it actually in the past?" to `isExpiredDate`, so the
|
|
12
|
+
* rule stays correct as the calendar advances and never flags a still-valid
|
|
13
|
+
* future date. A second instance of the same class: the separator required
|
|
14
|
+
* `expires` to be followed directly by `:` or whitespace, which skipped the
|
|
15
|
+
* CVE allowlist's own JSON shape (`"expires": "…"`).
|
|
16
|
+
* 2. Fail-noisy (`quality-yml-ref` over-fires). A plain substring match meant
|
|
17
|
+
* the platform's own `pr-quality.yml` matched, so 58 of 59 findings were
|
|
18
|
+
* false positives — enough to bury a real error in the same run.
|
|
19
|
+
*
|
|
20
|
+
* These tests exercise the date logic directly (`isExpiredDate`), the compiled
|
|
21
|
+
* patterns, and end-to-end behaviour (`lintFile` against a real fixture file),
|
|
22
|
+
* pinning "today" via a fixed clock so they are deterministic.
|
|
16
23
|
*
|
|
17
24
|
* Run: node --test scripts/check-docs-staleness.test.mjs
|
|
18
25
|
*/
|
|
@@ -128,3 +135,101 @@ test('lintFile honours the staleness-ignore suppression comment for the year rul
|
|
|
128
135
|
},
|
|
129
136
|
);
|
|
130
137
|
});
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// expired-placeholder — the JSON-quoted shape.
|
|
141
|
+
//
|
|
142
|
+
// Second fail-open of the same class as the 202[0-4] year window: the pattern
|
|
143
|
+
// required `expires` to be followed directly by `:` or whitespace, so the CVE
|
|
144
|
+
// allowlist's own JSON shape (`"expires": "2026-12-31"`, per audit-check.mjs)
|
|
145
|
+
// never matched — `expires"` is neither. Every documented allowlist entry was
|
|
146
|
+
// therefore invisible to the gate, including a lapsed one in
|
|
147
|
+
// docs/runbooks/dependency-update.md.
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
test('expired-placeholder matches the JSON-quoted allowlist shape', () => {
|
|
151
|
+
const rule = RULES.find((r) => r.id === 'expired-placeholder');
|
|
152
|
+
for (const line of [
|
|
153
|
+
' "expires": "2025-12-31",', // the CVE allowlist's real shape
|
|
154
|
+
" 'expires': '2025-12-31',", // single-quoted (YAML/JS)
|
|
155
|
+
' expires: "2025-12-31"', // quoted value, bare key
|
|
156
|
+
]) {
|
|
157
|
+
rule.pattern.lastIndex = 0;
|
|
158
|
+
assert.ok(rule.pattern.test(line), `pattern should match ${JSON.stringify(line)}`);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('expired-placeholder still matches the unquoted shapes (no regression)', () => {
|
|
163
|
+
const rule = RULES.find((r) => r.id === 'expired-placeholder');
|
|
164
|
+
for (const line of [
|
|
165
|
+
'expires: 2025-01-01',
|
|
166
|
+
'# CVE-2022-3517 — expires 2025-06-01',
|
|
167
|
+
]) {
|
|
168
|
+
rule.pattern.lastIndex = 0;
|
|
169
|
+
assert.ok(rule.pattern.test(line), `pattern should match ${JSON.stringify(line)}`);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('lintFile flags a lapsed JSON-quoted expiry end-to-end', () => {
|
|
174
|
+
withTempDoc('Allowlist entry.\n "expires": "2025-12-31",\nEnd.\n', (file) => {
|
|
175
|
+
const expired = lintFile(file).filter((f) => f.rule.id === 'expired-placeholder');
|
|
176
|
+
assert.equal(expired.length, 1);
|
|
177
|
+
assert.match(expired[0].match, /2025-12-31/);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('lintFile does NOT flag a future JSON-quoted expiry', () => {
|
|
182
|
+
withTempDoc('Allowlist entry.\n "expires": "2099-12-31",\nEnd.\n', (file) => {
|
|
183
|
+
const expired = lintFile(file).filter((f) => f.rule.id === 'expired-placeholder');
|
|
184
|
+
assert.equal(expired.length, 0);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('lintFile does NOT flag a placeholder expiry token', () => {
|
|
189
|
+
// The runbooks intentionally use `<YYYY-MM-DD>` rather than a concrete date,
|
|
190
|
+
// precisely so an example can never lapse into a finding.
|
|
191
|
+
withTempDoc('Allowlist entry.\n "expires": "<YYYY-MM-DD>",\nEnd.\n', (file) => {
|
|
192
|
+
const expired = lintFile(file).filter((f) => f.rule.id === 'expired-placeholder');
|
|
193
|
+
assert.equal(expired.length, 0);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// quality-yml-ref — bare filename only.
|
|
199
|
+
//
|
|
200
|
+
// The pattern was a plain substring match, so the platform's own
|
|
201
|
+
// `pr-quality.yml` matched: 58 of 59 findings against mandrel-platform's docs
|
|
202
|
+
// were that false positive, burying a real `expired-placeholder` error. The
|
|
203
|
+
// assertion below is on the invariant (a `<prefix>-quality.yml` is a different
|
|
204
|
+
// file) rather than on the single `pr-` spelling that motivated it.
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
test('quality-yml-ref does NOT match a prefixed <prefix>-quality.yml', () => {
|
|
208
|
+
const rule = RULES.find((r) => r.id === 'quality-yml-ref');
|
|
209
|
+
assert.ok(rule, 'quality-yml-ref rule must exist');
|
|
210
|
+
for (const line of [
|
|
211
|
+
'uses: dsj1984/mandrel-platform/.github/workflows/pr-quality.yml@abc123',
|
|
212
|
+
'the `pr-quality.yml` reusable workflow',
|
|
213
|
+
'a consumer that names its caller `ci-quality.yml`',
|
|
214
|
+
'see my_quality.yml for details',
|
|
215
|
+
]) {
|
|
216
|
+
rule.pattern.lastIndex = 0;
|
|
217
|
+
assert.equal(
|
|
218
|
+
rule.pattern.test(line),
|
|
219
|
+
false,
|
|
220
|
+
`pattern must not match ${JSON.stringify(line)}`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('quality-yml-ref still matches a bare quality.yml reference', () => {
|
|
226
|
+
const rule = RULES.find((r) => r.id === 'quality-yml-ref');
|
|
227
|
+
for (const line of [
|
|
228
|
+
"| athportal | `quality.yml` | `quality` |",
|
|
229
|
+
'the quality.yml workflow was renamed',
|
|
230
|
+
'quality.yml',
|
|
231
|
+
]) {
|
|
232
|
+
rule.pattern.lastIndex = 0;
|
|
233
|
+
assert.ok(rule.pattern.test(line), `pattern should match ${JSON.stringify(line)}`);
|
|
234
|
+
}
|
|
235
|
+
});
|