mandrel-platform 0.13.0 → 0.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +203 -7
- package/config/dependency-cruiser.base.json +121 -0
- package/config/edge-security/allowlist.mjs +138 -0
- package/config/edge-security/cors-astro.mjs +131 -0
- package/config/edge-security/cors-hono.mjs +111 -0
- package/config/edge-security/index.mjs +34 -0
- package/config/edge-security/rate-limit.mjs +219 -0
- package/config/edge-security/security-headers.mjs +148 -0
- package/config/knip.base.json +7 -0
- package/config/lighthouse.base.json +27 -0
- package/config/size-limit.base.json +6 -0
- package/config/stryker.base.json +15 -0
- package/package.json +23 -2
- package/scripts/check-action-pins.mjs +344 -0
- package/scripts/check-action-pins.test.mjs +240 -0
- package/scripts/check-coverage-threshold.mjs +300 -0
- package/scripts/check-coverage-threshold.test.mjs +350 -0
- package/scripts/check-destructive-migration.mjs +313 -0
- package/scripts/check-destructive-migration.test.mjs +183 -0
- package/scripts/check-pin-drift.mjs +361 -17
- package/scripts/check-pin-drift.test.mjs +344 -1
- package/scripts/edge-security.test.mjs +300 -0
- package/scripts/pin-drift-consumers.json +2 -0
- package/scripts/platform-repair.mjs +748 -0
- package/scripts/platform-repair.test.mjs +458 -0
- package/scripts/platform-sync.mjs +0 -0
- package/scripts/platform-sync.test.mjs +0 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-action-pins.mjs
|
|
4
|
+
*
|
|
5
|
+
* Action-pin ratchet (Story #112).
|
|
6
|
+
*
|
|
7
|
+
* Third-party GitHub Actions referenced by `uses:` in this repo's workflows
|
|
8
|
+
* and composite actions are SHA-pinned by convention — but until now NOTHING
|
|
9
|
+
* ENFORCED it. A single `uses: owner/repo@v4` that slipped past review would
|
|
10
|
+
* silently re-introduce mutable-tag risk: the tag can be force-moved to a
|
|
11
|
+
* malicious commit after review, and because the shared `pr-quality.yml` is
|
|
12
|
+
* inherited by every consumer, a tag-pinned regression has 3× blast radius
|
|
13
|
+
* across all three consumer repos (the threat this Story closes alongside the
|
|
14
|
+
* harden-runner egress baseline).
|
|
15
|
+
*
|
|
16
|
+
* This lint is the ratchet: it walks every workflow file under
|
|
17
|
+
* `.github/workflows/` and every composite `action.yml` under
|
|
18
|
+
* `.github/actions/`, extracts each `uses:` reference, and FAILS if any
|
|
19
|
+
* THIRD-PARTY action is pinned to anything other than a full 40-character
|
|
20
|
+
* commit SHA. It runs in mandrel-platform's own `ci-required` (ci.yml), so a
|
|
21
|
+
* non-SHA third-party pin can never reach `main`.
|
|
22
|
+
*
|
|
23
|
+
* Classification — what MUST be a 40-hex SHA, and what is exempt:
|
|
24
|
+
*
|
|
25
|
+
* • THIRD-PARTY `owner/repo[/subpath]@ref` → MUST be a 40-char hex SHA.
|
|
26
|
+
* (e.g. `actions/checkout`, `step-security/harden-runner`,
|
|
27
|
+
* `pnpm/action-setup`.) A non-SHA ref (a tag like `v4`, a branch, a short
|
|
28
|
+
* SHA) FAILS the lint.
|
|
29
|
+
*
|
|
30
|
+
* • FIRST-PARTY self-references — `dsj1984/mandrel-platform/...@<ref>` — are
|
|
31
|
+
* EXEMPT from this ratchet. They are this repo's OWN reusable workflows /
|
|
32
|
+
* composite actions, governed by the cross-repo portability lint's pin-lag
|
|
33
|
+
* guard (`check-workflow-portability.mjs`, Rule 3), and they carry a
|
|
34
|
+
* release-tag shape at publish time. The first-party owner is overridable
|
|
35
|
+
* via `--first-party-owner` for a fork.
|
|
36
|
+
*
|
|
37
|
+
* • LOCAL `./path` references and `docker://image` references are EXEMPT —
|
|
38
|
+
* a local path has no upstream tag to move, and a docker ref is pinned by
|
|
39
|
+
* its own digest convention, out of scope for this action-tag ratchet.
|
|
40
|
+
*
|
|
41
|
+
* The reference is read from the `uses:` value with any trailing `# comment`
|
|
42
|
+
* (the conventional `# v4.2.2` tag annotation) stripped first, so the human
|
|
43
|
+
* tag note alongside the SHA never confuses the parse.
|
|
44
|
+
*
|
|
45
|
+
* Usage:
|
|
46
|
+
* node scripts/check-action-pins.mjs
|
|
47
|
+
* node scripts/check-action-pins.mjs --workflows-dir .github/workflows
|
|
48
|
+
* node scripts/check-action-pins.mjs --actions-dir .github/actions
|
|
49
|
+
* node scripts/check-action-pins.mjs --first-party-owner my-org/my-repo
|
|
50
|
+
*
|
|
51
|
+
* Exit codes:
|
|
52
|
+
* 0 — every third-party `uses:` is pinned to a full 40-char commit SHA.
|
|
53
|
+
* 1 — one or more third-party actions are not SHA-pinned (each named in
|
|
54
|
+
* stderr with file:line).
|
|
55
|
+
*
|
|
56
|
+
* Consumer adoption:
|
|
57
|
+
* Copy this script into your project's `scripts/` directory and wire it into
|
|
58
|
+
* your CI alongside check-required-contexts.mjs / check-workflow-portability.mjs:
|
|
59
|
+
*
|
|
60
|
+
* - name: Lint third-party action pins
|
|
61
|
+
* run: node scripts/check-action-pins.mjs --first-party-owner <owner/repo>
|
|
62
|
+
*
|
|
63
|
+
* It is dependency-free (no YAML parser) so it copies cleanly into any repo.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
67
|
+
import { resolve, join, relative } from "node:path";
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Pure helpers (exported for the sibling node:test suite)
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
const DEFAULT_FIRST_PARTY_OWNER = "dsj1984/mandrel-platform";
|
|
74
|
+
|
|
75
|
+
/** A full git commit SHA is exactly 40 lowercase/uppercase hex characters. */
|
|
76
|
+
const SHA40_RE = /^[0-9a-fA-F]{40}$/;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse the CLI argv (array AFTER `node script.mjs`) into an options object.
|
|
80
|
+
* Throws on an unknown flag or a flag missing its value so the lint fails
|
|
81
|
+
* loudly rather than silently mis-reading its own configuration.
|
|
82
|
+
*/
|
|
83
|
+
export function parseArgs(argv) {
|
|
84
|
+
const opts = {
|
|
85
|
+
workflowsDir: ".github/workflows",
|
|
86
|
+
actionsDir: ".github/actions",
|
|
87
|
+
firstPartyOwner: DEFAULT_FIRST_PARTY_OWNER,
|
|
88
|
+
cwd: process.cwd(),
|
|
89
|
+
};
|
|
90
|
+
const takeValue = (i, flag) => {
|
|
91
|
+
const v = argv[i + 1];
|
|
92
|
+
if (v === undefined || v.startsWith("--")) {
|
|
93
|
+
throw new Error(`missing value for "${flag}"`);
|
|
94
|
+
}
|
|
95
|
+
return v;
|
|
96
|
+
};
|
|
97
|
+
for (let i = 0; i < argv.length; i++) {
|
|
98
|
+
const arg = argv[i];
|
|
99
|
+
switch (arg) {
|
|
100
|
+
case "--workflows-dir":
|
|
101
|
+
opts.workflowsDir = takeValue(i, arg);
|
|
102
|
+
i++;
|
|
103
|
+
break;
|
|
104
|
+
case "--actions-dir":
|
|
105
|
+
opts.actionsDir = takeValue(i, arg);
|
|
106
|
+
i++;
|
|
107
|
+
break;
|
|
108
|
+
case "--first-party-owner":
|
|
109
|
+
opts.firstPartyOwner = takeValue(i, arg);
|
|
110
|
+
i++;
|
|
111
|
+
break;
|
|
112
|
+
case "--cwd":
|
|
113
|
+
opts.cwd = takeValue(i, arg);
|
|
114
|
+
i++;
|
|
115
|
+
break;
|
|
116
|
+
default:
|
|
117
|
+
throw new Error(`unknown argument "${arg}"`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return opts;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Strip a trailing `# comment` (the conventional `# v4.2.2` tag note) and
|
|
125
|
+
* surrounding whitespace/quotes from a raw `uses:` value, returning the bare
|
|
126
|
+
* action reference. A `#` inside the ref itself is not valid GitHub syntax,
|
|
127
|
+
* so splitting on the first ` #` is safe.
|
|
128
|
+
*/
|
|
129
|
+
export function stripUsesValue(raw) {
|
|
130
|
+
let v = String(raw).trim();
|
|
131
|
+
// Drop a trailing comment: the first '#' that is preceded by whitespace (or
|
|
132
|
+
// at the start) begins a comment. GitHub action refs never contain '#'.
|
|
133
|
+
const hashIdx = v.search(/\s#/);
|
|
134
|
+
if (hashIdx !== -1) v = v.slice(0, hashIdx);
|
|
135
|
+
v = v.trim();
|
|
136
|
+
// Unwrap matched surrounding quotes.
|
|
137
|
+
if (
|
|
138
|
+
(v.startsWith('"') && v.endsWith('"')) ||
|
|
139
|
+
(v.startsWith("'") && v.endsWith("'"))
|
|
140
|
+
) {
|
|
141
|
+
v = v.slice(1, -1).trim();
|
|
142
|
+
}
|
|
143
|
+
return v;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Classify a bare `uses:` reference. Returns one of:
|
|
148
|
+
* { kind: 'local' } — `./path` or `../path` (exempt)
|
|
149
|
+
* { kind: 'docker' } — `docker://image` (exempt)
|
|
150
|
+
* { kind: 'first-party', owner, ref } — the configured first-party owner (exempt)
|
|
151
|
+
* { kind: 'third-party', owner, ref } — external action (MUST be SHA-pinned)
|
|
152
|
+
* { kind: 'unparseable' } — not a recognizable `uses:` reference
|
|
153
|
+
*/
|
|
154
|
+
export function classifyUses(bareRef, firstPartyOwner = DEFAULT_FIRST_PARTY_OWNER) {
|
|
155
|
+
const ref = String(bareRef).trim();
|
|
156
|
+
if (ref === "") return { kind: "unparseable" };
|
|
157
|
+
if (ref.startsWith("./") || ref.startsWith("../")) return { kind: "local" };
|
|
158
|
+
if (ref.startsWith("docker://")) return { kind: "docker" };
|
|
159
|
+
|
|
160
|
+
// owner/repo[/subpath]@gitref. The git ref is everything after the LAST '@'
|
|
161
|
+
// (an action subpath never contains '@'; the ref does not either).
|
|
162
|
+
const atIdx = ref.lastIndexOf("@");
|
|
163
|
+
if (atIdx === -1) {
|
|
164
|
+
// No `@ref` at all — not a pinnable external reference (e.g. a malformed
|
|
165
|
+
// entry). Treat as unparseable so the caller can flag it explicitly.
|
|
166
|
+
return { kind: "unparseable", ownerRepoPath: ref };
|
|
167
|
+
}
|
|
168
|
+
const ownerRepoPath = ref.slice(0, atIdx);
|
|
169
|
+
const gitRef = ref.slice(atIdx + 1);
|
|
170
|
+
const segments = ownerRepoPath.split("/");
|
|
171
|
+
if (segments.length < 2) return { kind: "unparseable", ownerRepoPath, ref: gitRef };
|
|
172
|
+
|
|
173
|
+
const ownerRepo = `${segments[0]}/${segments[1]}`;
|
|
174
|
+
if (ownerRepo.toLowerCase() === String(firstPartyOwner).toLowerCase()) {
|
|
175
|
+
return { kind: "first-party", owner: ownerRepo, ref: gitRef };
|
|
176
|
+
}
|
|
177
|
+
return { kind: "third-party", owner: ownerRepo, ref: gitRef };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** True when a git ref is a full 40-character commit SHA. */
|
|
181
|
+
export function isSha40(gitRef) {
|
|
182
|
+
return SHA40_RE.test(String(gitRef).trim());
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Scan a single file's TEXT for `uses:` step keys and evaluate each third-party
|
|
187
|
+
* reference. Returns { violations: [...], scanned: <count> }. A violation is
|
|
188
|
+
* `{ file, line, ref, owner, reason }`. `file` is left as passed-in (the
|
|
189
|
+
* caller supplies a display path).
|
|
190
|
+
*
|
|
191
|
+
* Only lines whose first non-space token is `uses:` (a YAML mapping key) are
|
|
192
|
+
* inspected — `uses:` appearing inside a comment or a `run:` heredoc never
|
|
193
|
+
* starts a YAML key at column-leading position, so this avoids false hits on
|
|
194
|
+
* documentation examples embedded in `#` comments (those are indented past a
|
|
195
|
+
* leading `#`).
|
|
196
|
+
*/
|
|
197
|
+
export function scanContent(content, displayFile, firstPartyOwner = DEFAULT_FIRST_PARTY_OWNER) {
|
|
198
|
+
const violations = [];
|
|
199
|
+
let scanned = 0;
|
|
200
|
+
const lines = String(content).split(/\r?\n/);
|
|
201
|
+
// Matches a YAML `uses:` mapping key: optional leading whitespace, an
|
|
202
|
+
// optional leading `- ` (sequence item), then `uses:` and the value.
|
|
203
|
+
const usesRe = /^\s*(?:-\s+)?uses:\s*(\S.*)$/;
|
|
204
|
+
for (let i = 0; i < lines.length; i++) {
|
|
205
|
+
const raw = lines[i];
|
|
206
|
+
// Skip whole-line comments outright (defensive; the regex below also
|
|
207
|
+
// won't match a leading '#').
|
|
208
|
+
if (/^\s*#/.test(raw)) continue;
|
|
209
|
+
const m = raw.match(usesRe);
|
|
210
|
+
if (!m) continue;
|
|
211
|
+
const bareRef = stripUsesValue(m[1]);
|
|
212
|
+
const cls = classifyUses(bareRef, firstPartyOwner);
|
|
213
|
+
if (cls.kind !== "third-party") continue; // local/docker/first-party/unparseable → exempt
|
|
214
|
+
scanned++;
|
|
215
|
+
if (!isSha40(cls.ref)) {
|
|
216
|
+
violations.push({
|
|
217
|
+
file: displayFile,
|
|
218
|
+
line: i + 1,
|
|
219
|
+
ref: bareRef,
|
|
220
|
+
owner: cls.owner,
|
|
221
|
+
reason: `third-party action "${cls.owner}" is pinned to "${cls.ref}", not a full 40-char commit SHA`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { violations, scanned };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Filesystem walking
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
/** List `*.yml` / `*.yaml` files directly under a workflows dir (non-recursive). */
|
|
233
|
+
export function listWorkflowFiles(dir) {
|
|
234
|
+
if (!existsSync(dir)) return [];
|
|
235
|
+
return readdirSync(dir)
|
|
236
|
+
.filter((f) => /\.ya?ml$/.test(f))
|
|
237
|
+
.map((f) => join(dir, f))
|
|
238
|
+
.filter((p) => {
|
|
239
|
+
try {
|
|
240
|
+
return statSync(p).isFile();
|
|
241
|
+
} catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
.sort();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Recursively list composite `action.yml` / `action.yaml` files under a dir. */
|
|
249
|
+
export function listActionFiles(dir) {
|
|
250
|
+
const out = [];
|
|
251
|
+
if (!existsSync(dir)) return out;
|
|
252
|
+
const walk = (d) => {
|
|
253
|
+
let entries;
|
|
254
|
+
try {
|
|
255
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
256
|
+
} catch {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
for (const e of entries) {
|
|
260
|
+
const full = join(d, e.name);
|
|
261
|
+
if (e.isDirectory()) {
|
|
262
|
+
walk(full);
|
|
263
|
+
} else if (/^action\.ya?ml$/.test(e.name)) {
|
|
264
|
+
out.push(full);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
walk(dir);
|
|
269
|
+
return out.sort();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Orchestration
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Run the full lint against the resolved option set. Returns
|
|
278
|
+
* `{ ok, violations, scanned, files }`. Pure with respect to stdout — the CLI
|
|
279
|
+
* wrapper formats and prints.
|
|
280
|
+
*/
|
|
281
|
+
export function runLint(opts) {
|
|
282
|
+
const cwd = opts.cwd || process.cwd();
|
|
283
|
+
const wfDir = resolve(cwd, opts.workflowsDir);
|
|
284
|
+
const acDir = resolve(cwd, opts.actionsDir);
|
|
285
|
+
const files = [...listWorkflowFiles(wfDir), ...listActionFiles(acDir)];
|
|
286
|
+
|
|
287
|
+
const violations = [];
|
|
288
|
+
let scanned = 0;
|
|
289
|
+
for (const file of files) {
|
|
290
|
+
let content;
|
|
291
|
+
try {
|
|
292
|
+
content = readFileSync(file, "utf8");
|
|
293
|
+
} catch {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const display = relative(cwd, file) || file;
|
|
297
|
+
const res = scanContent(content, display, opts.firstPartyOwner);
|
|
298
|
+
violations.push(...res.violations);
|
|
299
|
+
scanned += res.scanned;
|
|
300
|
+
}
|
|
301
|
+
return { ok: violations.length === 0, violations, scanned, files };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// CLI entrypoint (skipped under `node --test` import)
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
export function runCli(argv, { log = console.log, err = console.error } = {}) {
|
|
309
|
+
let opts;
|
|
310
|
+
try {
|
|
311
|
+
opts = parseArgs(argv);
|
|
312
|
+
} catch (e) {
|
|
313
|
+
err(`[action-pins] ❌ ${e.message}`);
|
|
314
|
+
return 1;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const result = runLint(opts);
|
|
318
|
+
|
|
319
|
+
if (!result.ok) {
|
|
320
|
+
err(`[action-pins] ❌ ${result.violations.length} unpinned third-party action(s):`);
|
|
321
|
+
for (const v of result.violations) {
|
|
322
|
+
err(` • ${v.file}:${v.line} — ${v.reason}`);
|
|
323
|
+
}
|
|
324
|
+
err(
|
|
325
|
+
"[action-pins] Pin every third-party action to a full 40-char commit SHA " +
|
|
326
|
+
"(keep the `# vX.Y.Z` tag note as a comment). A mutable tag can be " +
|
|
327
|
+
"force-moved to a malicious commit after review."
|
|
328
|
+
);
|
|
329
|
+
return 1;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
log(
|
|
333
|
+
`[action-pins] ✅ all ${result.scanned} third-party action reference(s) are SHA-pinned ` +
|
|
334
|
+
`(${result.files.length} file(s) scanned).`
|
|
335
|
+
);
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Only run when executed directly, not when imported by the test suite.
|
|
340
|
+
const invokedDirectly =
|
|
341
|
+
process.argv[1] && resolve(process.argv[1]).endsWith("check-action-pins.mjs");
|
|
342
|
+
if (invokedDirectly) {
|
|
343
|
+
process.exit(runCli(process.argv.slice(2)));
|
|
344
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-action-pins.test.mjs — node:test suite for the action-pin ratchet that
|
|
4
|
+
* backs mandrel-platform's `ci-required` third-party-action SHA-pin gate
|
|
5
|
+
* (Story #112).
|
|
6
|
+
*
|
|
7
|
+
* This is the "equivalent self-test" the Story's acceptance criteria call for:
|
|
8
|
+
* it exercises the ratchet's classification (third-party vs first-party vs
|
|
9
|
+
* local vs docker), the 40-char-SHA assertion, the `# tag` comment strip, and
|
|
10
|
+
* a full content scan with both a SHA-pinned (pass) and a tag-pinned (fail)
|
|
11
|
+
* fixture. Pure helpers + a temp-dir fixture keep the whole pipeline offline.
|
|
12
|
+
*
|
|
13
|
+
* Run: node scripts/check-action-pins.test.mjs (or `node --test scripts/`)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { test } from "node:test";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
parseArgs,
|
|
24
|
+
stripUsesValue,
|
|
25
|
+
classifyUses,
|
|
26
|
+
isSha40,
|
|
27
|
+
scanContent,
|
|
28
|
+
listWorkflowFiles,
|
|
29
|
+
listActionFiles,
|
|
30
|
+
runLint,
|
|
31
|
+
runCli,
|
|
32
|
+
} from "./check-action-pins.mjs";
|
|
33
|
+
|
|
34
|
+
const SHA = "11bd71901bbe5b1630ceea73d27597364c9af683"; // 40 hex
|
|
35
|
+
const SHORT = "11bd719"; // 7 hex
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// isSha40
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
test("isSha40 accepts exactly 40 hex chars", () => {
|
|
42
|
+
assert.equal(isSha40(SHA), true);
|
|
43
|
+
assert.equal(isSha40(SHA.toUpperCase()), true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("isSha40 rejects tags, short SHAs, branches", () => {
|
|
47
|
+
assert.equal(isSha40("v4"), false);
|
|
48
|
+
assert.equal(isSha40("v4.2.2"), false);
|
|
49
|
+
assert.equal(isSha40(SHORT), false);
|
|
50
|
+
assert.equal(isSha40("main"), false);
|
|
51
|
+
assert.equal(isSha40(SHA + "0"), false); // 41 chars
|
|
52
|
+
assert.equal(isSha40("g".repeat(40)), false); // non-hex
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// stripUsesValue
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
test("stripUsesValue drops the trailing # tag comment", () => {
|
|
60
|
+
assert.equal(
|
|
61
|
+
stripUsesValue(`actions/checkout@${SHA} # v4.2.2`),
|
|
62
|
+
`actions/checkout@${SHA}`
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("stripUsesValue handles no comment and surrounding quotes", () => {
|
|
67
|
+
assert.equal(stripUsesValue(`actions/checkout@${SHA}`), `actions/checkout@${SHA}`);
|
|
68
|
+
assert.equal(stripUsesValue(`"actions/checkout@${SHA}"`), `actions/checkout@${SHA}`);
|
|
69
|
+
assert.equal(stripUsesValue(`'actions/checkout@${SHA}'`), `actions/checkout@${SHA}`);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// classifyUses
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
test("classifyUses flags external owner/repo as third-party", () => {
|
|
77
|
+
const c = classifyUses(`actions/checkout@${SHA}`);
|
|
78
|
+
assert.equal(c.kind, "third-party");
|
|
79
|
+
assert.equal(c.owner, "actions/checkout");
|
|
80
|
+
assert.equal(c.ref, SHA);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("classifyUses treats the first-party owner as exempt", () => {
|
|
84
|
+
const c = classifyUses(
|
|
85
|
+
`dsj1984/mandrel-platform/.github/actions/setup-toolchain@${SHA}`
|
|
86
|
+
);
|
|
87
|
+
assert.equal(c.kind, "first-party");
|
|
88
|
+
// The subpath after owner/repo is preserved out of `ref`; ref is the gitref.
|
|
89
|
+
assert.equal(c.ref, SHA);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("classifyUses honours a custom --first-party-owner", () => {
|
|
93
|
+
const c = classifyUses(`my-org/my-repo/.github/workflows/x.yml@v1`, "my-org/my-repo");
|
|
94
|
+
assert.equal(c.kind, "first-party");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("classifyUses exempts local and docker refs", () => {
|
|
98
|
+
assert.equal(classifyUses("./.github/actions/foo").kind, "local");
|
|
99
|
+
assert.equal(classifyUses("../shared/action").kind, "local");
|
|
100
|
+
assert.equal(classifyUses("docker://alpine:3.19").kind, "docker");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("classifyUses isolates the git ref after the LAST @ (subpath-safe)", () => {
|
|
104
|
+
const c = classifyUses(`github/codeql-action/analyze@${SHA}`);
|
|
105
|
+
assert.equal(c.kind, "third-party");
|
|
106
|
+
assert.equal(c.owner, "github/codeql-action");
|
|
107
|
+
assert.equal(c.ref, SHA);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// scanContent
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
test("scanContent passes a fully SHA-pinned third-party uses", () => {
|
|
115
|
+
const yaml = [
|
|
116
|
+
"jobs:",
|
|
117
|
+
" build:",
|
|
118
|
+
" steps:",
|
|
119
|
+
` - uses: actions/checkout@${SHA} # v4.2.2`,
|
|
120
|
+
` - uses: step-security/harden-runner@${SHA} # v2.19.4`,
|
|
121
|
+
].join("\n");
|
|
122
|
+
const { violations, scanned } = scanContent(yaml, "wf.yml");
|
|
123
|
+
assert.equal(scanned, 2);
|
|
124
|
+
assert.deepEqual(violations, []);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("scanContent fails a tag-pinned third-party uses", () => {
|
|
128
|
+
const yaml = [
|
|
129
|
+
" steps:",
|
|
130
|
+
" - uses: actions/checkout@v4",
|
|
131
|
+
` - uses: pnpm/action-setup@${SHA}`,
|
|
132
|
+
].join("\n");
|
|
133
|
+
const { violations, scanned } = scanContent(yaml, "wf.yml");
|
|
134
|
+
assert.equal(scanned, 2);
|
|
135
|
+
assert.equal(violations.length, 1);
|
|
136
|
+
assert.equal(violations[0].owner, "actions/checkout");
|
|
137
|
+
assert.equal(violations[0].line, 2);
|
|
138
|
+
assert.match(violations[0].reason, /not a full 40-char commit SHA/);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("scanContent ignores first-party, local, docker, and comment lines", () => {
|
|
142
|
+
const yaml = [
|
|
143
|
+
"# uses: actions/checkout@v4 (this is a comment example, must be ignored)",
|
|
144
|
+
" steps:",
|
|
145
|
+
` - uses: dsj1984/mandrel-platform/.github/actions/setup-toolchain@${SHA}`,
|
|
146
|
+
" - uses: ./.github/actions/local-thing",
|
|
147
|
+
" - uses: docker://alpine:3.19",
|
|
148
|
+
].join("\n");
|
|
149
|
+
const { violations, scanned } = scanContent(yaml, "wf.yml");
|
|
150
|
+
assert.equal(scanned, 0); // none are third-party
|
|
151
|
+
assert.deepEqual(violations, []);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("scanContent flags a short-SHA third-party pin", () => {
|
|
155
|
+
const yaml = ` - uses: actions/setup-node@${SHORT}`;
|
|
156
|
+
const { violations } = scanContent(yaml, "wf.yml");
|
|
157
|
+
assert.equal(violations.length, 1);
|
|
158
|
+
assert.match(violations[0].reason, /not a full 40-char commit SHA/);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// parseArgs
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
test("parseArgs applies defaults and overrides", () => {
|
|
166
|
+
const d = parseArgs([]);
|
|
167
|
+
assert.equal(d.workflowsDir, ".github/workflows");
|
|
168
|
+
assert.equal(d.actionsDir, ".github/actions");
|
|
169
|
+
assert.equal(d.firstPartyOwner, "dsj1984/mandrel-platform");
|
|
170
|
+
|
|
171
|
+
const o = parseArgs(["--first-party-owner", "x/y", "--workflows-dir", "wf"]);
|
|
172
|
+
assert.equal(o.firstPartyOwner, "x/y");
|
|
173
|
+
assert.equal(o.workflowsDir, "wf");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("parseArgs throws on unknown flag and missing value", () => {
|
|
177
|
+
assert.throws(() => parseArgs(["--nope"]), /unknown argument/);
|
|
178
|
+
assert.throws(() => parseArgs(["--first-party-owner"]), /missing value/);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// runLint + runCli over a temp fixture tree
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
function fixtureRepo({ workflow }) {
|
|
186
|
+
const root = mkdtempSync(join(tmpdir(), "pin-ratchet-"));
|
|
187
|
+
mkdirSync(join(root, ".github", "workflows"), { recursive: true });
|
|
188
|
+
writeFileSync(join(root, ".github", "workflows", "ci.yml"), workflow);
|
|
189
|
+
return root;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
test("runLint is green when every third-party action is SHA-pinned", () => {
|
|
193
|
+
const root = fixtureRepo({
|
|
194
|
+
workflow: [
|
|
195
|
+
" steps:",
|
|
196
|
+
` - uses: actions/checkout@${SHA} # v4`,
|
|
197
|
+
` - uses: dsj1984/mandrel-platform/.github/actions/setup-toolchain@${SHA}`,
|
|
198
|
+
].join("\n"),
|
|
199
|
+
});
|
|
200
|
+
try {
|
|
201
|
+
const res = runLint({
|
|
202
|
+
cwd: root,
|
|
203
|
+
workflowsDir: ".github/workflows",
|
|
204
|
+
actionsDir: ".github/actions",
|
|
205
|
+
firstPartyOwner: "dsj1984/mandrel-platform",
|
|
206
|
+
});
|
|
207
|
+
assert.equal(res.ok, true);
|
|
208
|
+
assert.equal(res.scanned, 1); // only the third-party checkout counts
|
|
209
|
+
} finally {
|
|
210
|
+
rmSync(root, { recursive: true, force: true });
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("runLint / runCli are red on a tag-pinned third-party action", () => {
|
|
215
|
+
const root = fixtureRepo({
|
|
216
|
+
workflow: [" steps:", " - uses: actions/checkout@v4"].join("\n"),
|
|
217
|
+
});
|
|
218
|
+
try {
|
|
219
|
+
const res = runLint({
|
|
220
|
+
cwd: root,
|
|
221
|
+
workflowsDir: ".github/workflows",
|
|
222
|
+
actionsDir: ".github/actions",
|
|
223
|
+
firstPartyOwner: "dsj1984/mandrel-platform",
|
|
224
|
+
});
|
|
225
|
+
assert.equal(res.ok, false);
|
|
226
|
+
assert.equal(res.violations.length, 1);
|
|
227
|
+
|
|
228
|
+
const errs = [];
|
|
229
|
+
const code = runCli(["--cwd", root], { log: () => {}, err: (m) => errs.push(m) });
|
|
230
|
+
assert.equal(code, 1);
|
|
231
|
+
assert.ok(errs.some((m) => /unpinned third-party action/.test(m)));
|
|
232
|
+
} finally {
|
|
233
|
+
rmSync(root, { recursive: true, force: true });
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("listWorkflowFiles / listActionFiles return [] for a missing dir", () => {
|
|
238
|
+
assert.deepEqual(listWorkflowFiles("/no/such/dir/workflows"), []);
|
|
239
|
+
assert.deepEqual(listActionFiles("/no/such/dir/actions"), []);
|
|
240
|
+
});
|