mandrel-platform 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -20
- package/config/edge-security/rate-limit.mjs +103 -20
- package/default.json +4 -19
- package/package.json +1 -1
- package/scripts/audit-check.mjs +321 -180
- package/scripts/audit-check.test.mjs +263 -0
- package/scripts/check-action-pins.mjs +106 -173
- package/scripts/check-coverage-threshold.mjs +44 -6
- package/scripts/check-coverage-threshold.test.mjs +43 -0
- package/scripts/check-docs-staleness.mjs +130 -81
- package/scripts/check-docs-staleness.test.mjs +130 -0
- package/scripts/check-pin-drift.mjs +61 -110
- package/scripts/check-pin-drift.test.mjs +175 -3
- package/scripts/check-workflow-portability.mjs +163 -118
- package/scripts/check-workflow-portability.test.mjs +199 -0
- package/scripts/edge-security.test.mjs +81 -1
- package/scripts/lib/args.mjs +93 -0
- package/scripts/lib/args.test.mjs +152 -0
- package/scripts/lib/gh-json.mjs +119 -0
- package/scripts/lib/semver-duration.mjs +84 -0
- package/scripts/lib/uses-pins.mjs +220 -0
- package/scripts/lib/uses-pins.test.mjs +219 -0
- package/scripts/lib/walk.mjs +74 -0
- package/scripts/platform-repair.mjs +9 -3
- package/scripts/update-semgrep-rules.mjs +76 -5
- package/templates/runbooks/README.md +9 -5
- package/templates/runbooks/branch-protection-setup.md +9 -3
|
@@ -68,41 +68,49 @@
|
|
|
68
68
|
* It is dependency-free (no YAML parser) so it copies cleanly into any repo.
|
|
69
69
|
*/
|
|
70
70
|
|
|
71
|
-
import { readFileSync,
|
|
71
|
+
import { readFileSync, statSync, existsSync } from "node:fs";
|
|
72
72
|
import { execFileSync } from "node:child_process";
|
|
73
73
|
import { resolve, join, relative, basename } from "node:path";
|
|
74
74
|
|
|
75
|
+
import { parseFlags } from "./lib/args.mjs";
|
|
76
|
+
import { parseUsesLine, classifyUses, isSha40 } from "./lib/uses-pins.mjs";
|
|
77
|
+
import { listWorkflowFiles, listActionFiles } from "./lib/walk.mjs";
|
|
78
|
+
|
|
75
79
|
// ---------------------------------------------------------------------------
|
|
76
80
|
// Arg parsing
|
|
77
81
|
// ---------------------------------------------------------------------------
|
|
78
82
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Parse the CLI argv slice into an options object. Pure — no I/O, no exit — so
|
|
85
|
+
* the sibling node:test suite can exercise it. `--help` is surfaced as a flag
|
|
86
|
+
* for the CLI wrapper to act on rather than exiting here. Unknown flags are
|
|
87
|
+
* ignored (lenient CLI), preserving this script's historical behavior.
|
|
88
|
+
*/
|
|
89
|
+
export function parseArgs(argv) {
|
|
90
|
+
return parseFlags(argv, {
|
|
91
|
+
flags: {
|
|
92
|
+
"--workflows-dir": { type: "string", dest: "workflowsDir", default: null },
|
|
93
|
+
"--actions-dir": { type: "string", dest: "actionsDir", default: null },
|
|
94
|
+
"--no-pin-check": { type: "boolean", dest: "pinCheck", value: false, default: true },
|
|
95
|
+
"--help": { type: "boolean", dest: "help", value: true, default: false },
|
|
96
|
+
},
|
|
97
|
+
aliases: {
|
|
98
|
+
"-w": "--workflows-dir",
|
|
99
|
+
"-a": "--actions-dir",
|
|
100
|
+
"-h": "--help",
|
|
101
|
+
},
|
|
102
|
+
onUnknown: "ignore",
|
|
103
|
+
});
|
|
97
104
|
}
|
|
98
105
|
|
|
106
|
+
// Runtime bindings shared by the discovery/lint helpers below. Initialized at
|
|
107
|
+
// module load with safe defaults (cwd-relative dirs, pin-check on) and
|
|
108
|
+
// re-derived per invocation inside runCli() so importing this module never
|
|
109
|
+
// triggers a scan.
|
|
99
110
|
const repoRoot = process.cwd();
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const resolvedActionsDir = actionsDir
|
|
104
|
-
? resolve(actionsDir)
|
|
105
|
-
: resolve(repoRoot, ".github/actions");
|
|
111
|
+
let resolvedWorkflowsDir = resolve(repoRoot, ".github/workflows");
|
|
112
|
+
let resolvedActionsDir = resolve(repoRoot, ".github/actions");
|
|
113
|
+
let pinCheck = true;
|
|
106
114
|
|
|
107
115
|
// ---------------------------------------------------------------------------
|
|
108
116
|
// Minimal indentation-aware YAML walk (dependency-free)
|
|
@@ -117,7 +125,7 @@ const resolvedActionsDir = actionsDir
|
|
|
117
125
|
// for a parent mapping. Quotes are stripped from keys so `"on":` === `on`.
|
|
118
126
|
// ---------------------------------------------------------------------------
|
|
119
127
|
|
|
120
|
-
function walkYaml(content) {
|
|
128
|
+
export function walkYaml(content) {
|
|
121
129
|
const lines = content.split("\n");
|
|
122
130
|
const stack = []; // [{ indent, key }]
|
|
123
131
|
const records = [];
|
|
@@ -193,19 +201,42 @@ const EXPR = /\$\{\{/;
|
|
|
193
201
|
// Content checks (reused for both working-tree files and pinned blobs)
|
|
194
202
|
// ---------------------------------------------------------------------------
|
|
195
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Detect whether a workflow declares `workflow_call` — i.e. is reusable and
|
|
206
|
+
* therefore subject to Rules 1 & 2. Two shapes count:
|
|
207
|
+
*
|
|
208
|
+
* 1. Mapping form — `on:` is a mapping with a `workflow_call:` child, so the
|
|
209
|
+
* walk yields a record whose path is `on.workflow_call` (or a descendant).
|
|
210
|
+
* 2. Inline form — `workflow_call` sits directly on the `on:` value, as a
|
|
211
|
+
* bare scalar (`on: workflow_call`) or inside a flow sequence
|
|
212
|
+
* (`on: [workflow_call]`, `on: [push, workflow_call]`). The walk yields a
|
|
213
|
+
* single record with path `on` whose value carries the trigger token(s).
|
|
214
|
+
*
|
|
215
|
+
* The former mapping-only check silently skipped inline-declared reusable
|
|
216
|
+
* workflows, so Rules 1 & 2 never ran on them.
|
|
217
|
+
*/
|
|
218
|
+
export function isReusableWorkflow(records) {
|
|
219
|
+
return records.some((r) => {
|
|
220
|
+
const p = r.path.join(".");
|
|
221
|
+
if (p === "on.workflow_call" || p.startsWith("on.workflow_call.")) return true;
|
|
222
|
+
// Inline scalar / flow-sequence on the top-level `on:` key.
|
|
223
|
+
if (p === "on" && /\bworkflow_call\b/.test(r.value)) return true;
|
|
224
|
+
return false;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
196
228
|
/** Reusable-workflow checks (Rules 1 & 2). Returns [{line, message}]. */
|
|
197
|
-
function checkWorkflowContent(content) {
|
|
229
|
+
export function checkWorkflowContent(content) {
|
|
198
230
|
const violations = [];
|
|
199
231
|
const records = walkYaml(content);
|
|
200
232
|
|
|
201
|
-
|
|
202
|
-
(r) => r.path.join(".") === "on.workflow_call" || r.path.join(".").startsWith("on.workflow_call.")
|
|
203
|
-
);
|
|
204
|
-
if (!isReusable) return violations;
|
|
233
|
+
if (!isReusableWorkflow(records)) return violations;
|
|
205
234
|
|
|
206
|
-
// Rule 1: no relative `uses:` anywhere in a reusable workflow.
|
|
235
|
+
// Rule 1: no relative `uses:` anywhere in a reusable workflow. Allow an
|
|
236
|
+
// optional leading `- ` so the sequence/list form (`- uses: ./…`) is caught
|
|
237
|
+
// as well as the mapping form — mirrors check-action-pins.mjs's `usesRe`.
|
|
207
238
|
content.split("\n").forEach((raw, idx) => {
|
|
208
|
-
if (/^\s*uses:\s*['"]?\.\//.test(raw)) {
|
|
239
|
+
if (/^\s*(?:-\s+)?uses:\s*['"]?\.\//.test(raw)) {
|
|
209
240
|
violations.push({
|
|
210
241
|
line: idx + 1,
|
|
211
242
|
message:
|
|
@@ -239,7 +270,7 @@ function checkWorkflowContent(content) {
|
|
|
239
270
|
}
|
|
240
271
|
|
|
241
272
|
/** Composite-action checks (Rules 3/4 of the original; manifest cleanliness). */
|
|
242
|
-
function checkActionContent(content) {
|
|
273
|
+
export function checkActionContent(content) {
|
|
243
274
|
const violations = [];
|
|
244
275
|
const records = walkYaml(content);
|
|
245
276
|
|
|
@@ -307,15 +338,23 @@ function gitShow(sha, path) {
|
|
|
307
338
|
function collectInternalPins(content) {
|
|
308
339
|
const pins = [];
|
|
309
340
|
content.split("\n").forEach((raw, idx) => {
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
341
|
+
const bareRef = parseUsesLine(raw);
|
|
342
|
+
if (bareRef === null) return;
|
|
343
|
+
// `owner/repo/<subpath>@<sha>` — classify against *any* owner (this repo's
|
|
344
|
+
// own slug or a fork's), then keep only same-repo refs whose subpath
|
|
345
|
+
// resolves to a real path in the working tree. Passing the parsed owner as
|
|
346
|
+
// the "first-party" owner makes classifyUses treat every owner/repo the
|
|
347
|
+
// same; the existsSync gate below is what actually decides "internal".
|
|
348
|
+
const atIdx = bareRef.lastIndexOf("@");
|
|
349
|
+
if (atIdx === -1) return;
|
|
350
|
+
const owner = bareRef.slice(0, atIdx).split("/").slice(0, 2).join("/");
|
|
351
|
+
const cls = classifyUses(bareRef, owner);
|
|
352
|
+
if (cls.kind !== "first-party" || !cls.subpath) return;
|
|
353
|
+
if (!isSha40(cls.ref)) return; // only full-SHA pins are validated by Rule 3
|
|
354
|
+
const subpath = cls.subpath;
|
|
316
355
|
const local = join(repoRoot, subpath);
|
|
317
356
|
if (!existsSync(local)) return; // external ref → skip
|
|
318
|
-
pins.push({ subpath, sha, line: idx + 1 });
|
|
357
|
+
pins.push({ subpath, sha: cls.ref, line: idx + 1 });
|
|
319
358
|
});
|
|
320
359
|
return pins;
|
|
321
360
|
}
|
|
@@ -343,46 +382,10 @@ function resolvePinnedManifest(subpath) {
|
|
|
343
382
|
}
|
|
344
383
|
|
|
345
384
|
// ---------------------------------------------------------------------------
|
|
346
|
-
// File discovery
|
|
385
|
+
// File discovery — `listWorkflowFiles` / `listActionFiles` are imported from
|
|
386
|
+
// `./lib/walk.mjs` (Story #203).
|
|
347
387
|
// ---------------------------------------------------------------------------
|
|
348
388
|
|
|
349
|
-
function listWorkflowFiles(dir) {
|
|
350
|
-
let entries;
|
|
351
|
-
try {
|
|
352
|
-
entries = readdirSync(dir);
|
|
353
|
-
} catch {
|
|
354
|
-
return [];
|
|
355
|
-
}
|
|
356
|
-
return entries
|
|
357
|
-
.filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"))
|
|
358
|
-
.map((f) => join(dir, f));
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function listActionFiles(dir) {
|
|
362
|
-
const found = [];
|
|
363
|
-
let entries;
|
|
364
|
-
try {
|
|
365
|
-
entries = readdirSync(dir);
|
|
366
|
-
} catch {
|
|
367
|
-
return found;
|
|
368
|
-
}
|
|
369
|
-
for (const entry of entries) {
|
|
370
|
-
const full = join(dir, entry);
|
|
371
|
-
let st;
|
|
372
|
-
try {
|
|
373
|
-
st = statSync(full);
|
|
374
|
-
} catch {
|
|
375
|
-
continue;
|
|
376
|
-
}
|
|
377
|
-
if (st.isDirectory()) {
|
|
378
|
-
found.push(...listActionFiles(full));
|
|
379
|
-
} else if (entry === "action.yml" || entry === "action.yaml") {
|
|
380
|
-
found.push(full);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
return found;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
389
|
// ---------------------------------------------------------------------------
|
|
387
390
|
// Per-file lint
|
|
388
391
|
// ---------------------------------------------------------------------------
|
|
@@ -442,52 +445,94 @@ function lintFile(filePath) {
|
|
|
442
445
|
// Run
|
|
443
446
|
// ---------------------------------------------------------------------------
|
|
444
447
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
)
|
|
448
|
+
/**
|
|
449
|
+
* Run the full lint end-to-end and return a POSIX exit code (0 = clean,
|
|
450
|
+
* 1 = violations). Applies the parsed options to the shared runtime bindings,
|
|
451
|
+
* discovers the workflow/action files, lints each, and reports. `log` / `err`
|
|
452
|
+
* are injectable so the sibling node:test suite can capture output without
|
|
453
|
+
* touching the real streams; the direct-invocation guard below wires them to
|
|
454
|
+
* process.stdout / process.stderr.
|
|
455
|
+
*/
|
|
456
|
+
export function runCli(argv, { log = writeStdout, err = writeStderr } = {}) {
|
|
457
|
+
const opts = parseArgs(argv);
|
|
458
|
+
if (opts.help) {
|
|
459
|
+
log(
|
|
460
|
+
"Usage: node scripts/check-workflow-portability.mjs [--workflows-dir <dir>] [--actions-dir <dir>] [--no-pin-check]\n"
|
|
461
|
+
);
|
|
462
|
+
return 0;
|
|
463
|
+
}
|
|
454
464
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
465
|
+
resolvedWorkflowsDir = opts.workflowsDir
|
|
466
|
+
? resolve(opts.workflowsDir)
|
|
467
|
+
: resolve(repoRoot, ".github/workflows");
|
|
468
|
+
resolvedActionsDir = opts.actionsDir
|
|
469
|
+
? resolve(opts.actionsDir)
|
|
470
|
+
: resolve(repoRoot, ".github/actions");
|
|
471
|
+
pinCheck = opts.pinCheck;
|
|
472
|
+
pinSkips.length = 0; // idempotent across repeated runCli() calls
|
|
473
|
+
|
|
474
|
+
const workflowFiles = listWorkflowFiles(resolvedWorkflowsDir);
|
|
475
|
+
const actionFiles = listActionFiles(resolvedActionsDir);
|
|
476
|
+
const allFiles = [...workflowFiles, ...actionFiles];
|
|
477
|
+
|
|
478
|
+
log(
|
|
479
|
+
`[check-workflow-portability] Workflows: ${relative(repoRoot, resolvedWorkflowsDir)}/ (${workflowFiles.length})\n` +
|
|
480
|
+
`[check-workflow-portability] Actions : ${relative(repoRoot, resolvedActionsDir)}/ (${actionFiles.length})\n` +
|
|
481
|
+
`[check-workflow-portability] Pin check: ${pinCheck ? (isGitRepo() ? "on" : "on (git unavailable — skipped)") : "off"}\n`
|
|
458
482
|
);
|
|
459
|
-
process.exit(0);
|
|
460
|
-
}
|
|
461
483
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
484
|
+
if (allFiles.length === 0) {
|
|
485
|
+
log(
|
|
486
|
+
`[check-workflow-portability] No workflow or action files found — nothing to lint.\n`
|
|
487
|
+
);
|
|
488
|
+
return 0;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
let total = 0;
|
|
492
|
+
for (const file of allFiles) {
|
|
493
|
+
const violations = lintFile(file);
|
|
494
|
+
if (violations.length === 0) continue;
|
|
495
|
+
total += violations.length;
|
|
496
|
+
const rel = relative(repoRoot, file);
|
|
497
|
+
err(`\n[check-workflow-portability] ❌ ${rel}\n`);
|
|
498
|
+
for (const v of violations) {
|
|
499
|
+
err(` ${rel}:${v.line} — ${v.message}\n`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (pinSkips.length > 0) {
|
|
504
|
+
log(
|
|
505
|
+
`\n[check-workflow-portability] ⚠️ ${pinSkips.length} internal pin(s) could not be verified (Rule 3 skipped):\n`
|
|
506
|
+
);
|
|
507
|
+
for (const s of pinSkips) log(` ${s}\n`);
|
|
471
508
|
}
|
|
472
|
-
}
|
|
473
509
|
|
|
474
|
-
if (
|
|
475
|
-
|
|
476
|
-
|
|
510
|
+
if (total > 0) {
|
|
511
|
+
err(
|
|
512
|
+
`\n[check-workflow-portability] ${total} portability violation${total === 1 ? "" : "s"} detected.\n` +
|
|
513
|
+
` These fail only when a CONSUMER repo calls the workflow/action, which is\n` +
|
|
514
|
+
` exactly why in-repo CI never caught them before. Fix each above.\n\n`
|
|
515
|
+
);
|
|
516
|
+
return 1;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
log(
|
|
520
|
+
`[check-workflow-portability] ✅ All reusable workflows and composite actions are cross-repo portable.\n`
|
|
477
521
|
);
|
|
478
|
-
|
|
522
|
+
return 0;
|
|
479
523
|
}
|
|
480
524
|
|
|
481
|
-
|
|
482
|
-
process.
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
);
|
|
487
|
-
process.exit(1);
|
|
525
|
+
function writeStdout(s) {
|
|
526
|
+
process.stdout.write(s);
|
|
527
|
+
}
|
|
528
|
+
function writeStderr(s) {
|
|
529
|
+
process.stderr.write(s);
|
|
488
530
|
}
|
|
489
531
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
process.
|
|
532
|
+
// Only run when executed directly, not when imported by the test suite.
|
|
533
|
+
const invokedDirectly =
|
|
534
|
+
process.argv[1] &&
|
|
535
|
+
resolve(process.argv[1]).endsWith("check-workflow-portability.mjs");
|
|
536
|
+
if (invokedDirectly) {
|
|
537
|
+
process.exit(runCli(process.argv.slice(2)));
|
|
538
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-workflow-portability.test.mjs — node:test suite for the cross-repo
|
|
4
|
+
* portability lint (Story #199).
|
|
5
|
+
*
|
|
6
|
+
* This is the "equivalent self-test" the Story's acceptance criteria call for.
|
|
7
|
+
* It exercises the two blind spots the Story closes, plus the surrounding
|
|
8
|
+
* behavior so the fixes are pinned against regression:
|
|
9
|
+
*
|
|
10
|
+
* 1. Rule 1 now catches the sequence/list form `- uses: ./…` (previously the
|
|
11
|
+
* leading `- ` slipped past the mapping-only regex), so a local action
|
|
12
|
+
* referenced inside a workflow_call workflow IS flagged.
|
|
13
|
+
* 2. `on: workflow_call` declared INLINE (bare scalar or flow sequence) is
|
|
14
|
+
* detected as reusable, so Rules 1 & 2 actually run on it — the former
|
|
15
|
+
* mapping-only `on.workflow_call` check silently skipped these.
|
|
16
|
+
*
|
|
17
|
+
* Pure exported helpers keep the whole suite offline — no temp dirs, no git.
|
|
18
|
+
*
|
|
19
|
+
* Run: node --test scripts/check-workflow-portability.test.mjs
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import assert from "node:assert/strict";
|
|
23
|
+
import { test } from "node:test";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
walkYaml,
|
|
27
|
+
isReusableWorkflow,
|
|
28
|
+
checkWorkflowContent,
|
|
29
|
+
checkActionContent,
|
|
30
|
+
parseArgs,
|
|
31
|
+
} from "./check-workflow-portability.mjs";
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// isReusableWorkflow — inline vs mapping workflow_call detection
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
test("isReusableWorkflow: mapping form (on: > workflow_call:) is reusable", () => {
|
|
38
|
+
const yaml = ["on:", " workflow_call:", " inputs:", " foo:", " type: string", ""].join("\n");
|
|
39
|
+
assert.equal(isReusableWorkflow(walkYaml(yaml)), true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("isReusableWorkflow: inline scalar form (on: workflow_call) is reusable", () => {
|
|
43
|
+
const yaml = ["name: reusable", "on: workflow_call", "jobs: {}", ""].join("\n");
|
|
44
|
+
assert.equal(isReusableWorkflow(walkYaml(yaml)), true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("isReusableWorkflow: inline flow-sequence form (on: [workflow_call]) is reusable", () => {
|
|
48
|
+
const yaml = ["on: [workflow_call]", "jobs: {}", ""].join("\n");
|
|
49
|
+
assert.equal(isReusableWorkflow(walkYaml(yaml)), true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("isReusableWorkflow: mixed inline flow-sequence (on: [push, workflow_call]) is reusable", () => {
|
|
53
|
+
const yaml = ["on: [push, workflow_call]", "jobs: {}", ""].join("\n");
|
|
54
|
+
assert.equal(isReusableWorkflow(walkYaml(yaml)), true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("isReusableWorkflow: a plain push-triggered workflow is NOT reusable", () => {
|
|
58
|
+
const yaml = ["on:", " push:", " branches: [main]", "jobs: {}", ""].join("\n");
|
|
59
|
+
assert.equal(isReusableWorkflow(walkYaml(yaml)), false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("isReusableWorkflow: inline scalar `on: push` is NOT reusable (no false positive)", () => {
|
|
63
|
+
const yaml = ["on: push", "jobs: {}", ""].join("\n");
|
|
64
|
+
assert.equal(isReusableWorkflow(walkYaml(yaml)), false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Rule 1 — relative `uses: ./…`, both mapping and sequence forms
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
test("Rule 1: sequence form `- uses: ./…` inside a workflow_call workflow IS flagged", () => {
|
|
72
|
+
const yaml = [
|
|
73
|
+
"on:",
|
|
74
|
+
" workflow_call:",
|
|
75
|
+
"jobs:",
|
|
76
|
+
" build:",
|
|
77
|
+
" runs-on: ubuntu-latest",
|
|
78
|
+
" steps:",
|
|
79
|
+
" - uses: ./.github/actions/foo",
|
|
80
|
+
"",
|
|
81
|
+
].join("\n");
|
|
82
|
+
const violations = checkWorkflowContent(yaml);
|
|
83
|
+
assert.equal(violations.length, 1, "expected exactly one Rule 1 violation");
|
|
84
|
+
assert.match(violations[0].message, /relative `uses: \.\/` path/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("Rule 1: mapping form `uses: ./…` is still flagged (no regression)", () => {
|
|
88
|
+
const yaml = [
|
|
89
|
+
"on:",
|
|
90
|
+
" workflow_call:",
|
|
91
|
+
"jobs:",
|
|
92
|
+
" build:",
|
|
93
|
+
" uses: ./.github/workflows/reused.yml",
|
|
94
|
+
"",
|
|
95
|
+
].join("\n");
|
|
96
|
+
const violations = checkWorkflowContent(yaml);
|
|
97
|
+
assert.equal(violations.length, 1);
|
|
98
|
+
assert.match(violations[0].message, /relative `uses: \.\/` path/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("Rule 1: sequence-form relative `uses` combined with INLINE workflow_call IS flagged", () => {
|
|
102
|
+
// Combines both fixes: inline reusable detection + sequence-form catch.
|
|
103
|
+
const yaml = [
|
|
104
|
+
"name: reusable-inline",
|
|
105
|
+
"on: workflow_call",
|
|
106
|
+
"jobs:",
|
|
107
|
+
" build:",
|
|
108
|
+
" runs-on: ubuntu-latest",
|
|
109
|
+
" steps:",
|
|
110
|
+
" - uses: ./.github/actions/foo",
|
|
111
|
+
"",
|
|
112
|
+
].join("\n");
|
|
113
|
+
const violations = checkWorkflowContent(yaml);
|
|
114
|
+
assert.equal(violations.length, 1);
|
|
115
|
+
assert.match(violations[0].message, /relative `uses: \.\/` path/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("Rule 1: absolute owner/repo `uses` in a reusable workflow is NOT flagged", () => {
|
|
119
|
+
const yaml = [
|
|
120
|
+
"on: workflow_call",
|
|
121
|
+
"jobs:",
|
|
122
|
+
" build:",
|
|
123
|
+
" runs-on: ubuntu-latest",
|
|
124
|
+
" steps:",
|
|
125
|
+
" - uses: actions/checkout@v4",
|
|
126
|
+
"",
|
|
127
|
+
].join("\n");
|
|
128
|
+
assert.deepEqual(checkWorkflowContent(yaml), []);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("Rule 1: relative `uses` in a NON-reusable workflow is NOT flagged", () => {
|
|
132
|
+
// `./` is portable when the workflow only runs in its own repo.
|
|
133
|
+
const yaml = [
|
|
134
|
+
"on:",
|
|
135
|
+
" push:",
|
|
136
|
+
"jobs:",
|
|
137
|
+
" build:",
|
|
138
|
+
" steps:",
|
|
139
|
+
" - uses: ./.github/actions/foo",
|
|
140
|
+
"",
|
|
141
|
+
].join("\n");
|
|
142
|
+
assert.deepEqual(checkWorkflowContent(yaml), []);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Rule 2 — ${{ }} in workflow_call input/secret meta runs on inline form too
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
test("Rule 2: still fires on the mapping workflow_call form", () => {
|
|
150
|
+
const yaml = [
|
|
151
|
+
"on:",
|
|
152
|
+
" workflow_call:",
|
|
153
|
+
" inputs:",
|
|
154
|
+
" name:",
|
|
155
|
+
" description: ${{ runner.os }}",
|
|
156
|
+
"",
|
|
157
|
+
].join("\n");
|
|
158
|
+
const violations = checkWorkflowContent(yaml);
|
|
159
|
+
assert.equal(violations.length, 1);
|
|
160
|
+
assert.match(violations[0].message, /expression in workflow_call/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// checkActionContent — untouched by this Story, guarded against regression
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
test("checkActionContent: flags ${{ }} in a composite input default", () => {
|
|
168
|
+
const yaml = [
|
|
169
|
+
"inputs:",
|
|
170
|
+
" dest:",
|
|
171
|
+
" default: ${{ runner.temp }}",
|
|
172
|
+
"",
|
|
173
|
+
].join("\n");
|
|
174
|
+
const violations = checkActionContent(yaml);
|
|
175
|
+
assert.equal(violations.length, 1);
|
|
176
|
+
assert.match(violations[0].message, /expression in action input/);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// parseArgs — pure option parsing
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
test("parseArgs: defaults are pin-check on, no dir overrides, no help", () => {
|
|
184
|
+
assert.deepEqual(parseArgs([]), {
|
|
185
|
+
workflowsDir: null,
|
|
186
|
+
actionsDir: null,
|
|
187
|
+
pinCheck: true,
|
|
188
|
+
help: false,
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("parseArgs: --no-pin-check, dir overrides, and --help are parsed", () => {
|
|
193
|
+
assert.deepEqual(parseArgs(["-w", "wf", "--actions-dir", "act", "--no-pin-check", "--help"]), {
|
|
194
|
+
workflowsDir: "wf",
|
|
195
|
+
actionsDir: "act",
|
|
196
|
+
pinCheck: false,
|
|
197
|
+
help: true,
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -252,12 +252,92 @@ test("default key extractor fails closed to a shared bucket when no IP", async (
|
|
|
252
252
|
assert.equal((await limiter.check(bare())).allowed, false); // same "anonymous" bucket
|
|
253
253
|
});
|
|
254
254
|
|
|
255
|
-
test("memory store
|
|
255
|
+
test("memory store evicts expired buckets on access", async () => {
|
|
256
256
|
const store = createMemoryStore();
|
|
257
257
|
store.set("k", { count: 5, resetAt: Date.now() - 1 });
|
|
258
258
|
assert.equal(store.get("k"), null);
|
|
259
259
|
});
|
|
260
260
|
|
|
261
|
+
test("memory store sweeps expired buckets on write so distinct keys do not leak", () => {
|
|
262
|
+
const store = createMemoryStore({ maxBuckets: 4 });
|
|
263
|
+
// Seed the store to capacity with already-expired buckets.
|
|
264
|
+
for (let i = 0; i < 4; i += 1) {
|
|
265
|
+
store.set(`expired-${i}`, { count: 1, resetAt: Date.now() - 1 });
|
|
266
|
+
}
|
|
267
|
+
// One more distinct key triggers the amortized sweep; the expired entries
|
|
268
|
+
// are reclaimed instead of growing the map past the cap.
|
|
269
|
+
store.set("live", { count: 1, resetAt: Date.now() + 60_000 });
|
|
270
|
+
assert.equal(store.get("live") !== null, true);
|
|
271
|
+
for (let i = 0; i < 4; i += 1) {
|
|
272
|
+
assert.equal(store.get(`expired-${i}`), null);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("memory store caps live-key count via LRU eviction under a distinct-key flood", () => {
|
|
277
|
+
const maxBuckets = 8;
|
|
278
|
+
const store = createMemoryStore({ maxBuckets });
|
|
279
|
+
const resetAt = Date.now() + 60_000; // all un-expired, so only the cap bounds size
|
|
280
|
+
// A flood of far more distinct, still-live keys than the cap.
|
|
281
|
+
for (let i = 0; i < maxBuckets * 50; i += 1) {
|
|
282
|
+
store.set(`ip-${i}`, { count: 1, resetAt });
|
|
283
|
+
}
|
|
284
|
+
// Size stays bounded — the map never grew to the flood count.
|
|
285
|
+
let liveCount = 0;
|
|
286
|
+
for (let i = 0; i < maxBuckets * 50; i += 1) {
|
|
287
|
+
if (store.get(`ip-${i}`) !== null) {
|
|
288
|
+
liveCount += 1;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
assert.equal(liveCount <= maxBuckets, true, `expected <= ${maxBuckets} live buckets, got ${liveCount}`);
|
|
292
|
+
// The most-recently-inserted key survived; the oldest was evicted (LRU).
|
|
293
|
+
assert.equal(store.get(`ip-${maxBuckets * 50 - 1}`) !== null, true);
|
|
294
|
+
assert.equal(store.get("ip-0"), null);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("a rate limiter over the bounded store stays memory-bounded across a distinct-key flood", async () => {
|
|
298
|
+
const store = createMemoryStore({ maxBuckets: 16 });
|
|
299
|
+
const limiter = createRateLimiter({ limit: 1, windowMs: 60_000, store });
|
|
300
|
+
// Each request presents a distinct client IP; without the cap this would
|
|
301
|
+
// grow one bucket per request forever.
|
|
302
|
+
for (let i = 0; i < 5_000; i += 1) {
|
|
303
|
+
await limiter.check(ipReq(`10.0.${(i >> 8) & 255}.${i & 255}`));
|
|
304
|
+
}
|
|
305
|
+
let liveCount = 0;
|
|
306
|
+
for (let i = 0; i < 5_000; i += 1) {
|
|
307
|
+
if (store.get(`10.0.${(i >> 8) & 255}.${i & 255}`) !== null) {
|
|
308
|
+
liveCount += 1;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
assert.equal(liveCount <= 16, true, `expected <= 16 live buckets, got ${liveCount}`);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("default key extractor ignores spoofable X-Forwarded-For", async () => {
|
|
315
|
+
const limiter = createRateLimiter({ limit: 1, windowMs: 60_000 });
|
|
316
|
+
// Two requests with different X-Forwarded-For values but no CF-Connecting-IP
|
|
317
|
+
// must land in the SAME bucket — a client cannot mint fresh buckets by
|
|
318
|
+
// rotating a forged X-Forwarded-For.
|
|
319
|
+
const xffReq = (xff) =>
|
|
320
|
+
new Request("https://api.example.com/", {
|
|
321
|
+
headers: { "X-Forwarded-For": xff },
|
|
322
|
+
});
|
|
323
|
+
assert.equal((await limiter.check(xffReq("1.2.3.4"))).allowed, true);
|
|
324
|
+
// Different forged header, same shared "anonymous" bucket → denied.
|
|
325
|
+
assert.equal((await limiter.check(xffReq("5.6.7.8"))).allowed, false);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("default key extractor keys off trusted CF-Connecting-IP even when X-Forwarded-For differs", async () => {
|
|
329
|
+
const limiter = createRateLimiter({ limit: 1, windowMs: 60_000 });
|
|
330
|
+
const req = (cf, xff) =>
|
|
331
|
+
new Request("https://api.example.com/", {
|
|
332
|
+
headers: { "CF-Connecting-IP": cf, "X-Forwarded-For": xff },
|
|
333
|
+
});
|
|
334
|
+
// Distinct trusted IPs get distinct buckets regardless of the forged XFF.
|
|
335
|
+
assert.equal((await limiter.check(req("1.1.1.1", "9.9.9.9"))).allowed, true);
|
|
336
|
+
assert.equal((await limiter.check(req("2.2.2.2", "9.9.9.9"))).allowed, true);
|
|
337
|
+
// Same trusted IP, different forged XFF → same bucket → denied.
|
|
338
|
+
assert.equal((await limiter.check(req("1.1.1.1", "8.8.8.8"))).allowed, false);
|
|
339
|
+
});
|
|
340
|
+
|
|
261
341
|
test("rateLimitHeaders includes Retry-After only when denied", () => {
|
|
262
342
|
const allowed = rateLimitHeaders({ allowed: true, limit: 10, remaining: 9, resetAt: Date.now() + 1000, retryAfter: 0 });
|
|
263
343
|
assert.equal("Retry-After" in allowed, false);
|