mandrel-platform 1.1.0 → 1.2.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/check-action-pins.mjs +87 -15
- package/scripts/check-action-pins.test.mjs +103 -4
- package/scripts/check-first-party-pin-freshness.mjs +532 -0
- package/scripts/check-first-party-pin-freshness.test.mjs +489 -0
- package/scripts/runner-env-drift.test.mjs +554 -0
- package/templates/runbooks/runner-provisioning.md +50 -6
- package/templates/runner/check-runner-env-drift.sh +248 -0
package/package.json
CHANGED
|
@@ -27,13 +27,29 @@
|
|
|
27
27
|
* `pnpm/action-setup`.) A non-SHA ref (a tag like `v4`, a branch, a short
|
|
28
28
|
* SHA) FAILS the lint.
|
|
29
29
|
*
|
|
30
|
-
* • FIRST-PARTY self-references — `dsj1984/mandrel-platform/...@<ref>` —
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
30
|
+
* • FIRST-PARTY self-references — `dsj1984/mandrel-platform/...@<ref>` — MUST
|
|
31
|
+
* ALSO be a 40-char hex SHA, and are reported as their own violation class.
|
|
32
|
+
* They were exempt until Story #354's audit: the exemption's stated
|
|
33
|
+
* justification was that `check-workflow-portability.mjs` Rule 3 governs
|
|
34
|
+
* them, but Rule 3's `collectInternalPins` skips any ref that is not
|
|
35
|
+
* already a 40-hex SHA (`if (!isSha40(cls.ref)) return`), so a
|
|
36
|
+
* branch-pinned self-reference was validated by NOTHING. The two other
|
|
37
|
+
* first-party guards had the same hole — the single-pin invariant below
|
|
38
|
+
* compares whatever refs it finds without requiring a SHA, and
|
|
39
|
+
* `check-first-party-pin-freshness.mjs` files a non-SHA ref under an
|
|
40
|
+
* informational `unpinnedRefs` note that never fails. So
|
|
41
|
+
* `…/gitleaks-scan@main` was green on all three.
|
|
42
|
+
*
|
|
43
|
+
* A moving self-ref is the same supply-chain risk the third-party ratchet
|
|
44
|
+
* exists to close, with a wider blast radius: `pr-quality.yml` is inherited
|
|
45
|
+
* by every consumer. It is also invisible to `platform-sync.mjs`, whose
|
|
46
|
+
* rewrite regex matches `@[0-9a-fA-F]{40}` only — a branch-pinned consumer
|
|
47
|
+
* workflow is silently skipped on every platform bump.
|
|
48
|
+
*
|
|
49
|
+
* The land-then-bump flow is unaffected: it always pins full SHAs (see
|
|
50
|
+
* docs/reusable-workflows.md § First-party self-pin freshness). The
|
|
51
|
+
* first-party owner is overridable via `--first-party-owner` for a fork,
|
|
52
|
+
* and first-party refs remain subject to the single-pin invariant below.
|
|
37
53
|
*
|
|
38
54
|
* • LOCAL `./path` references and `docker://image` references are EXEMPT —
|
|
39
55
|
* a local path has no upstream tag to move, and a docker ref is pinned by
|
|
@@ -125,10 +141,18 @@ export function parseArgs(argv) {
|
|
|
125
141
|
// ---------------------------------------------------------------------------
|
|
126
142
|
|
|
127
143
|
/**
|
|
128
|
-
* Scan a single file's TEXT for `uses:` step keys and evaluate
|
|
129
|
-
* reference
|
|
130
|
-
* `{
|
|
131
|
-
*
|
|
144
|
+
* Scan a single file's TEXT for `uses:` step keys and evaluate every REMOTE
|
|
145
|
+
* reference against the 40-hex SHA ratchet. Returns
|
|
146
|
+
* `{ violations, scanned, firstPartyViolations, firstPartyScanned }` — the two
|
|
147
|
+
* owner classes are counted and reported separately because their remediation
|
|
148
|
+
* differs (bump a vendored third-party pin vs. re-pin one of this repo's own
|
|
149
|
+
* call sites, which must move at every call site together to keep the
|
|
150
|
+
* single-pin invariant). A violation is `{ file, line, ref, owner, reason }`;
|
|
151
|
+
* `file` is left as passed-in (the caller supplies a display path).
|
|
152
|
+
*
|
|
153
|
+
* LOCAL (`./path`) and `docker://` references stay exempt: a local path has no
|
|
154
|
+
* upstream ref that can move, and a docker ref carries its own digest
|
|
155
|
+
* convention.
|
|
132
156
|
*
|
|
133
157
|
* Only lines whose first non-space token is `uses:` (a YAML mapping key) are
|
|
134
158
|
* inspected — `uses:` appearing inside a comment or a `run:` heredoc never
|
|
@@ -138,13 +162,33 @@ export function parseArgs(argv) {
|
|
|
138
162
|
*/
|
|
139
163
|
export function scanContent(content, displayFile, firstPartyOwner = DEFAULT_FIRST_PARTY_OWNER) {
|
|
140
164
|
const violations = [];
|
|
165
|
+
const firstPartyViolations = [];
|
|
141
166
|
let scanned = 0;
|
|
167
|
+
let firstPartyScanned = 0;
|
|
142
168
|
const lines = String(content).split(/\r?\n/);
|
|
143
169
|
for (let i = 0; i < lines.length; i++) {
|
|
144
170
|
const bareRef = parseUsesLine(lines[i]);
|
|
145
171
|
if (bareRef === null) continue;
|
|
146
172
|
const cls = classifyUses(bareRef, firstPartyOwner);
|
|
147
|
-
|
|
173
|
+
|
|
174
|
+
// First-party self-references (Story #354 audit). A bare `owner/repo@ref`
|
|
175
|
+
// carrying no subpath is ratcheted too: the hazard is the MOVING REF, and
|
|
176
|
+
// it moves whether or not the reference names a subpath.
|
|
177
|
+
if (cls.kind === "first-party") {
|
|
178
|
+
firstPartyScanned++;
|
|
179
|
+
if (!isSha40(cls.ref)) {
|
|
180
|
+
firstPartyViolations.push({
|
|
181
|
+
file: displayFile,
|
|
182
|
+
line: i + 1,
|
|
183
|
+
ref: bareRef,
|
|
184
|
+
owner: cls.owner,
|
|
185
|
+
reason: `first-party self-reference "${cls.owner}" is pinned to "${cls.ref}", not a full 40-char commit SHA`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (cls.kind !== "third-party") continue; // local/docker/unparseable → exempt
|
|
148
192
|
scanned++;
|
|
149
193
|
if (!isSha40(cls.ref)) {
|
|
150
194
|
violations.push({
|
|
@@ -156,7 +200,7 @@ export function scanContent(content, displayFile, firstPartyOwner = DEFAULT_FIRS
|
|
|
156
200
|
});
|
|
157
201
|
}
|
|
158
202
|
}
|
|
159
|
-
return { violations, scanned };
|
|
203
|
+
return { violations, scanned, firstPartyViolations, firstPartyScanned };
|
|
160
204
|
}
|
|
161
205
|
|
|
162
206
|
// ---------------------------------------------------------------------------
|
|
@@ -178,7 +222,9 @@ export function runLint(opts) {
|
|
|
178
222
|
const files = [...workflowFiles, ...listActionFiles(acDir)];
|
|
179
223
|
|
|
180
224
|
const violations = [];
|
|
225
|
+
const firstPartyViolations = [];
|
|
181
226
|
let scanned = 0;
|
|
227
|
+
let firstPartyScanned = 0;
|
|
182
228
|
// Keep the raw workflow-file contents for the single-pin pass so we read
|
|
183
229
|
// each file from disk once.
|
|
184
230
|
const workflowRecords = [];
|
|
@@ -192,7 +238,9 @@ export function runLint(opts) {
|
|
|
192
238
|
const display = relative(cwd, file) || file;
|
|
193
239
|
const res = scanContent(content, display, opts.firstPartyOwner);
|
|
194
240
|
violations.push(...res.violations);
|
|
241
|
+
firstPartyViolations.push(...res.firstPartyViolations);
|
|
195
242
|
scanned += res.scanned;
|
|
243
|
+
firstPartyScanned += res.firstPartyScanned;
|
|
196
244
|
if (workflowFiles.includes(file)) {
|
|
197
245
|
workflowRecords.push({ file: display, content });
|
|
198
246
|
}
|
|
@@ -204,9 +252,14 @@ export function runLint(opts) {
|
|
|
204
252
|
: [];
|
|
205
253
|
|
|
206
254
|
return {
|
|
207
|
-
ok:
|
|
255
|
+
ok:
|
|
256
|
+
violations.length === 0 &&
|
|
257
|
+
firstPartyViolations.length === 0 &&
|
|
258
|
+
singlePinViolations.length === 0,
|
|
208
259
|
violations,
|
|
260
|
+
firstPartyViolations,
|
|
209
261
|
scanned,
|
|
262
|
+
firstPartyScanned,
|
|
210
263
|
files,
|
|
211
264
|
singlePinViolations,
|
|
212
265
|
};
|
|
@@ -242,6 +295,24 @@ export function runCli(argv, { log = console.log, err = console.error } = {}) {
|
|
|
242
295
|
);
|
|
243
296
|
}
|
|
244
297
|
|
|
298
|
+
if (result.firstPartyViolations.length > 0) {
|
|
299
|
+
failed = true;
|
|
300
|
+
err(
|
|
301
|
+
`[action-pins] ❌ ${result.firstPartyViolations.length} first-party self-reference(s) pinned to a moving ref:`
|
|
302
|
+
);
|
|
303
|
+
for (const v of result.firstPartyViolations) {
|
|
304
|
+
err(` • ${v.file}:${v.line} — ${v.reason}`);
|
|
305
|
+
}
|
|
306
|
+
err(
|
|
307
|
+
"[action-pins] Pin every first-party `uses:` to a full 40-char commit SHA " +
|
|
308
|
+
"too (keep the `# vX.Y.Z` tag note as a comment). A branch or tag ref " +
|
|
309
|
+
"means the revision that runs can change with no diff here — and " +
|
|
310
|
+
"`pr-quality.yml` is inherited by every consumer. It is also invisible " +
|
|
311
|
+
"to platform-sync.mjs, whose rewrite matches 40-hex SHAs only, so it " +
|
|
312
|
+
"would be skipped on every platform bump."
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
245
316
|
if (result.singlePinViolations.length > 0) {
|
|
246
317
|
failed = true;
|
|
247
318
|
err(
|
|
@@ -263,7 +334,8 @@ export function runCli(argv, { log = console.log, err = console.error } = {}) {
|
|
|
263
334
|
if (failed) return 1;
|
|
264
335
|
|
|
265
336
|
log(
|
|
266
|
-
`[action-pins] ✅ all ${result.scanned} third-party
|
|
337
|
+
`[action-pins] ✅ all ${result.scanned} third-party and ${result.firstPartyScanned} ` +
|
|
338
|
+
`first-party action reference(s) are SHA-pinned ` +
|
|
267
339
|
`(${result.files.length} file(s) scanned); first-party single-pin invariant holds.`
|
|
268
340
|
);
|
|
269
341
|
return 0;
|
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
* a full content scan with both a SHA-pinned (pass) and a tag-pinned (fail)
|
|
11
11
|
* fixture. Pure helpers + a temp-dir fixture keep the whole pipeline offline.
|
|
12
12
|
*
|
|
13
|
+
* The first-party ratchet block (Story #354 audit) covers what used to be a
|
|
14
|
+
* three-way blind spot: a self-reference on a moving ref passed this lint (as
|
|
15
|
+
* exempt), the portability lint's Rule 3 (which skips non-SHA refs), and
|
|
16
|
+
* check-first-party-pin-freshness.mjs (which files it under a non-failing
|
|
17
|
+
* informational note) all at once.
|
|
18
|
+
*
|
|
13
19
|
* Run: node scripts/check-action-pins.test.mjs (or `node --test scripts/`)
|
|
14
20
|
*/
|
|
15
21
|
|
|
@@ -138,7 +144,7 @@ test("scanContent fails a tag-pinned third-party uses", () => {
|
|
|
138
144
|
assert.match(violations[0].reason, /not a full 40-char commit SHA/);
|
|
139
145
|
});
|
|
140
146
|
|
|
141
|
-
test("scanContent ignores
|
|
147
|
+
test("scanContent ignores local, docker, and comment lines", () => {
|
|
142
148
|
const yaml = [
|
|
143
149
|
"# uses: actions/checkout@v4 (this is a comment example, must be ignored)",
|
|
144
150
|
" steps:",
|
|
@@ -146,11 +152,102 @@ test("scanContent ignores first-party, local, docker, and comment lines", () =>
|
|
|
146
152
|
" - uses: ./.github/actions/local-thing",
|
|
147
153
|
" - uses: docker://alpine:3.19",
|
|
148
154
|
].join("\n");
|
|
149
|
-
const
|
|
150
|
-
assert.equal(scanned, 0); // none are third-party
|
|
155
|
+
const res = scanContent(yaml, "wf.yml");
|
|
156
|
+
assert.equal(res.scanned, 0); // none are third-party
|
|
157
|
+
assert.deepEqual(res.violations, []);
|
|
158
|
+
// The first-party ref IS counted now, and passes because it is SHA-pinned.
|
|
159
|
+
// A local `./path` and a `docker://` ref remain exempt from both classes.
|
|
160
|
+
assert.equal(res.firstPartyScanned, 1);
|
|
161
|
+
assert.deepEqual(res.firstPartyViolations, []);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// First-party SHA ratchet (Story #354 audit)
|
|
166
|
+
//
|
|
167
|
+
// A first-party self-reference on a moving ref used to be green on all three
|
|
168
|
+
// first-party guards at once: this ratchet skipped it as exempt, the
|
|
169
|
+
// portability lint's Rule 3 skips any ref that is not already a 40-hex SHA,
|
|
170
|
+
// and check-first-party-pin-freshness.mjs files it under an informational
|
|
171
|
+
// note that never fails. These tests pin the hole shut.
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
test("scanContent flags a branch-pinned first-party self-reference", () => {
|
|
175
|
+
const yaml = " - uses: dsj1984/mandrel-platform/.github/actions/gitleaks-scan@main";
|
|
176
|
+
const { firstPartyViolations, firstPartyScanned, violations } = scanContent(yaml, "wf.yml");
|
|
177
|
+
assert.equal(firstPartyScanned, 1);
|
|
178
|
+
assert.equal(firstPartyViolations.length, 1);
|
|
179
|
+
assert.equal(firstPartyViolations[0].line, 1);
|
|
180
|
+
assert.equal(firstPartyViolations[0].owner, "dsj1984/mandrel-platform");
|
|
181
|
+
assert.match(firstPartyViolations[0].reason, /not a full 40-char commit SHA/);
|
|
182
|
+
// Never misfiled as a third-party violation — the remediation differs.
|
|
151
183
|
assert.deepEqual(violations, []);
|
|
152
184
|
});
|
|
153
185
|
|
|
186
|
+
test("scanContent flags a tag-pinned and a short-SHA first-party self-reference", () => {
|
|
187
|
+
const yaml = [
|
|
188
|
+
" - uses: dsj1984/mandrel-platform/.github/workflows/pr-quality.yml@v1.1.0",
|
|
189
|
+
` - uses: dsj1984/mandrel-platform/.github/actions/osv-scan@${SHORT}`,
|
|
190
|
+
].join("\n");
|
|
191
|
+
const { firstPartyViolations } = scanContent(yaml, "wf.yml");
|
|
192
|
+
assert.equal(firstPartyViolations.length, 2);
|
|
193
|
+
assert.deepEqual(
|
|
194
|
+
firstPartyViolations.map((v) => v.line),
|
|
195
|
+
[1, 2],
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("scanContent ratchets a bare first-party self-reference carrying no subpath", () => {
|
|
200
|
+
// No subpath means no manifest for the freshness checker to resolve, but the
|
|
201
|
+
// ref still moves — so the ratchet must still apply.
|
|
202
|
+
const yaml = " - uses: dsj1984/mandrel-platform@main";
|
|
203
|
+
const { firstPartyViolations, firstPartyScanned } = scanContent(yaml, "wf.yml");
|
|
204
|
+
assert.equal(firstPartyScanned, 1);
|
|
205
|
+
assert.equal(firstPartyViolations.length, 1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("scanContent honours --first-party-owner when ratcheting a fork's self-refs", () => {
|
|
209
|
+
const yaml = " - uses: my-org/my-fork/.github/actions/thing@main";
|
|
210
|
+
const asFirstParty = scanContent(yaml, "wf.yml", "my-org/my-fork");
|
|
211
|
+
assert.equal(asFirstParty.firstPartyViolations.length, 1);
|
|
212
|
+
assert.deepEqual(asFirstParty.violations, []);
|
|
213
|
+
|
|
214
|
+
// Under the default owner the SAME line is a third-party violation instead —
|
|
215
|
+
// still caught, just under the other class.
|
|
216
|
+
const asThirdParty = scanContent(yaml, "wf.yml");
|
|
217
|
+
assert.equal(asThirdParty.violations.length, 1);
|
|
218
|
+
assert.deepEqual(asThirdParty.firstPartyViolations, []);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("runLint / runCli are red on a branch-pinned first-party self-reference", () => {
|
|
222
|
+
const root = fixtureRepo({
|
|
223
|
+
workflow: [
|
|
224
|
+
" steps:",
|
|
225
|
+
` - uses: actions/checkout@${SHA} # v4`,
|
|
226
|
+
" - uses: dsj1984/mandrel-platform/.github/actions/gitleaks-scan@main",
|
|
227
|
+
].join("\n"),
|
|
228
|
+
});
|
|
229
|
+
try {
|
|
230
|
+
const res = runLint({
|
|
231
|
+
cwd: root,
|
|
232
|
+
workflowsDir: ".github/workflows",
|
|
233
|
+
actionsDir: ".github/actions",
|
|
234
|
+
firstPartyOwner: "dsj1984/mandrel-platform",
|
|
235
|
+
});
|
|
236
|
+
assert.equal(res.ok, false);
|
|
237
|
+
assert.equal(res.firstPartyViolations.length, 1);
|
|
238
|
+
assert.deepEqual(res.violations, []); // the third-party pin is fine
|
|
239
|
+
|
|
240
|
+
const errs = [];
|
|
241
|
+
const code = runCli(["--cwd", root], { log: () => {}, err: (m) => errs.push(m) });
|
|
242
|
+
assert.equal(code, 1);
|
|
243
|
+
assert.ok(errs.some((m) => /first-party self-reference\(s\) pinned to a moving ref/.test(m)));
|
|
244
|
+
// The remedy names the blast radius, not just the rule.
|
|
245
|
+
assert.ok(errs.some((m) => /inherited by every consumer/.test(m)));
|
|
246
|
+
} finally {
|
|
247
|
+
rmSync(root, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
154
251
|
test("scanContent flags a short-SHA third-party pin", () => {
|
|
155
252
|
const yaml = ` - uses: actions/setup-node@${SHORT}`;
|
|
156
253
|
const { violations } = scanContent(yaml, "wf.yml");
|
|
@@ -205,7 +302,9 @@ test("runLint is green when every third-party action is SHA-pinned", () => {
|
|
|
205
302
|
firstPartyOwner: "dsj1984/mandrel-platform",
|
|
206
303
|
});
|
|
207
304
|
assert.equal(res.ok, true);
|
|
208
|
-
assert.equal(res.scanned, 1); // only the third-party checkout counts
|
|
305
|
+
assert.equal(res.scanned, 1); // only the third-party checkout counts here
|
|
306
|
+
assert.equal(res.firstPartyScanned, 1); // the self-reference is counted separately
|
|
307
|
+
assert.deepEqual(res.firstPartyViolations, []);
|
|
209
308
|
} finally {
|
|
210
309
|
rmSync(root, { recursive: true, force: true });
|
|
211
310
|
}
|