mandrel-platform 0.17.2 → 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 +254 -34
- package/config/commitlint.base.mjs +36 -0
- package/config/edge-security/rate-limit.mjs +103 -20
- package/config/repo-settings.schema.json +78 -0
- package/default.json +4 -19
- package/package.json +2 -1
- package/scripts/apply-uptime-monitors.mjs +378 -0
- package/scripts/apply-uptime-monitors.test.mjs +372 -0
- 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-repo-settings.mjs +363 -0
- package/scripts/check-repo-settings.test.mjs +320 -0
- package/scripts/check-required-contexts.mjs +247 -129
- package/scripts/check-required-contexts.test.mjs +137 -0
- package/scripts/check-ruleset.mjs +435 -0
- package/scripts/check-ruleset.test.mjs +439 -0
- package/scripts/check-workflow-portability.mjs +163 -118
- package/scripts/check-workflow-portability.test.mjs +199 -0
- package/scripts/check-wrangler-baseline.mjs +514 -0
- package/scripts/check-wrangler-baseline.test.mjs +454 -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/platform-sync.mjs +533 -5
- package/scripts/platform-sync.test.mjs +477 -0
- package/scripts/update-semgrep-rules.mjs +76 -5
- package/templates/runbooks/README.md +9 -5
- package/templates/runbooks/branch-protection-setup.md +9 -3
- package/templates/workflows/deploy-staging.yml +86 -0
- package/templates/workflows/uptime-apply.yml +54 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* audit-check.test.mjs — node:test suite for the CVE-gate core in
|
|
4
|
+
* audit-check.mjs (Story #195).
|
|
5
|
+
*
|
|
6
|
+
* Covers the fail-closed contract and the pure decision core:
|
|
7
|
+
* - an uninterpretable report + non-zero pnpm exit fails closed (exit 1)
|
|
8
|
+
* - a validly-suppressed high advisory (by GHSA id and by CVE id) passes
|
|
9
|
+
* - an expired allowlist entry fails closed (exit 1)
|
|
10
|
+
* - an unsuppressed critical fails closed (exit 1)
|
|
11
|
+
*
|
|
12
|
+
* The suppression/expiry/interpretation logic is exercised through the pure
|
|
13
|
+
* functions (`partitionAllowlist`, `isInterpretableReport`,
|
|
14
|
+
* `extractBlockingAdvisories`, `evaluateReport`) — no pnpm spawn, no
|
|
15
|
+
* filesystem — plus the CLI-level allowlist paths (`runCli` with a fixture
|
|
16
|
+
* allowlist) that decide the exit code before pnpm ever runs.
|
|
17
|
+
*
|
|
18
|
+
* Run: node --test scripts/audit-check.test.mjs (or `node --test scripts/`)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import { test } from "node:test";
|
|
23
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
partitionAllowlist,
|
|
29
|
+
isInterpretableReport,
|
|
30
|
+
extractBlockingAdvisories,
|
|
31
|
+
evaluateReport,
|
|
32
|
+
parseArgs,
|
|
33
|
+
loadAllowlist,
|
|
34
|
+
runCli,
|
|
35
|
+
} from "./audit-check.mjs";
|
|
36
|
+
|
|
37
|
+
const TODAY = "2026-07-02";
|
|
38
|
+
|
|
39
|
+
// A far-future expiry so "valid" fixtures never age out as the clock moves.
|
|
40
|
+
const FUTURE = "2999-12-31";
|
|
41
|
+
// A date safely in the past.
|
|
42
|
+
const PAST = "2000-01-01";
|
|
43
|
+
|
|
44
|
+
/** Build a pnpm-audit-shaped report from a list of advisories. */
|
|
45
|
+
function reportWith(advisories) {
|
|
46
|
+
const map = {};
|
|
47
|
+
for (const [key, adv] of Object.entries(advisories)) {
|
|
48
|
+
map[key] = adv;
|
|
49
|
+
}
|
|
50
|
+
return { advisories: map, metadata: {} };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const HIGH_GHSA = {
|
|
54
|
+
ghsa_id: "GHSA-aaaa-bbbb-cccc",
|
|
55
|
+
cve: ["CVE-2026-1111"],
|
|
56
|
+
severity: "high",
|
|
57
|
+
title: "High severity in transitive dep",
|
|
58
|
+
url: "https://example.test/GHSA-aaaa-bbbb-cccc",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const CRITICAL_ADV = {
|
|
62
|
+
ghsa_id: "GHSA-dddd-eeee-ffff",
|
|
63
|
+
cve: ["CVE-2026-2222"],
|
|
64
|
+
severity: "critical",
|
|
65
|
+
title: "Critical RCE",
|
|
66
|
+
url: "https://example.test/GHSA-dddd-eeee-ffff",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ── partitionAllowlist ──────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
test("partitionAllowlist: active entry lands in suppressed set", () => {
|
|
72
|
+
const { suppressed, expired, invalid } = partitionAllowlist(
|
|
73
|
+
[{ id: "GHSA-aaaa-bbbb-cccc", reason: "accepted", expires: FUTURE }],
|
|
74
|
+
TODAY,
|
|
75
|
+
);
|
|
76
|
+
assert.ok(suppressed.has("GHSA-aaaa-bbbb-cccc"));
|
|
77
|
+
assert.equal(expired.length, 0);
|
|
78
|
+
assert.equal(invalid.length, 0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("partitionAllowlist: expired entry lands in expired, not suppressed", () => {
|
|
82
|
+
const { suppressed, expired } = partitionAllowlist(
|
|
83
|
+
[{ id: "GHSA-aaaa-bbbb-cccc", reason: "accepted", expires: PAST }],
|
|
84
|
+
TODAY,
|
|
85
|
+
);
|
|
86
|
+
assert.equal(suppressed.size, 0);
|
|
87
|
+
assert.equal(expired.length, 1);
|
|
88
|
+
assert.equal(expired[0].id, "GHSA-aaaa-bbbb-cccc");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("partitionAllowlist: entry missing id or expires is invalid", () => {
|
|
92
|
+
const { invalid } = partitionAllowlist(
|
|
93
|
+
[
|
|
94
|
+
{ reason: "no id", expires: FUTURE },
|
|
95
|
+
{ id: "GHSA-x", reason: "no expires" },
|
|
96
|
+
],
|
|
97
|
+
TODAY,
|
|
98
|
+
);
|
|
99
|
+
assert.equal(invalid.length, 2);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── isInterpretableReport ───────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
test("isInterpretableReport: true for a report with an advisories object", () => {
|
|
105
|
+
assert.equal(isInterpretableReport(reportWith({})), true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("isInterpretableReport: false for an error envelope without advisories", () => {
|
|
109
|
+
assert.equal(
|
|
110
|
+
isInterpretableReport({ error: { code: "ERR", summary: "boom" } }),
|
|
111
|
+
false,
|
|
112
|
+
);
|
|
113
|
+
assert.equal(isInterpretableReport(null), false);
|
|
114
|
+
assert.equal(isInterpretableReport("not-json-object"), false);
|
|
115
|
+
assert.equal(isInterpretableReport({ advisories: null }), false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ── extractBlockingAdvisories: suppression matching ─────────────────────────
|
|
119
|
+
|
|
120
|
+
test("extractBlockingAdvisories: high advisory suppressed by GHSA id → no blocking", () => {
|
|
121
|
+
const report = reportWith({ 1: HIGH_GHSA });
|
|
122
|
+
const suppressed = new Set(["GHSA-aaaa-bbbb-cccc"]);
|
|
123
|
+
assert.deepEqual(extractBlockingAdvisories(report, suppressed), []);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("extractBlockingAdvisories: high advisory suppressed by CVE id → no blocking", () => {
|
|
127
|
+
const report = reportWith({ 1: HIGH_GHSA });
|
|
128
|
+
const suppressed = new Set(["CVE-2026-1111"]);
|
|
129
|
+
assert.deepEqual(extractBlockingAdvisories(report, suppressed), []);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("extractBlockingAdvisories: unsuppressed critical is blocking", () => {
|
|
133
|
+
const report = reportWith({ 1: CRITICAL_ADV });
|
|
134
|
+
const blocking = extractBlockingAdvisories(report, new Set());
|
|
135
|
+
assert.equal(blocking.length, 1);
|
|
136
|
+
assert.equal(blocking[0].severity, "critical");
|
|
137
|
+
assert.equal(blocking[0].id, "GHSA-dddd-eeee-ffff");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("extractBlockingAdvisories: moderate/low severities are ignored", () => {
|
|
141
|
+
const report = reportWith({
|
|
142
|
+
1: { ghsa_id: "GHSA-mod", severity: "moderate", title: "meh" },
|
|
143
|
+
2: { ghsa_id: "GHSA-low", severity: "low", title: "meh" },
|
|
144
|
+
});
|
|
145
|
+
assert.deepEqual(extractBlockingAdvisories(report, new Set()), []);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("extractBlockingAdvisories: uninterpretable report yields empty (guarded by caller)", () => {
|
|
149
|
+
assert.deepEqual(extractBlockingAdvisories({ error: "boom" }, new Set()), []);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ── evaluateReport: the fail-closed decision core ───────────────────────────
|
|
153
|
+
|
|
154
|
+
test("evaluateReport: uninterpretable report + non-zero pnpm exit → exit 1 (fail closed)", () => {
|
|
155
|
+
const result = evaluateReport({ error: "boom" }, 1, new Set());
|
|
156
|
+
assert.equal(result.exitCode, 1);
|
|
157
|
+
assert.equal(result.reason, "uninterpretable-failclosed");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("evaluateReport: uninterpretable report + zero exit → exit 0 (clean, nothing to report)", () => {
|
|
161
|
+
const result = evaluateReport({ metadata: {} }, 0, new Set());
|
|
162
|
+
assert.equal(result.exitCode, 0);
|
|
163
|
+
assert.equal(result.reason, "clean-no-advisories");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("evaluateReport: validly-suppressed high (GHSA) → exit 0", () => {
|
|
167
|
+
const report = reportWith({ 1: HIGH_GHSA });
|
|
168
|
+
const result = evaluateReport(report, 1, new Set(["GHSA-aaaa-bbbb-cccc"]));
|
|
169
|
+
assert.equal(result.exitCode, 0);
|
|
170
|
+
assert.equal(result.reason, "clean");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("evaluateReport: validly-suppressed high (CVE) → exit 0", () => {
|
|
174
|
+
const report = reportWith({ 1: HIGH_GHSA });
|
|
175
|
+
const result = evaluateReport(report, 1, new Set(["CVE-2026-1111"]));
|
|
176
|
+
assert.equal(result.exitCode, 0);
|
|
177
|
+
assert.equal(result.reason, "clean");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("evaluateReport: unsuppressed critical → exit 1", () => {
|
|
181
|
+
const report = reportWith({ 1: CRITICAL_ADV });
|
|
182
|
+
const result = evaluateReport(report, 1, new Set());
|
|
183
|
+
assert.equal(result.exitCode, 1);
|
|
184
|
+
assert.equal(result.reason, "unsuppressed");
|
|
185
|
+
assert.equal(result.blocking.length, 1);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ── CLI-level: expired allowlist short-circuits before pnpm ─────────────────
|
|
189
|
+
|
|
190
|
+
test("runCli: expired allowlist entry → exit non-zero (before pnpm runs)", () => {
|
|
191
|
+
const dir = mkdtempSync(join(tmpdir(), "audit-check-expired-"));
|
|
192
|
+
try {
|
|
193
|
+
const allowlistPath = join(dir, "audit-allowlist.json");
|
|
194
|
+
writeFileSync(
|
|
195
|
+
allowlistPath,
|
|
196
|
+
JSON.stringify([
|
|
197
|
+
{ id: "GHSA-aaaa-bbbb-cccc", reason: "was accepted", expires: PAST },
|
|
198
|
+
]),
|
|
199
|
+
);
|
|
200
|
+
const exit = runCli(["--allowlist", allowlistPath]);
|
|
201
|
+
assert.equal(exit, 1);
|
|
202
|
+
} finally {
|
|
203
|
+
rmSync(dir, { recursive: true, force: true });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("runCli: malformed allowlist entry (missing expires) → exit non-zero", () => {
|
|
208
|
+
const dir = mkdtempSync(join(tmpdir(), "audit-check-malformed-"));
|
|
209
|
+
try {
|
|
210
|
+
const allowlistPath = join(dir, "audit-allowlist.json");
|
|
211
|
+
writeFileSync(
|
|
212
|
+
allowlistPath,
|
|
213
|
+
JSON.stringify([{ id: "GHSA-aaaa-bbbb-cccc", reason: "no expiry" }]),
|
|
214
|
+
);
|
|
215
|
+
const exit = runCli(["--allowlist", allowlistPath]);
|
|
216
|
+
assert.equal(exit, 1);
|
|
217
|
+
} finally {
|
|
218
|
+
rmSync(dir, { recursive: true, force: true });
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("runCli: non-array allowlist → exit non-zero", () => {
|
|
223
|
+
const dir = mkdtempSync(join(tmpdir(), "audit-check-nonarray-"));
|
|
224
|
+
try {
|
|
225
|
+
const allowlistPath = join(dir, "audit-allowlist.json");
|
|
226
|
+
writeFileSync(allowlistPath, JSON.stringify({ not: "an array" }));
|
|
227
|
+
const exit = runCli(["--allowlist", allowlistPath]);
|
|
228
|
+
assert.equal(exit, 1);
|
|
229
|
+
} finally {
|
|
230
|
+
rmSync(dir, { recursive: true, force: true });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ── parseArgs / loadAllowlist ───────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
test("parseArgs: --allowlist resolves against cwd; default is audit-allowlist.json", () => {
|
|
237
|
+
assert.equal(
|
|
238
|
+
parseArgs(["--allowlist", "custom.json"], "/repo").allowlistPath,
|
|
239
|
+
"/repo/custom.json",
|
|
240
|
+
);
|
|
241
|
+
assert.equal(
|
|
242
|
+
parseArgs([], "/repo").allowlistPath,
|
|
243
|
+
"/repo/audit-allowlist.json",
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("loadAllowlist: absent file returns empty array", () => {
|
|
248
|
+
assert.deepEqual(
|
|
249
|
+
loadAllowlist(join(tmpdir(), "does-not-exist-xyz.json")),
|
|
250
|
+
[],
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("loadAllowlist: non-array throws", () => {
|
|
255
|
+
const dir = mkdtempSync(join(tmpdir(), "audit-check-load-"));
|
|
256
|
+
try {
|
|
257
|
+
const p = join(dir, "a.json");
|
|
258
|
+
writeFileSync(p, JSON.stringify({ nope: true }));
|
|
259
|
+
assert.throws(() => loadAllowlist(p), /must be a JSON array/);
|
|
260
|
+
} finally {
|
|
261
|
+
rmSync(dir, { recursive: true, force: true });
|
|
262
|
+
}
|
|
263
|
+
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* check-action-pins.mjs
|
|
4
4
|
*
|
|
5
|
-
* Action-pin ratchet (Story #112).
|
|
5
|
+
* Action-pin ratchet (Story #112) + intra-repo single-pin invariant (#203).
|
|
6
6
|
*
|
|
7
7
|
* Third-party GitHub Actions referenced by `uses:` in this repo's workflows
|
|
8
8
|
* and composite actions are SHA-pinned by convention — but until now NOTHING
|
|
@@ -32,12 +32,20 @@
|
|
|
32
32
|
* composite actions, governed by the cross-repo portability lint's pin-lag
|
|
33
33
|
* guard (`check-workflow-portability.mjs`, Rule 3), and they carry a
|
|
34
34
|
* release-tag shape at publish time. The first-party owner is overridable
|
|
35
|
-
* via `--first-party-owner` for a fork.
|
|
35
|
+
* via `--first-party-owner` for a fork. They ARE, however, subject to the
|
|
36
|
+
* single-pin invariant below.
|
|
36
37
|
*
|
|
37
38
|
* • LOCAL `./path` references and `docker://image` references are EXEMPT —
|
|
38
39
|
* a local path has no upstream tag to move, and a docker ref is pinned by
|
|
39
40
|
* its own digest convention, out of scope for this action-tag ratchet.
|
|
40
41
|
*
|
|
42
|
+
* Single-pin invariant (Story #203): across `.github/workflows/`, two
|
|
43
|
+
* first-party `uses:` refs to the SAME subpath MUST carry the SAME SHA. Two
|
|
44
|
+
* workflows pinning `owner/repo/.github/actions/foo` at different commits is a
|
|
45
|
+
* silent split-brain — one workflow runs the fixed action, the other the
|
|
46
|
+
* stale one. This lint fails when that drift is present. Disable with
|
|
47
|
+
* `--no-single-pin` (e.g. mid-migration).
|
|
48
|
+
*
|
|
41
49
|
* The reference is read from the `uses:` value with any trailing `# comment`
|
|
42
50
|
* (the conventional `# v4.2.2` tag annotation) stripped first, so the human
|
|
43
51
|
* tag note alongside the SHA never confuses the parse.
|
|
@@ -47,11 +55,13 @@
|
|
|
47
55
|
* node scripts/check-action-pins.mjs --workflows-dir .github/workflows
|
|
48
56
|
* node scripts/check-action-pins.mjs --actions-dir .github/actions
|
|
49
57
|
* node scripts/check-action-pins.mjs --first-party-owner my-org/my-repo
|
|
58
|
+
* node scripts/check-action-pins.mjs --no-single-pin
|
|
50
59
|
*
|
|
51
60
|
* Exit codes:
|
|
52
|
-
* 0 — every third-party `uses:` is pinned
|
|
53
|
-
* 1 — one or more third-party actions are not SHA-pinned
|
|
54
|
-
* stderr with
|
|
61
|
+
* 0 — every third-party `uses:` is SHA-pinned and the single-pin invariant holds.
|
|
62
|
+
* 1 — one or more third-party actions are not SHA-pinned, or a first-party
|
|
63
|
+
* subpath is pinned to two different SHAs (each named in stderr with
|
|
64
|
+
* file:line).
|
|
55
65
|
*
|
|
56
66
|
* Consumer adoption:
|
|
57
67
|
* Copy this script into your project's `scripts/` directory and wire it into
|
|
@@ -60,20 +70,37 @@
|
|
|
60
70
|
* - name: Lint third-party action pins
|
|
61
71
|
* run: node scripts/check-action-pins.mjs --first-party-owner <owner/repo>
|
|
62
72
|
*
|
|
63
|
-
* It
|
|
73
|
+
* It depends only on the sibling `scripts/lib/` helpers (no YAML parser), so
|
|
74
|
+
* copy `scripts/lib/{args,uses-pins,walk}.mjs` alongside it.
|
|
64
75
|
*/
|
|
65
76
|
|
|
66
|
-
import { readFileSync
|
|
67
|
-
import { resolve,
|
|
77
|
+
import { readFileSync } from "node:fs";
|
|
78
|
+
import { resolve, relative } from "node:path";
|
|
68
79
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
80
|
+
import { parseFlags } from "./lib/args.mjs";
|
|
81
|
+
import {
|
|
82
|
+
DEFAULT_FIRST_PARTY_OWNER,
|
|
83
|
+
stripUsesValue,
|
|
84
|
+
parseUsesLine,
|
|
85
|
+
classifyUses,
|
|
86
|
+
isSha40,
|
|
87
|
+
findSinglePinViolations,
|
|
88
|
+
} from "./lib/uses-pins.mjs";
|
|
89
|
+
import { listWorkflowFiles, listActionFiles } from "./lib/walk.mjs";
|
|
72
90
|
|
|
73
|
-
|
|
91
|
+
// Re-export the shared primitives so the sibling test suite (and any external
|
|
92
|
+
// consumer that imports from this script) keeps its existing import surface.
|
|
93
|
+
export {
|
|
94
|
+
stripUsesValue,
|
|
95
|
+
classifyUses,
|
|
96
|
+
isSha40,
|
|
97
|
+
listWorkflowFiles,
|
|
98
|
+
listActionFiles,
|
|
99
|
+
};
|
|
74
100
|
|
|
75
|
-
|
|
76
|
-
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Arg parsing
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
77
104
|
|
|
78
105
|
/**
|
|
79
106
|
* Parse the CLI argv (array AFTER `node script.mjs`) into an options object.
|
|
@@ -81,106 +108,21 @@ const SHA40_RE = /^[0-9a-fA-F]{40}$/;
|
|
|
81
108
|
* loudly rather than silently mis-reading its own configuration.
|
|
82
109
|
*/
|
|
83
110
|
export function parseArgs(argv) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 };
|
|
111
|
+
return parseFlags(argv, {
|
|
112
|
+
flags: {
|
|
113
|
+
"--workflows-dir": { type: "string", dest: "workflowsDir", default: ".github/workflows" },
|
|
114
|
+
"--actions-dir": { type: "string", dest: "actionsDir", default: ".github/actions" },
|
|
115
|
+
"--first-party-owner": { type: "string", dest: "firstPartyOwner", default: DEFAULT_FIRST_PARTY_OWNER },
|
|
116
|
+
"--cwd": { type: "string", dest: "cwd", default: process.cwd() },
|
|
117
|
+
"--no-single-pin": { type: "boolean", dest: "singlePin", value: false, default: true },
|
|
118
|
+
},
|
|
119
|
+
onUnknown: "throw",
|
|
120
|
+
});
|
|
178
121
|
}
|
|
179
122
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Content scan
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
184
126
|
|
|
185
127
|
/**
|
|
186
128
|
* Scan a single file's TEXT for `uses:` step keys and evaluate each third-party
|
|
@@ -198,17 +140,9 @@ export function scanContent(content, displayFile, firstPartyOwner = DEFAULT_FIRS
|
|
|
198
140
|
const violations = [];
|
|
199
141
|
let scanned = 0;
|
|
200
142
|
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
143
|
for (let i = 0; i < lines.length; i++) {
|
|
205
|
-
const
|
|
206
|
-
|
|
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]);
|
|
144
|
+
const bareRef = parseUsesLine(lines[i]);
|
|
145
|
+
if (bareRef === null) continue;
|
|
212
146
|
const cls = classifyUses(bareRef, firstPartyOwner);
|
|
213
147
|
if (cls.kind !== "third-party") continue; // local/docker/first-party/unparseable → exempt
|
|
214
148
|
scanned++;
|
|
@@ -225,67 +159,29 @@ export function scanContent(content, displayFile, firstPartyOwner = DEFAULT_FIRS
|
|
|
225
159
|
return { violations, scanned };
|
|
226
160
|
}
|
|
227
161
|
|
|
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
162
|
// ---------------------------------------------------------------------------
|
|
273
163
|
// Orchestration
|
|
274
164
|
// ---------------------------------------------------------------------------
|
|
275
165
|
|
|
276
166
|
/**
|
|
277
167
|
* Run the full lint against the resolved option set. Returns
|
|
278
|
-
* `{ ok, violations, scanned, files }`. Pure with respect
|
|
279
|
-
* wrapper formats and prints.
|
|
168
|
+
* `{ ok, violations, scanned, files, singlePinViolations }`. Pure with respect
|
|
169
|
+
* to stdout — the CLI wrapper formats and prints. `singlePinViolations` is
|
|
170
|
+
* populated only when `opts.singlePin` is not `false`, and reflects the
|
|
171
|
+
* intra-repo single-pin invariant across the workflow files only.
|
|
280
172
|
*/
|
|
281
173
|
export function runLint(opts) {
|
|
282
174
|
const cwd = opts.cwd || process.cwd();
|
|
283
175
|
const wfDir = resolve(cwd, opts.workflowsDir);
|
|
284
176
|
const acDir = resolve(cwd, opts.actionsDir);
|
|
285
|
-
const
|
|
177
|
+
const workflowFiles = listWorkflowFiles(wfDir);
|
|
178
|
+
const files = [...workflowFiles, ...listActionFiles(acDir)];
|
|
286
179
|
|
|
287
180
|
const violations = [];
|
|
288
181
|
let scanned = 0;
|
|
182
|
+
// Keep the raw workflow-file contents for the single-pin pass so we read
|
|
183
|
+
// each file from disk once.
|
|
184
|
+
const workflowRecords = [];
|
|
289
185
|
for (const file of files) {
|
|
290
186
|
let content;
|
|
291
187
|
try {
|
|
@@ -297,8 +193,23 @@ export function runLint(opts) {
|
|
|
297
193
|
const res = scanContent(content, display, opts.firstPartyOwner);
|
|
298
194
|
violations.push(...res.violations);
|
|
299
195
|
scanned += res.scanned;
|
|
196
|
+
if (workflowFiles.includes(file)) {
|
|
197
|
+
workflowRecords.push({ file: display, content });
|
|
198
|
+
}
|
|
300
199
|
}
|
|
301
|
-
|
|
200
|
+
|
|
201
|
+
const singlePin = opts.singlePin !== false;
|
|
202
|
+
const singlePinViolations = singlePin
|
|
203
|
+
? findSinglePinViolations(workflowRecords, opts.firstPartyOwner)
|
|
204
|
+
: [];
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
ok: violations.length === 0 && singlePinViolations.length === 0,
|
|
208
|
+
violations,
|
|
209
|
+
scanned,
|
|
210
|
+
files,
|
|
211
|
+
singlePinViolations,
|
|
212
|
+
};
|
|
302
213
|
}
|
|
303
214
|
|
|
304
215
|
// ---------------------------------------------------------------------------
|
|
@@ -316,7 +227,10 @@ export function runCli(argv, { log = console.log, err = console.error } = {}) {
|
|
|
316
227
|
|
|
317
228
|
const result = runLint(opts);
|
|
318
229
|
|
|
319
|
-
|
|
230
|
+
let failed = false;
|
|
231
|
+
|
|
232
|
+
if (result.violations.length > 0) {
|
|
233
|
+
failed = true;
|
|
320
234
|
err(`[action-pins] ❌ ${result.violations.length} unpinned third-party action(s):`);
|
|
321
235
|
for (const v of result.violations) {
|
|
322
236
|
err(` • ${v.file}:${v.line} — ${v.reason}`);
|
|
@@ -326,12 +240,31 @@ export function runCli(argv, { log = console.log, err = console.error } = {}) {
|
|
|
326
240
|
"(keep the `# vX.Y.Z` tag note as a comment). A mutable tag can be " +
|
|
327
241
|
"force-moved to a malicious commit after review."
|
|
328
242
|
);
|
|
329
|
-
return 1;
|
|
330
243
|
}
|
|
331
244
|
|
|
245
|
+
if (result.singlePinViolations.length > 0) {
|
|
246
|
+
failed = true;
|
|
247
|
+
err(
|
|
248
|
+
`[action-pins] ❌ ${result.singlePinViolations.length} first-party subpath(s) pinned to different SHAs (single-pin invariant):`
|
|
249
|
+
);
|
|
250
|
+
for (const v of result.singlePinViolations) {
|
|
251
|
+
err(` • ${v.target} is pinned to ${v.shas.length} distinct refs:`);
|
|
252
|
+
for (const occ of v.occurrences) {
|
|
253
|
+
err(` ${occ.file}:${occ.line} — @${occ.ref}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
err(
|
|
257
|
+
"[action-pins] Every first-party `uses:` to the same subpath across " +
|
|
258
|
+
".github/workflows/ must carry the SAME SHA — otherwise one workflow " +
|
|
259
|
+
"runs the fixed action and another the stale one."
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (failed) return 1;
|
|
264
|
+
|
|
332
265
|
log(
|
|
333
266
|
`[action-pins] ✅ all ${result.scanned} third-party action reference(s) are SHA-pinned ` +
|
|
334
|
-
`(${result.files.length} file(s) scanned).`
|
|
267
|
+
`(${result.files.length} file(s) scanned); first-party single-pin invariant holds.`
|
|
335
268
|
);
|
|
336
269
|
return 0;
|
|
337
270
|
}
|