@codyswann/lisa 4.29.10 → 4.29.12
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/all/copy-overwrite/scripts/check-third-party-review-evidence.mjs +706 -0
- package/all/copy-overwrite/scripts/lisa-gates.mjs +55 -1
- package/cdk/create-only/.github/workflows/ci.yml +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +10 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +20 -11
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/expo/create-only/.github/workflows/deploy.yml +4 -4
- package/harper-fabric/create-only/.github/workflows/deploy.yml +1 -1
- package/harper-fabric/create-only/.github/workflows/zap-baseline.yml +1 -1
- package/nestjs/create-only/.github/workflows/deploy.yml +9 -9
- package/npm-package/create-only/.github/workflows/publish-to-npm.yml +1 -1
- package/package.json +5 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +138 -37
- package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +138 -37
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +138 -37
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +138 -37
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +138 -37
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +138 -37
- package/rails/create-only/.github/workflows/deploy.yml +3 -3
- package/scripts/check-third-party-action-pins.mjs +455 -0
- package/scripts/two-channel-couplings.json +1 -1
- package/typescript/copy-overwrite/.prettierignore +1 -0
- package/typescript/create-only/.github/workflows/third-party-review-evidence.yml +114 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic gate that every THIRD-PARTY GitHub Action this repository uses
|
|
4
|
+
* — in its own workflows and in the workflow templates it seeds into consumers
|
|
5
|
+
* — is referenced by a full 40-character commit SHA (issue #3585).
|
|
6
|
+
*
|
|
7
|
+
* A mutable ref is not a version. `@master`, `@main`, `@v1` and `@v0.9.0` are
|
|
8
|
+
* pointers that resolve, at job start, to whatever the upstream owner has
|
|
9
|
+
* pushed at that instant, and for a third-party action that owner is a
|
|
10
|
+
* stranger. A tag is mutable by definition — `git push --force` moves it — and
|
|
11
|
+
* a floating major is *designed* to move.
|
|
12
|
+
*
|
|
13
|
+
* The exposure is not theoretical here. When this gate was written the tree
|
|
14
|
+
* held 19 distinct third-party references, none pinned, thirteen of them in
|
|
15
|
+
* jobs holding a credential:
|
|
16
|
+
*
|
|
17
|
+
* - `snyk/actions/node@master` with the Snyk token in the same step's `env:`;
|
|
18
|
+
* - `fossas/fossa-action@main` handed the FOSSA key as an input;
|
|
19
|
+
* - `noliran/branch-based-secrets@v1`, whose ENTIRE FUNCTION is to resolve
|
|
20
|
+
* repository secrets into the job environment, in the publish path;
|
|
21
|
+
* - `GeoWerkstatt/create-jira-release@v1`, handed a webhook secret.
|
|
22
|
+
*
|
|
23
|
+
* `quality.yml` and `release.yml` are reusable workflows the fleet calls at
|
|
24
|
+
* `@main`, so a compromised upstream would execute inside every consumer's
|
|
25
|
+
* quality gate on their next run.
|
|
26
|
+
*
|
|
27
|
+
* Pinning today does not stop the next `uses:` line, which is why the durable
|
|
28
|
+
* artifact is this detector rather than the pins. A stale comment in a
|
|
29
|
+
* consumer repository nearly caused two load-bearing references to be
|
|
30
|
+
* unpinned; prose does not hold a boundary, an executable check does.
|
|
31
|
+
*
|
|
32
|
+
* ## What is deliberately NOT in scope
|
|
33
|
+
*
|
|
34
|
+
* **First-party references.** `uses: CodySwannGT/lisa/...@main` is a different
|
|
35
|
+
* threat model — a stability question about an upstream we own and can read,
|
|
36
|
+
* tracked separately in #3488 — and a check that reddens it is the wrong check
|
|
37
|
+
* and will be turned off. First-party references never appear in this gate's
|
|
38
|
+
* findings and are never named in its output. That exemption is a required
|
|
39
|
+
* property with its own test, not an afterthought.
|
|
40
|
+
*
|
|
41
|
+
* **`actions/*` and `github/*`.** These are published by GitHub itself, the
|
|
42
|
+
* same party that runs the job, so pinning them moves no trust boundary. The
|
|
43
|
+
* decision is recorded here so it stays consistent, and the exemption is
|
|
44
|
+
* narrow: it is an OWNER allowlist, not a substring match.
|
|
45
|
+
*
|
|
46
|
+
* **Fixture trees** (`tests/fixtures/`, `parity/fixtures/`). These are inputs
|
|
47
|
+
* to other checks, never executed by GitHub Actions, and pinning them would
|
|
48
|
+
* distort the shapes those checks exist to recognise. The count of skipped
|
|
49
|
+
* fixture files is REPORTED in every run rather than silently dropped, so the
|
|
50
|
+
* boundary is visible in the output instead of buried in this comment.
|
|
51
|
+
*
|
|
52
|
+
* **Consumer repositories already seeded from the `create-only` templates.**
|
|
53
|
+
* A `create-only` file is written once and never overwritten, so correcting a
|
|
54
|
+
* template changes what FUTURE repositories are seeded with and changes
|
|
55
|
+
* nothing about any repository that already exists. This gate runs in this
|
|
56
|
+
* repository only; it cannot and does not report seeded consumers as fixed.
|
|
57
|
+
*
|
|
58
|
+
* ## Why a bare SHA is also a finding
|
|
59
|
+
*
|
|
60
|
+
* `uses: <vendor>/<action>@<40-hex>` with no trailing comment is
|
|
61
|
+
* immutable but unreadable: nobody can tell what is installed, so nobody
|
|
62
|
+
* upgrades it, so it rots at whatever commit it was pinned at. The pin and the
|
|
63
|
+
* human-readable version are one artifact, and the gate treats them that way.
|
|
64
|
+
*
|
|
65
|
+
* Determinism guarantees (so the unit test is reproducible and CI is stable):
|
|
66
|
+
* - zero third-party dependencies (Node built-ins only),
|
|
67
|
+
* - no network access,
|
|
68
|
+
* - no `Date` / `Math.random`,
|
|
69
|
+
* - the file list comes from `git ls-files`, so the gate sees exactly what a
|
|
70
|
+
* release would carry.
|
|
71
|
+
*
|
|
72
|
+
* CLI:
|
|
73
|
+
* node scripts/check-third-party-action-pins.mjs [--root <dir>] [--json]
|
|
74
|
+
*
|
|
75
|
+
* Exit codes (mirroring the sibling gates in this directory):
|
|
76
|
+
* 0 — every third-party reference is a 40-hex SHA carrying a version comment.
|
|
77
|
+
* 1 — >=1 reference is mutable, or pinned without a version comment.
|
|
78
|
+
* 2 — operational/usage error: unknown flag, a flag missing its value,
|
|
79
|
+
* `--root` absent or not a git repository, git unavailable, or zero
|
|
80
|
+
* workflow files discovered. Finding nothing to check is a broken
|
|
81
|
+
* invocation, not conformance.
|
|
82
|
+
*
|
|
83
|
+
* @module scripts/check-third-party-action-pins
|
|
84
|
+
*/
|
|
85
|
+
import fs from "node:fs";
|
|
86
|
+
import path from "node:path";
|
|
87
|
+
import process from "node:process";
|
|
88
|
+
import { fileURLToPath } from "node:url";
|
|
89
|
+
|
|
90
|
+
import { boundedExecFileSync } from "./lib/bounded-spawn.mjs";
|
|
91
|
+
import { invokedAsScript } from "./lib/invoked-as-script.mjs";
|
|
92
|
+
|
|
93
|
+
const REPO_ROOT = path.resolve(
|
|
94
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
95
|
+
".."
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Any workflow file, at any depth: this repository's own `.github/workflows/`
|
|
100
|
+
* and every seeded template tree, so a mutable ref cannot be introduced by
|
|
101
|
+
* seeding rather than by running.
|
|
102
|
+
*/
|
|
103
|
+
const WORKFLOW_RE = /(^|\/)\.github\/workflows\/[^/]+\.ya?ml$/;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Fixture trees: inputs to other checks, never executed by GitHub Actions.
|
|
107
|
+
* Prefix match on the repo-relative path, so a real workflow cannot hide
|
|
108
|
+
* behind a directory that merely contains the word "fixtures".
|
|
109
|
+
*/
|
|
110
|
+
const FIXTURE_PREFIXES = ["tests/fixtures/", "parity/fixtures/"];
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The owner of this repository's own reusable workflows. Compared
|
|
114
|
+
* case-insensitively because GitHub owners are case-insensitive and a `uses:`
|
|
115
|
+
* line may spell one either way.
|
|
116
|
+
*/
|
|
117
|
+
const FIRST_PARTY_OWNERS = ["codyswanngt"];
|
|
118
|
+
|
|
119
|
+
/** GitHub's own action namespaces — the runner's publisher, see header. */
|
|
120
|
+
const GITHUB_OWNED_OWNERS = ["actions", "github"];
|
|
121
|
+
|
|
122
|
+
/** Classification of an action's owner against the two documented exemptions. */
|
|
123
|
+
const FIRST_PARTY = "first-party";
|
|
124
|
+
const GITHUB_OWNED = "github-owned";
|
|
125
|
+
const THIRD_PARTY = "third-party";
|
|
126
|
+
|
|
127
|
+
/** Verdicts `evaluateReference` returns; `MUTABLE` is the headline finding. */
|
|
128
|
+
const OK = "ok";
|
|
129
|
+
const MUTABLE = "mutable-ref";
|
|
130
|
+
const NO_VERSION = "missing-version-comment";
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `uses: <owner>/<repo>[/<subpath>]@<ref>` with anything after it captured, so
|
|
134
|
+
* a trailing `# v1.2.3` can be read. Local (`./path`) and container
|
|
135
|
+
* (`docker://`) references have no owner and never match.
|
|
136
|
+
*/
|
|
137
|
+
const USES_RE =
|
|
138
|
+
/^\s*(?:-\s*)?uses:\s*(?<owner>[A-Za-z0-9][A-Za-z0-9-]*)\/(?<rest>[A-Za-z0-9_.][A-Za-z0-9_./-]*)@(?<ref>[^\s"'#]+)(?<trailer>.*)$/;
|
|
139
|
+
|
|
140
|
+
/** A full commit SHA. Abbreviated SHAs are ambiguous and do not count. */
|
|
141
|
+
const FULL_SHA_RE = /^[0-9a-f]{40}$/;
|
|
142
|
+
|
|
143
|
+
/** A trailing `# <version>` comment with at least one non-space character. */
|
|
144
|
+
const VERSION_COMMENT_RE = /#\s*(?<version>\S.*?)\s*$/;
|
|
145
|
+
|
|
146
|
+
/** Max bytes of `git ls-files` output (7k+ tracked paths is ~0.3 MB today). */
|
|
147
|
+
const MAX_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Usage error — thrown for an invalid invocation or an unusable state so
|
|
151
|
+
* `main` can distinguish it (exit 2) from a finding (exit 1).
|
|
152
|
+
*/
|
|
153
|
+
export class UsageError extends Error {}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Classify an action owner against the two documented exemptions.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} owner - the owner segment of a `uses:` reference.
|
|
159
|
+
* @returns {"first-party" | "github-owned" | "third-party"} the classification.
|
|
160
|
+
*/
|
|
161
|
+
export function classifyOwner(owner) {
|
|
162
|
+
const normalized = String(owner).toLowerCase();
|
|
163
|
+
if (FIRST_PARTY_OWNERS.includes(normalized)) return FIRST_PARTY;
|
|
164
|
+
if (GITHUB_OWNED_OWNERS.includes(normalized)) return GITHUB_OWNED;
|
|
165
|
+
return THIRD_PARTY;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Extract every action reference in `content`, already classified.
|
|
170
|
+
*
|
|
171
|
+
* Every reference is returned, first-party ones included, so the caller can
|
|
172
|
+
* count what it examined. Only third-party ones are ever eligible to become a
|
|
173
|
+
* finding — see `evaluateReference`.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} content - full text of a workflow file.
|
|
176
|
+
* @returns {{ action: string, owner: string, ref: string, version: string | null, kind: string, line: number }[]}
|
|
177
|
+
* one entry per reference, with 1-based line numbers, in file order.
|
|
178
|
+
*/
|
|
179
|
+
export function findActionRefs(content) {
|
|
180
|
+
const lines = String(content).split(/\r?\n/);
|
|
181
|
+
const refs = [];
|
|
182
|
+
for (let index = 0; index < lines.length; index++) {
|
|
183
|
+
const match = USES_RE.exec(lines[index]);
|
|
184
|
+
if (match === null) continue;
|
|
185
|
+
const { owner, ref, rest, trailer } = match.groups;
|
|
186
|
+
const comment = VERSION_COMMENT_RE.exec(trailer);
|
|
187
|
+
refs.push({
|
|
188
|
+
action: `${owner}/${rest}`,
|
|
189
|
+
kind: classifyOwner(owner),
|
|
190
|
+
line: index + 1,
|
|
191
|
+
owner,
|
|
192
|
+
ref,
|
|
193
|
+
version: comment === null ? null : comment.groups.version,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return refs;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Decide whether one reference is a finding.
|
|
201
|
+
*
|
|
202
|
+
* @param {{ kind: string, ref: string, version: string | null }} reference -
|
|
203
|
+
* a reference from `findActionRefs`.
|
|
204
|
+
* @returns {"ok" | "mutable-ref" | "missing-version-comment"} the verdict.
|
|
205
|
+
*/
|
|
206
|
+
export function evaluateReference(reference) {
|
|
207
|
+
if (reference.kind !== THIRD_PARTY) return OK;
|
|
208
|
+
if (!FULL_SHA_RE.test(reference.ref)) return MUTABLE;
|
|
209
|
+
return reference.version === null ? NO_VERSION : OK;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Whether a repo-relative path is a workflow this gate governs.
|
|
214
|
+
*
|
|
215
|
+
* @param {string} file - repo-relative path.
|
|
216
|
+
* @returns {boolean} whether the file is an in-scope workflow.
|
|
217
|
+
*/
|
|
218
|
+
export function isGovernedWorkflow(file) {
|
|
219
|
+
return WORKFLOW_RE.test(file) && !isFixtureWorkflow(file);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Whether a repo-relative path is an excluded fixture workflow.
|
|
224
|
+
*
|
|
225
|
+
* @param {string} file - repo-relative path.
|
|
226
|
+
* @returns {boolean} whether the file sits in an excluded fixture tree.
|
|
227
|
+
*/
|
|
228
|
+
export function isFixtureWorkflow(file) {
|
|
229
|
+
return (
|
|
230
|
+
WORKFLOW_RE.test(file) &&
|
|
231
|
+
FIXTURE_PREFIXES.some(prefix => file.startsWith(prefix))
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* List every tracked file in `root`, relative to it. Throws `UsageError` when
|
|
237
|
+
* git is unavailable or `root` is not a repository.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} root - the repository root.
|
|
240
|
+
* @returns {string[]} tracked paths, relative to `root`.
|
|
241
|
+
*/
|
|
242
|
+
function listTrackedFiles(root) {
|
|
243
|
+
let stdout;
|
|
244
|
+
try {
|
|
245
|
+
stdout = boundedExecFileSync("git", ["-C", root, "ls-files", "-z"], {
|
|
246
|
+
encoding: "utf8",
|
|
247
|
+
maxBuffer: MAX_GIT_OUTPUT_BYTES,
|
|
248
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
249
|
+
});
|
|
250
|
+
} catch (error) {
|
|
251
|
+
throw new UsageError(
|
|
252
|
+
`could not list tracked files in ${root}: ${error.message}`
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
return stdout.split("\0").filter(entry => entry !== "");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Assemble the machine-readable report.
|
|
260
|
+
*
|
|
261
|
+
* @param {ReadonlyArray<Record<string, unknown>>} findings - non-ok references.
|
|
262
|
+
* @param {{ root: string, workflows: number, fixturesSkipped: number, thirdParty: number, checked: number }} opts
|
|
263
|
+
* resolved options plus scan size.
|
|
264
|
+
* @returns {Record<string, unknown>} the report object.
|
|
265
|
+
*/
|
|
266
|
+
export function buildReport(findings, opts) {
|
|
267
|
+
return {
|
|
268
|
+
findings,
|
|
269
|
+
root: opts.root,
|
|
270
|
+
schemaVersion: 1,
|
|
271
|
+
summary: {
|
|
272
|
+
checked: opts.checked,
|
|
273
|
+
fixturesSkipped: opts.fixturesSkipped,
|
|
274
|
+
missingVersionComment: findings.filter(
|
|
275
|
+
finding => finding.verdict === NO_VERSION
|
|
276
|
+
).length,
|
|
277
|
+
mutable: findings.filter(finding => finding.verdict === MUTABLE).length,
|
|
278
|
+
thirdParty: opts.thirdParty,
|
|
279
|
+
workflows: opts.workflows,
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The remediation text. A guard that reddens without teaching the fix gets
|
|
286
|
+
* bypassed, so the exact command that produces the SHA is in the output.
|
|
287
|
+
*
|
|
288
|
+
* @returns {string[]} lines of remediation guidance.
|
|
289
|
+
*/
|
|
290
|
+
function remediation() {
|
|
291
|
+
return [
|
|
292
|
+
"Pin every third-party action to a full 40-character commit SHA, with the",
|
|
293
|
+
"human-readable version in a trailing comment so the pin stays auditable:",
|
|
294
|
+
"",
|
|
295
|
+
" - uses: <vendor>/<action>@<40-hex-sha> # v1.2.3",
|
|
296
|
+
"",
|
|
297
|
+
"Resolve the SHA for the ref the workflow uses TODAY — pinning and",
|
|
298
|
+
"upgrading in one commit makes a regression unattributable:",
|
|
299
|
+
"",
|
|
300
|
+
" gh api repos/<owner>/<repo>/commits/<ref> --jq .sha",
|
|
301
|
+
"",
|
|
302
|
+
"Then name the version that SHA carries:",
|
|
303
|
+
"",
|
|
304
|
+
" gh api repos/<owner>/<repo>/tags --jq '.[] | select(.commit.sha==\"<sha>\") | .name'",
|
|
305
|
+
"",
|
|
306
|
+
"A branch head with no tag is pinned the same way; say so in the comment,",
|
|
307
|
+
"e.g. `# main (untagged, 2026-08-28)`.",
|
|
308
|
+
"",
|
|
309
|
+
"First-party workflow references and GitHub's own actions/* and github/*",
|
|
310
|
+
"are out of scope for this gate by design and are never reported here.",
|
|
311
|
+
];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Render the human-readable report.
|
|
316
|
+
*
|
|
317
|
+
* @param {{ findings: ReadonlyArray<Record<string, unknown>>, summary: Record<string, number> }} report
|
|
318
|
+
* the report object.
|
|
319
|
+
* @returns {string} the rendered report.
|
|
320
|
+
*/
|
|
321
|
+
function humanReport(report) {
|
|
322
|
+
const { summary } = report;
|
|
323
|
+
const scanned =
|
|
324
|
+
`${summary.thirdParty} third-party reference(s) across ` +
|
|
325
|
+
`${summary.workflows} workflow file(s) ` +
|
|
326
|
+
`(${summary.fixturesSkipped} fixture workflow(s) skipped)`;
|
|
327
|
+
if (report.findings.length === 0) {
|
|
328
|
+
return `✓ ${scanned} — all pinned to a full commit SHA with a version comment`;
|
|
329
|
+
}
|
|
330
|
+
const lines = report.findings.map(finding =>
|
|
331
|
+
finding.verdict === MUTABLE
|
|
332
|
+
? `✗ ${finding.file}:${finding.line}\n ${finding.action}@${finding.ref} — mutable ref, not a commit SHA`
|
|
333
|
+
: `✗ ${finding.file}:${finding.line}\n ${finding.action}@${finding.ref} — pinned but no version comment`
|
|
334
|
+
);
|
|
335
|
+
return [
|
|
336
|
+
...lines,
|
|
337
|
+
"",
|
|
338
|
+
`${report.findings.length} of ${scanned} are not safely pinned.`,
|
|
339
|
+
"",
|
|
340
|
+
...remediation(),
|
|
341
|
+
].join("\n");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Parse argv into resolved options. Throws `UsageError` on a bad invocation.
|
|
346
|
+
*
|
|
347
|
+
* @param {readonly string[]} argv - arguments (without node/script prefix).
|
|
348
|
+
* @returns {{ root: string, json: boolean }} options.
|
|
349
|
+
*/
|
|
350
|
+
export function parseArgs(argv) {
|
|
351
|
+
let root = null;
|
|
352
|
+
let json = false;
|
|
353
|
+
for (let i = 0; i < argv.length; i++) {
|
|
354
|
+
const arg = argv[i];
|
|
355
|
+
if (arg === "--json") {
|
|
356
|
+
json = true;
|
|
357
|
+
} else if (arg === "--root") {
|
|
358
|
+
const next = argv[i + 1];
|
|
359
|
+
if (next === undefined || next.startsWith("--")) {
|
|
360
|
+
throw new UsageError("--root requires a value");
|
|
361
|
+
}
|
|
362
|
+
root = next;
|
|
363
|
+
i += 1;
|
|
364
|
+
} else {
|
|
365
|
+
throw new UsageError(`unknown argument: ${arg}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return { json, root: path.resolve(root ?? REPO_ROOT) };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Resolve the file list for a run, or throw `UsageError`.
|
|
373
|
+
*
|
|
374
|
+
* @param {string} root - the repository root.
|
|
375
|
+
* @returns {{ workflows: string[], fixturesSkipped: number }} the scan inputs.
|
|
376
|
+
*/
|
|
377
|
+
function collectWorkflows(root) {
|
|
378
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
379
|
+
throw new UsageError(`--root is not a directory: ${root}`);
|
|
380
|
+
}
|
|
381
|
+
const tracked = listTrackedFiles(root);
|
|
382
|
+
const workflows = tracked.filter(file => isGovernedWorkflow(file));
|
|
383
|
+
if (workflows.length === 0) {
|
|
384
|
+
// Finding nothing to check is a broken invocation, not conformance.
|
|
385
|
+
throw new UsageError(
|
|
386
|
+
`no workflow files found under ${root} — expected paths like ` +
|
|
387
|
+
".github/workflows/*.yml. Refusing to report a clean run for a scan " +
|
|
388
|
+
"that examined nothing."
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
fixturesSkipped: tracked.filter(file => isFixtureWorkflow(file)).length,
|
|
393
|
+
workflows,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Run the gate. Returns the process exit code (does not call `exit`).
|
|
399
|
+
*
|
|
400
|
+
* @param {readonly string[]} argv - arguments (without node/script prefix).
|
|
401
|
+
* @param {{ stdout?: { write(s: string): void }, stderr?: { write(s: string): void } }} [io]
|
|
402
|
+
* injectable streams (defaults to process streams).
|
|
403
|
+
* @returns {number} exit code (0 clean, 1 finding, 2 usage error).
|
|
404
|
+
*/
|
|
405
|
+
export function main(argv, io = {}) {
|
|
406
|
+
const out = io.stdout ?? process.stdout;
|
|
407
|
+
const err = io.stderr ?? process.stderr;
|
|
408
|
+
let opts;
|
|
409
|
+
let scan;
|
|
410
|
+
try {
|
|
411
|
+
opts = parseArgs(argv);
|
|
412
|
+
scan = collectWorkflows(opts.root);
|
|
413
|
+
} catch (error) {
|
|
414
|
+
err.write(`error: ${error.message}\n`);
|
|
415
|
+
return 2;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
let checked = 0;
|
|
419
|
+
let thirdParty = 0;
|
|
420
|
+
const findings = [];
|
|
421
|
+
try {
|
|
422
|
+
for (const file of scan.workflows) {
|
|
423
|
+
const content = fs.readFileSync(path.join(opts.root, file), "utf8");
|
|
424
|
+
for (const reference of findActionRefs(content)) {
|
|
425
|
+
checked += 1;
|
|
426
|
+
if (reference.kind === THIRD_PARTY) thirdParty += 1;
|
|
427
|
+
const verdict = evaluateReference(reference);
|
|
428
|
+
if (verdict !== OK) {
|
|
429
|
+
findings.push({ ...reference, file, verdict });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
} catch (error) {
|
|
434
|
+
err.write(`error: failed to scan workflows: ${error.message}\n`);
|
|
435
|
+
return 2;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const report = buildReport(findings, {
|
|
439
|
+
checked,
|
|
440
|
+
fixturesSkipped: scan.fixturesSkipped,
|
|
441
|
+
root: opts.root,
|
|
442
|
+
thirdParty,
|
|
443
|
+
workflows: scan.workflows.length,
|
|
444
|
+
});
|
|
445
|
+
out.write(
|
|
446
|
+
`${opts.json ? JSON.stringify(report, null, 2) : humanReport(report)}\n`
|
|
447
|
+
);
|
|
448
|
+
return report.findings.length === 0 ? 0 : 1;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (invokedAsScript(import.meta.url)) {
|
|
452
|
+
// exitCode (not process.exit): when stdout is a pipe, writes are async and
|
|
453
|
+
// process.exit() truncates the report mid-flush.
|
|
454
|
+
process.exitCode = main(process.argv.slice(2));
|
|
455
|
+
}
|
|
@@ -72,6 +72,7 @@ scripts/check-nightly-e2e-health.mjs
|
|
|
72
72
|
scripts/check-release-package-identity.mjs
|
|
73
73
|
scripts/check-skipped-required-checks.mjs
|
|
74
74
|
scripts/check-state-classification.mjs
|
|
75
|
+
scripts/check-third-party-review-evidence.mjs
|
|
75
76
|
scripts/check-threshold-ratchet.mjs
|
|
76
77
|
scripts/check-verification-coverage.mjs
|
|
77
78
|
scripts/classify-maestro-failures.mjs
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Seeded by Lisa on first setup — this file is YOURS.
|
|
2
|
+
# Lisa will not overwrite it. (copy-overwrite assets ARE replaced each run.)
|
|
3
|
+
# -----------------------------------------------------------------------------
|
|
4
|
+
# Third-party review evidence — the RESPONSE arm (#3591)
|
|
5
|
+
# -----------------------------------------------------------------------------
|
|
6
|
+
# `review-evidence.yml` answers "can the gate SEE a review that never happened?"
|
|
7
|
+
# This one answers "and then what?".
|
|
8
|
+
#
|
|
9
|
+
# A third-party review app posts a COMMIT STATUS on the pull request head. When
|
|
10
|
+
# it is throttled or declines, it still reports `state: success` — it never
|
|
11
|
+
# blocks. Only the `description` separates a review that ran from one that never
|
|
12
|
+
# started. Measured over the last 30 merged pull requests in the repository this
|
|
13
|
+
# was written against: 24 of 30 merges passed a required review gate that
|
|
14
|
+
# reviewed nothing, every one of them `success`.
|
|
15
|
+
#
|
|
16
|
+
# WHY `on: status` AND NOT A POLLING LOOP. The verdict changes the moment the
|
|
17
|
+
# reviewer posts, and only then. A schedule that polled for it would burn
|
|
18
|
+
# billable minutes on every repository, every interval, to learn nothing on
|
|
19
|
+
# almost every run. This wakes on the event that carries the answer, does one
|
|
20
|
+
# API read, decides, and exits.
|
|
21
|
+
#
|
|
22
|
+
# WHY THE JOB DOES NOT CREATE A STATUS. Creating one would be a `status` event,
|
|
23
|
+
# and this workflow triggers on `status`. Nothing here writes a status; the
|
|
24
|
+
# verdict is the run's own exit code.
|
|
25
|
+
#
|
|
26
|
+
# WHAT MAKES IT RED. Neither real third-party review evidence at the head, nor a
|
|
27
|
+
# posted adversarial-review substitute for that same head. A merge path that
|
|
28
|
+
# substitutes a local review leaves a marked comment behind, and this reads it.
|
|
29
|
+
#
|
|
30
|
+
# THREE NO-OPS THAT SAY SO. No reviewer configured, a commit that is nobody's
|
|
31
|
+
# pull request head, and a status from an app nobody declared are all no-ops —
|
|
32
|
+
# and each PRINTS that it is one. A silent pass is the defect this exists to
|
|
33
|
+
# end, so it is not reproduced here.
|
|
34
|
+
#
|
|
35
|
+
# AND ONE DEFERRAL. A declared reviewer still `pending` has not answered yet:
|
|
36
|
+
# measured, a reviewer posts `Review queued` then `Review in progress` before
|
|
37
|
+
# the verdict, up to seven statuses on one head. Judging the first would report
|
|
38
|
+
# a finding on 100% of pull requests, and a guard that fires every time gets
|
|
39
|
+
# deleted rather than read. The settled status fires its own event.
|
|
40
|
+
#
|
|
41
|
+
# THE TWO ARMS COVER DIFFERENT HALVES. This one wakes on a status and catches a
|
|
42
|
+
# reviewer that ANSWERED HOLLOW. `review-evidence.yml` runs on the pull-request
|
|
43
|
+
# event and catches a reviewer that NEVER ANSWERED, which produces no status and
|
|
44
|
+
# therefore no event here. Neither replaces the other.
|
|
45
|
+
#
|
|
46
|
+
# NOTHING RE-REQUESTS A REVIEW. Re-requesting OVERWRITES the existing status
|
|
47
|
+
# rather than adding to it, so under a throttle it destroys a real review,
|
|
48
|
+
# one-way. Do not add one.
|
|
49
|
+
|
|
50
|
+
name: 🕵️ Third-Party Review Evidence
|
|
51
|
+
|
|
52
|
+
on:
|
|
53
|
+
status:
|
|
54
|
+
workflow_dispatch:
|
|
55
|
+
inputs:
|
|
56
|
+
sha:
|
|
57
|
+
description: 'Commit SHA to inspect'
|
|
58
|
+
required: true
|
|
59
|
+
type: string
|
|
60
|
+
|
|
61
|
+
# `statuses: read` is the only route that carries a description at all;
|
|
62
|
+
# `pull-requests: read` finds the head pull request and its comments.
|
|
63
|
+
permissions:
|
|
64
|
+
contents: read
|
|
65
|
+
statuses: read
|
|
66
|
+
pull-requests: read
|
|
67
|
+
|
|
68
|
+
concurrency:
|
|
69
|
+
group: third-party-review-evidence-${{ github.event.sha || inputs.sha }}
|
|
70
|
+
cancel-in-progress: true
|
|
71
|
+
|
|
72
|
+
jobs:
|
|
73
|
+
evidence:
|
|
74
|
+
name: 🕵️ Was this head reviewed, or reviewed for?
|
|
75
|
+
# A pending status is the reviewer saying it has started, which is not yet
|
|
76
|
+
# an answer. Judging it would report a finding on every pull request, and a
|
|
77
|
+
# guard that fires every time gets deleted rather than read.
|
|
78
|
+
if: github.event_name == 'workflow_dispatch' || github.event.state != 'pending'
|
|
79
|
+
runs-on: ubuntu-latest
|
|
80
|
+
timeout-minutes: 5
|
|
81
|
+
|
|
82
|
+
steps:
|
|
83
|
+
- name: 📥 Checkout repository
|
|
84
|
+
uses: actions/checkout@v6
|
|
85
|
+
with:
|
|
86
|
+
ref: ${{ github.event.sha || inputs.sha }}
|
|
87
|
+
persist-credentials: false
|
|
88
|
+
|
|
89
|
+
- name: 🔧 Setup Node.js
|
|
90
|
+
uses: actions/setup-node@v6
|
|
91
|
+
with:
|
|
92
|
+
package-manager-cache: false
|
|
93
|
+
node-version: ${{ vars.NODE_VERSION || '22.21.1' }}
|
|
94
|
+
|
|
95
|
+
- name: 🕵️ Prove review evidence, or a posted substitute, at this head
|
|
96
|
+
env:
|
|
97
|
+
GH_TOKEN: ${{ github.token }}
|
|
98
|
+
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
99
|
+
EVIDENCE_SHA: ${{ github.event.sha || inputs.sha }}
|
|
100
|
+
# Empty on the manual path, where nothing woke the run and every
|
|
101
|
+
# declared reviewer is evaluated.
|
|
102
|
+
EVIDENCE_CONTEXT: ${{ github.event.context }}
|
|
103
|
+
run: |
|
|
104
|
+
set -eu
|
|
105
|
+
script=scripts/check-third-party-review-evidence.mjs
|
|
106
|
+
if [ ! -f "$script" ]; then
|
|
107
|
+
echo "::error title=Review-evidence prover missing::$script does not exist, so no review evidence was read for this commit. An absent prover is a FAILURE, never a skip — a job that passes because it found nothing to run with is the same false green this guard exists to refuse."
|
|
108
|
+
exit 1
|
|
109
|
+
fi
|
|
110
|
+
if [ -n "${EVIDENCE_CONTEXT:-}" ]; then
|
|
111
|
+
node "$script" --sha="$EVIDENCE_SHA" --context="$EVIDENCE_CONTEXT"
|
|
112
|
+
else
|
|
113
|
+
node "$script" --sha="$EVIDENCE_SHA"
|
|
114
|
+
fi
|