continuous-improvement 3.17.0 → 3.18.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/.claude-plugin/marketplace.json +1 -1
- package/README.md +164 -99
- package/bin/audit-actions.mjs +433 -0
- package/bin/check-command-count.mjs +114 -0
- package/bin/portfolio-health.mjs +298 -0
- package/commands/reconcile.md +34 -7
- package/hooks/gateguard.mjs +137 -3
- package/lib/gateguard-state.mjs +5 -1
- package/package.json +12 -6
- package/plugins/beginner.json +1 -1
- package/plugins/continuous-improvement/.claude-plugin/marketplace.json +1 -1
- package/plugins/continuous-improvement/.claude-plugin/plugin.json +1 -1
- package/plugins/continuous-improvement/commands/reconcile.md +34 -7
- package/plugins/continuous-improvement/hooks/gateguard.mjs +137 -3
- package/plugins/continuous-improvement/lib/gateguard-state.mjs +5 -1
- package/plugins/continuous-improvement/skills/README.md +1 -1
- package/plugins/continuous-improvement/skills/gateguard/SKILL.md +10 -0
- package/plugins/continuous-improvement/skills/reconcile/SKILL.md +52 -4
- package/plugins/expert.json +1 -1
- package/skills/gateguard.md +10 -0
- package/skills/reconcile.md +52 -4
- package/templates/actions_security_checklist.md +39 -0
- package/templates/experiment_template.md +38 -0
- package/templates/portfolio_event.schema.json +69 -0
- package/templates/release_receipt_template.md +37 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* audit-actions — static GitHub Actions security scanner.
|
|
4
|
+
*
|
|
5
|
+
* Portfolio-spine train (docs/plans/2026-07-04-portfolio-spine.md).
|
|
6
|
+
*
|
|
7
|
+
* Scans `.github/workflows/*.yml|*.yaml` for the mechanical subset of the
|
|
8
|
+
* checks in templates/actions_security_checklist.md:
|
|
9
|
+
*
|
|
10
|
+
* (a) missing explicit `permissions:` at workflow AND job level -> high
|
|
11
|
+
* (b) `permissions: write-all` -> high
|
|
12
|
+
* (c) `uses: owner/repo@ref` not pinned to a 40-hex commit SHA -> medium
|
|
13
|
+
* (first-party actions/* github/* not SHA-pinned -> low)
|
|
14
|
+
* (d) job missing `timeout-minutes` -> medium
|
|
15
|
+
* (e) push/schedule-triggered workflow without `concurrency:`
|
|
16
|
+
* at the workflow level or on every job -> low
|
|
17
|
+
* (f) `${{ github.event.* }}` / `${{ github.head_ref }}` in run: -> high
|
|
18
|
+
* (g) pull_request_target/issue_comment/issues trigger combined
|
|
19
|
+
* with secrets.* usage or write permissions -> high
|
|
20
|
+
*
|
|
21
|
+
* Zero runtime dependencies: the YAML handling is hand-rolled, line-oriented
|
|
22
|
+
* parsing of the constrained GitHub-workflow YAML subset (indentation-based
|
|
23
|
+
* key detection). Its limits — naive comment stripping, shallow flow-style
|
|
24
|
+
* parsing, no anchors/aliases, block scalars tracked for run: only — are
|
|
25
|
+
* documented in docs/plans/2026-07-04-portfolio-spine.md. Constructs the
|
|
26
|
+
* parser does not understand produce no findings (false negatives over
|
|
27
|
+
* false positives); the checklist template is the human layer on top.
|
|
28
|
+
*
|
|
29
|
+
* Usage:
|
|
30
|
+
* node bin/audit-actions.mjs [--repo <path>] [--out <file>] [--strict]
|
|
31
|
+
* --repo <path> repository root to scan (default ".")
|
|
32
|
+
* --out <file> markdown report path (default "reports/actions-security.md")
|
|
33
|
+
* --strict exit 1 when any high-severity finding exists
|
|
34
|
+
* --help print usage
|
|
35
|
+
*
|
|
36
|
+
* Exit codes: 0 scan completed; 1 --strict with high findings; 2 repo root
|
|
37
|
+
* not found (fail closed — a typo'd --repo must never pass a security gate).
|
|
38
|
+
*/
|
|
39
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
40
|
+
import { dirname, join } from "node:path";
|
|
41
|
+
import { argv, exit } from "node:process";
|
|
42
|
+
const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 };
|
|
43
|
+
const SHA_PIN = /^[0-9a-f]{40}$/;
|
|
44
|
+
const DANGEROUS_TRIGGERS = ["pull_request_target", "issue_comment", "issues"];
|
|
45
|
+
const CONCURRENCY_TRIGGERS = ["push", "schedule"];
|
|
46
|
+
const UNTRUSTED_INTERPOLATION = /\$\{\{\s*github\.(?:event\.|head_ref\b)/;
|
|
47
|
+
const SECRETS_INTERPOLATION = /\$\{\{\s*secrets\./;
|
|
48
|
+
const REMEDIATION = {
|
|
49
|
+
"missing-permissions": "Declare explicit `permissions:` (default `contents: read`) at the workflow level; add write scopes per-job only where provably needed.",
|
|
50
|
+
"write-all-permissions": "Replace `write-all` with the minimal named scopes each job needs.",
|
|
51
|
+
"unpinned-third-party-action": "Pin third-party actions to a full 40-hex commit SHA — tags and branches are mutable.",
|
|
52
|
+
"unpinned-first-party-action": "Pin first-party actions to a full commit SHA, or at minimum a major version tag.",
|
|
53
|
+
"missing-timeout": "Add `timeout-minutes:` to the job to cap runaway or hung runs.",
|
|
54
|
+
"missing-concurrency": "Add a `concurrency:` block with a stable group key so overlapping runs cancel or queue.",
|
|
55
|
+
"untrusted-input-in-run": "Pass untrusted event text through `env:` and reference it as a quoted shell variable; never interpolate `${{ github.event.* }}` or `${{ github.head_ref }}` directly into `run:`.",
|
|
56
|
+
"dangerous-trigger-with-secrets": "Workflows on pull_request_target/issue_comment/issues run with attacker-influenced input; drop secrets/write tokens from them or add a maintainer-approval gate.",
|
|
57
|
+
};
|
|
58
|
+
/** Strip a trailing comment, tracking single/double quotes so `#` inside a quoted scalar survives. */
|
|
59
|
+
export function stripComment(line) {
|
|
60
|
+
let inSingle = false;
|
|
61
|
+
let inDouble = false;
|
|
62
|
+
for (let i = 0; i < line.length; i++) {
|
|
63
|
+
const ch = line[i];
|
|
64
|
+
if (ch === "'" && !inDouble)
|
|
65
|
+
inSingle = !inSingle;
|
|
66
|
+
else if (ch === '"' && !inSingle)
|
|
67
|
+
inDouble = !inDouble;
|
|
68
|
+
else if (ch === "#" && !inSingle && !inDouble) {
|
|
69
|
+
if (i === 0 || line[i - 1] === " " || line[i - 1] === "\t") {
|
|
70
|
+
return line.slice(0, i).trimEnd();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return line;
|
|
75
|
+
}
|
|
76
|
+
function indentOf(line) {
|
|
77
|
+
let n = 0;
|
|
78
|
+
while (n < line.length && line[n] === " ")
|
|
79
|
+
n++;
|
|
80
|
+
return n;
|
|
81
|
+
}
|
|
82
|
+
function unquote(value) {
|
|
83
|
+
const v = value.trim();
|
|
84
|
+
if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) {
|
|
85
|
+
return v.slice(1, -1);
|
|
86
|
+
}
|
|
87
|
+
return v;
|
|
88
|
+
}
|
|
89
|
+
/** Trigger names from an inline `on:` value — scalar (`push`) or flow list (`[push, schedule]`). */
|
|
90
|
+
function parseInlineTriggers(value) {
|
|
91
|
+
const v = value.trim();
|
|
92
|
+
if (v.startsWith("[")) {
|
|
93
|
+
return v
|
|
94
|
+
.replace(/^\[/, "")
|
|
95
|
+
.replace(/\]$/, "")
|
|
96
|
+
.split(",")
|
|
97
|
+
.map((t) => unquote(t))
|
|
98
|
+
.filter((t) => t !== "");
|
|
99
|
+
}
|
|
100
|
+
return v === "" ? [] : [unquote(v)];
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Parse one workflow into the facts the checks need. Line-oriented,
|
|
104
|
+
* indentation-based; see the module header for the documented subset limits.
|
|
105
|
+
*/
|
|
106
|
+
export function parseWorkflow(content) {
|
|
107
|
+
const parsed = {
|
|
108
|
+
triggers: [],
|
|
109
|
+
onLine: 1,
|
|
110
|
+
hasWorkflowPermissions: false,
|
|
111
|
+
hasConcurrency: false,
|
|
112
|
+
hasWritePermission: false,
|
|
113
|
+
writeAllLines: [],
|
|
114
|
+
usesSecrets: false,
|
|
115
|
+
usesRefs: [],
|
|
116
|
+
runInterpolationLines: [],
|
|
117
|
+
jobs: [],
|
|
118
|
+
};
|
|
119
|
+
const lines = content.split(/\r?\n/);
|
|
120
|
+
let topKey = "";
|
|
121
|
+
let onNestedIndent = -1;
|
|
122
|
+
let jobNameIndent = -1;
|
|
123
|
+
let jobPropIndent = -1;
|
|
124
|
+
let currentJob = null;
|
|
125
|
+
let permBlockIndent = -1; // inside a permissions: block (workflow or job level)
|
|
126
|
+
let runBlockIndent = -1; // inside a run: | block scalar
|
|
127
|
+
for (let i = 0; i < lines.length; i++) {
|
|
128
|
+
const raw = stripComment(lines[i] ?? "");
|
|
129
|
+
if (raw.trim() === "")
|
|
130
|
+
continue;
|
|
131
|
+
const lineNo = i + 1;
|
|
132
|
+
const indent = indentOf(raw);
|
|
133
|
+
let text = raw.trim();
|
|
134
|
+
if (SECRETS_INTERPOLATION.test(raw))
|
|
135
|
+
parsed.usesSecrets = true;
|
|
136
|
+
if (runBlockIndent >= 0) {
|
|
137
|
+
if (indent > runBlockIndent) {
|
|
138
|
+
if (UNTRUSTED_INTERPOLATION.test(raw))
|
|
139
|
+
parsed.runInterpolationLines.push(lineNo);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
runBlockIndent = -1;
|
|
143
|
+
}
|
|
144
|
+
if (permBlockIndent >= 0 && indent <= permBlockIndent)
|
|
145
|
+
permBlockIndent = -1;
|
|
146
|
+
if (permBlockIndent >= 0 && /:\s*(write|write-all)\s*$/.test(text)) {
|
|
147
|
+
parsed.hasWritePermission = true;
|
|
148
|
+
if (/write-all\s*$/.test(text))
|
|
149
|
+
parsed.writeAllLines.push(lineNo);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
// Normalize step-item lines ("- uses: x", "- run: y") to key/value form.
|
|
153
|
+
const isListItem = text.startsWith("- ");
|
|
154
|
+
if (isListItem)
|
|
155
|
+
text = text.slice(2).trim();
|
|
156
|
+
const keyMatch = /^([A-Za-z_][A-Za-z0-9_-]*):(.*)$/.exec(text);
|
|
157
|
+
if (indent === 0 && keyMatch && !isListItem) {
|
|
158
|
+
topKey = keyMatch[1];
|
|
159
|
+
const value = keyMatch[2].trim();
|
|
160
|
+
onNestedIndent = -1;
|
|
161
|
+
if (topKey === "on") {
|
|
162
|
+
parsed.onLine = lineNo;
|
|
163
|
+
parsed.triggers.push(...parseInlineTriggers(value));
|
|
164
|
+
}
|
|
165
|
+
else if (topKey === "permissions") {
|
|
166
|
+
parsed.hasWorkflowPermissions = true;
|
|
167
|
+
notePermissionsValue(parsed, value, lineNo);
|
|
168
|
+
if (value === "")
|
|
169
|
+
permBlockIndent = indent;
|
|
170
|
+
}
|
|
171
|
+
else if (topKey === "concurrency") {
|
|
172
|
+
parsed.hasConcurrency = true;
|
|
173
|
+
}
|
|
174
|
+
else if (topKey === "jobs") {
|
|
175
|
+
jobNameIndent = -1;
|
|
176
|
+
jobPropIndent = -1;
|
|
177
|
+
}
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (topKey === "on" && keyMatch && !isListItem) {
|
|
181
|
+
if (onNestedIndent === -1)
|
|
182
|
+
onNestedIndent = indent;
|
|
183
|
+
if (indent === onNestedIndent)
|
|
184
|
+
parsed.triggers.push(keyMatch[1]);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (topKey !== "jobs" || !keyMatch)
|
|
188
|
+
continue;
|
|
189
|
+
// First nested key under jobs: fixes the job-name indent level.
|
|
190
|
+
if (jobNameIndent === -1 && !isListItem)
|
|
191
|
+
jobNameIndent = indent;
|
|
192
|
+
if (indent === jobNameIndent && !isListItem && keyMatch[2].trim() === "") {
|
|
193
|
+
currentJob = { name: keyMatch[1], line: lineNo, hasPermissions: false, hasTimeout: false, hasConcurrency: false, isReusableCall: false };
|
|
194
|
+
parsed.jobs.push(currentJob);
|
|
195
|
+
jobPropIndent = -1;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (currentJob === null)
|
|
199
|
+
continue;
|
|
200
|
+
if (jobPropIndent === -1 && indent > jobNameIndent && !isListItem)
|
|
201
|
+
jobPropIndent = indent;
|
|
202
|
+
const key = keyMatch[1];
|
|
203
|
+
const value = keyMatch[2].trim();
|
|
204
|
+
const isJobProp = indent === jobPropIndent && !isListItem;
|
|
205
|
+
if (isJobProp && key === "permissions") {
|
|
206
|
+
currentJob.hasPermissions = true;
|
|
207
|
+
notePermissionsValue(parsed, value, lineNo);
|
|
208
|
+
if (value === "")
|
|
209
|
+
permBlockIndent = indent;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (isJobProp && key === "timeout-minutes") {
|
|
213
|
+
currentJob.hasTimeout = true;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (isJobProp && key === "concurrency") {
|
|
217
|
+
currentJob.hasConcurrency = true;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (key === "uses") {
|
|
221
|
+
if (isJobProp)
|
|
222
|
+
currentJob.isReusableCall = true;
|
|
223
|
+
parsed.usesRefs.push({ line: lineNo, spec: unquote(value) });
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (key === "run") {
|
|
227
|
+
if (/^[|>][+-]?\d*$/.test(value)) {
|
|
228
|
+
runBlockIndent = indent;
|
|
229
|
+
}
|
|
230
|
+
else if (UNTRUSTED_INTERPOLATION.test(value)) {
|
|
231
|
+
parsed.runInterpolationLines.push(lineNo);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return parsed;
|
|
236
|
+
}
|
|
237
|
+
/** Record write-all / inline write scopes from a `permissions:` value. */
|
|
238
|
+
function notePermissionsValue(parsed, value, lineNo) {
|
|
239
|
+
if (value === "")
|
|
240
|
+
return;
|
|
241
|
+
if (/^["']?write-all["']?$/.test(value)) {
|
|
242
|
+
parsed.hasWritePermission = true;
|
|
243
|
+
parsed.writeAllLines.push(lineNo);
|
|
244
|
+
}
|
|
245
|
+
else if (/:\s*write\b/.test(value)) {
|
|
246
|
+
// Inline flow map, e.g. permissions: {contents: write}
|
|
247
|
+
parsed.hasWritePermission = true;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function checkFile(file) {
|
|
251
|
+
const w = parseWorkflow(file.content);
|
|
252
|
+
const findings = [];
|
|
253
|
+
const add = (line, severity, check, message) => {
|
|
254
|
+
findings.push({ file: file.path, line, severity, check, message, remediation: REMEDIATION[check] ?? "" });
|
|
255
|
+
};
|
|
256
|
+
// (a) permissions absent everywhere
|
|
257
|
+
if (!w.hasWorkflowPermissions && !w.jobs.some((j) => j.hasPermissions)) {
|
|
258
|
+
add(1, "high", "missing-permissions", "No explicit `permissions:` at the workflow level or on any job — the workflow runs with the default token grant.");
|
|
259
|
+
}
|
|
260
|
+
// (b) write-all
|
|
261
|
+
for (const line of w.writeAllLines) {
|
|
262
|
+
add(line, "high", "write-all-permissions", "`permissions: write-all` grants every scope to the workflow token.");
|
|
263
|
+
}
|
|
264
|
+
// (c) unpinned uses references
|
|
265
|
+
for (const ref of w.usesRefs) {
|
|
266
|
+
const finding = classifyUsesRef(ref);
|
|
267
|
+
if (finding)
|
|
268
|
+
add(ref.line, finding.severity, finding.check, finding.message);
|
|
269
|
+
}
|
|
270
|
+
// (d) missing timeout-minutes per job (reusable-workflow call jobs excluded)
|
|
271
|
+
for (const job of w.jobs) {
|
|
272
|
+
if (!job.isReusableCall && !job.hasTimeout) {
|
|
273
|
+
add(job.line, "medium", "missing-timeout", `Job \`${job.name}\` has no \`timeout-minutes\`.`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// (e) push/schedule without concurrency — satisfied by a workflow-level
|
|
277
|
+
// block, or by every job declaring its own job-level concurrency.
|
|
278
|
+
const everyJobHasConcurrency = w.jobs.length > 0 && w.jobs.every((j) => j.hasConcurrency);
|
|
279
|
+
if (!w.hasConcurrency && !everyJobHasConcurrency && w.triggers.some((t) => CONCURRENCY_TRIGGERS.includes(t))) {
|
|
280
|
+
add(w.onLine, "low", "missing-concurrency", "Workflow is triggered by push/schedule but declares no `concurrency:` block.");
|
|
281
|
+
}
|
|
282
|
+
// (f) untrusted interpolation inside run:
|
|
283
|
+
for (const line of w.runInterpolationLines) {
|
|
284
|
+
add(line, "high", "untrusted-input-in-run", "Untrusted event text (`github.event.*` / `github.head_ref`) is interpolated directly into a `run:` shell command.");
|
|
285
|
+
}
|
|
286
|
+
// (g) dangerous trigger combined with secrets or write permissions
|
|
287
|
+
const dangerous = w.triggers.filter((t) => DANGEROUS_TRIGGERS.includes(t));
|
|
288
|
+
if (dangerous.length > 0 && (w.usesSecrets || w.hasWritePermission)) {
|
|
289
|
+
add(w.onLine, "high", "dangerous-trigger-with-secrets", `Trigger \`${dangerous.join("`, `")}\` is combined with ${w.usesSecrets ? "secrets access" : "write permissions"}.`);
|
|
290
|
+
}
|
|
291
|
+
return findings;
|
|
292
|
+
}
|
|
293
|
+
function classifyUsesRef(ref) {
|
|
294
|
+
const spec = ref.spec;
|
|
295
|
+
if (spec === "" || spec.startsWith("./") || spec.startsWith("docker://"))
|
|
296
|
+
return null;
|
|
297
|
+
const m = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:\/[^@]*)?(?:@(.+))?$/.exec(spec);
|
|
298
|
+
if (!m)
|
|
299
|
+
return null;
|
|
300
|
+
const owner = m[1];
|
|
301
|
+
const gitRef = m[3] ?? "";
|
|
302
|
+
if (SHA_PIN.test(gitRef))
|
|
303
|
+
return null;
|
|
304
|
+
const firstParty = owner === "actions" || owner === "github";
|
|
305
|
+
if (firstParty) {
|
|
306
|
+
return {
|
|
307
|
+
severity: "low",
|
|
308
|
+
check: "unpinned-first-party-action",
|
|
309
|
+
message: `First-party action \`${spec}\` is not pinned to a commit SHA.`,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
severity: "medium",
|
|
314
|
+
check: "unpinned-third-party-action",
|
|
315
|
+
message: `Third-party action \`${spec}\` is not pinned to a 40-hex commit SHA.`,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Pure scanner: workflow files in, deterministic findings out.
|
|
320
|
+
* Sorted by file path, then line, then severity, then check id.
|
|
321
|
+
*/
|
|
322
|
+
export function auditWorkflows(files) {
|
|
323
|
+
const findings = [];
|
|
324
|
+
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
325
|
+
findings.push(...checkFile(file));
|
|
326
|
+
}
|
|
327
|
+
return findings.sort((a, b) => a.file.localeCompare(b.file) ||
|
|
328
|
+
a.line - b.line ||
|
|
329
|
+
SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] ||
|
|
330
|
+
a.check.localeCompare(b.check));
|
|
331
|
+
}
|
|
332
|
+
/** Markdown report: summary table on top, findings grouped per workflow file. */
|
|
333
|
+
export function renderReport(findings, opts = {}) {
|
|
334
|
+
const scanned = opts.scannedFiles ?? [...new Set(findings.map((f) => f.file))].sort();
|
|
335
|
+
const counts = { high: 0, medium: 0, low: 0 };
|
|
336
|
+
for (const f of findings)
|
|
337
|
+
counts[f.severity]++;
|
|
338
|
+
const out = [];
|
|
339
|
+
out.push("# GitHub Actions security audit");
|
|
340
|
+
out.push("");
|
|
341
|
+
out.push(`Scanned ${scanned.length} workflow file(s). Checks and remediation follow templates/actions_security_checklist.md.`);
|
|
342
|
+
out.push("");
|
|
343
|
+
out.push("## Summary");
|
|
344
|
+
out.push("");
|
|
345
|
+
out.push("| Severity | Count |");
|
|
346
|
+
out.push("|---|---|");
|
|
347
|
+
out.push(`| high | ${counts.high} |`);
|
|
348
|
+
out.push(`| medium | ${counts.medium} |`);
|
|
349
|
+
out.push(`| low | ${counts.low} |`);
|
|
350
|
+
out.push("");
|
|
351
|
+
if (findings.length === 0) {
|
|
352
|
+
out.push("No findings. All scanned workflows pass the mechanical checks.");
|
|
353
|
+
out.push("");
|
|
354
|
+
return out.join("\n");
|
|
355
|
+
}
|
|
356
|
+
const byFile = new Map();
|
|
357
|
+
for (const f of findings) {
|
|
358
|
+
const list = byFile.get(f.file) ?? [];
|
|
359
|
+
list.push(f);
|
|
360
|
+
byFile.set(f.file, list);
|
|
361
|
+
}
|
|
362
|
+
for (const [file, list] of byFile) {
|
|
363
|
+
out.push(`## ${file}`);
|
|
364
|
+
out.push("");
|
|
365
|
+
out.push("| Severity | Line | Finding | Remediation |");
|
|
366
|
+
out.push("|---|---|---|---|");
|
|
367
|
+
for (const f of list) {
|
|
368
|
+
out.push(`| ${f.severity} | ${f.line} | ${f.message} | ${f.remediation} |`);
|
|
369
|
+
}
|
|
370
|
+
out.push("");
|
|
371
|
+
}
|
|
372
|
+
return out.join("\n");
|
|
373
|
+
}
|
|
374
|
+
function listWorkflowFiles(repoRoot) {
|
|
375
|
+
const dir = join(repoRoot, ".github", "workflows");
|
|
376
|
+
if (!existsSync(dir))
|
|
377
|
+
return [];
|
|
378
|
+
const files = [];
|
|
379
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
380
|
+
if (!entry.endsWith(".yml") && !entry.endsWith(".yaml"))
|
|
381
|
+
continue;
|
|
382
|
+
files.push({
|
|
383
|
+
path: `.github/workflows/${entry}`,
|
|
384
|
+
content: readFileSync(join(dir, entry), "utf8"),
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
return files;
|
|
388
|
+
}
|
|
389
|
+
const USAGE = `Usage: node bin/audit-actions.mjs [--repo <path>] [--out <file>] [--strict]
|
|
390
|
+
|
|
391
|
+
--repo <path> repository root to scan (default ".")
|
|
392
|
+
--out <file> markdown report path (default "reports/actions-security.md")
|
|
393
|
+
--strict exit 1 when any high-severity finding exists
|
|
394
|
+
--help print this usage
|
|
395
|
+
|
|
396
|
+
Exit codes: 0 scan completed; 1 --strict with high findings; 2 repo root not found.
|
|
397
|
+
`;
|
|
398
|
+
function main() {
|
|
399
|
+
const args = argv.slice(2);
|
|
400
|
+
if (args.includes("--help")) {
|
|
401
|
+
console.log(USAGE);
|
|
402
|
+
return 0;
|
|
403
|
+
}
|
|
404
|
+
const repoIdx = args.indexOf("--repo");
|
|
405
|
+
const outIdx = args.indexOf("--out");
|
|
406
|
+
const repoRoot = repoIdx >= 0 ? (args[repoIdx + 1] ?? ".") : ".";
|
|
407
|
+
const outPath = outIdx >= 0 ? (args[outIdx + 1] ?? "reports/actions-security.md") : "reports/actions-security.md";
|
|
408
|
+
const strict = args.includes("--strict");
|
|
409
|
+
// Fail closed: a typo'd --repo (or wrong CWD) must not report a clean scan.
|
|
410
|
+
if (!existsSync(repoRoot)) {
|
|
411
|
+
console.error(`audit-actions: repo root not found: ${repoRoot}`);
|
|
412
|
+
return 2;
|
|
413
|
+
}
|
|
414
|
+
const workflowsDir = join(repoRoot, ".github", "workflows");
|
|
415
|
+
if (!existsSync(workflowsDir)) {
|
|
416
|
+
console.log(`audit-actions: no workflows directory at ${workflowsDir} — scanning zero workflow files.`);
|
|
417
|
+
}
|
|
418
|
+
const files = listWorkflowFiles(repoRoot);
|
|
419
|
+
const findings = auditWorkflows(files);
|
|
420
|
+
const report = renderReport(findings, { scannedFiles: files.map((f) => f.path) });
|
|
421
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
422
|
+
writeFileSync(outPath, report);
|
|
423
|
+
const high = findings.filter((f) => f.severity === "high").length;
|
|
424
|
+
console.log(`audit-actions: ${files.length} workflow file(s), ${findings.length} finding(s) (${high} high).`);
|
|
425
|
+
console.log(`Report written to ${outPath}`);
|
|
426
|
+
if (strict && high > 0)
|
|
427
|
+
return 1;
|
|
428
|
+
return 0;
|
|
429
|
+
}
|
|
430
|
+
const invokedDirectly = argv[1]?.endsWith("audit-actions.mjs");
|
|
431
|
+
if (invokedDirectly) {
|
|
432
|
+
exit(main());
|
|
433
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Command Count Check
|
|
4
|
+
*
|
|
5
|
+
* The README "## Slash commands" section is hand-maintained: a fenced list of
|
|
6
|
+
* `/command description` lines plus "All N commands" / "All N ship" count
|
|
7
|
+
* claims. The skill count is guarded (check-skill-count, check-skill-count-prose)
|
|
8
|
+
* but the command count was not — it silently drifted to "18" while 28 commands
|
|
9
|
+
* shipped (reconciled by hand in PR #263). This lint locks the README list and
|
|
10
|
+
* its count claims to the actual shipped command set so the drift cannot recur.
|
|
11
|
+
*
|
|
12
|
+
* Source of truth: `commands/*.md` (flat source, excluding README.md) — the set
|
|
13
|
+
* a contributor edits when adding a command. `npm run build` mirrors it into the
|
|
14
|
+
* bundle and `verify:everything-mirror` already guarantees parity.
|
|
15
|
+
*
|
|
16
|
+
* The check asserts, against the README "## Slash commands" section:
|
|
17
|
+
* 1. set parity — the fenced `/command` lines equal the commands/*.md set
|
|
18
|
+
* (reports `missing` = shipped-but-unlisted, `extra` = listed-but-unshipped)
|
|
19
|
+
* 2. count claims — every "All N commands" / "All N ship" number equals the
|
|
20
|
+
* actual command count
|
|
21
|
+
*
|
|
22
|
+
* Fail-closed: a missing section, an empty list, or a missing count claim each
|
|
23
|
+
* produce a violation rather than a silent pass.
|
|
24
|
+
*
|
|
25
|
+
* Usage:
|
|
26
|
+
* node bin/check-command-count.mjs # Check the current repo
|
|
27
|
+
* node bin/check-command-count.mjs <repo-root> # Check a specific repo root
|
|
28
|
+
*
|
|
29
|
+
* Exit codes:
|
|
30
|
+
* 0 — README lists every shipped command and every count claim matches
|
|
31
|
+
* 1 — at least one command missing/extra, or a count claim is stale/missing
|
|
32
|
+
*/
|
|
33
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import { argv, cwd, exit } from "node:process";
|
|
36
|
+
const COMMANDS_DIR = "commands";
|
|
37
|
+
const README = "README.md";
|
|
38
|
+
export function listCommandFiles(repoRoot) {
|
|
39
|
+
return readdirSync(join(repoRoot, COMMANDS_DIR))
|
|
40
|
+
.filter((f) => f.endsWith(".md") && f !== "README.md")
|
|
41
|
+
.map((f) => f.slice(0, -3))
|
|
42
|
+
.sort();
|
|
43
|
+
}
|
|
44
|
+
export function parseReadmeSlashCommands(content) {
|
|
45
|
+
// Locate the "## Slash commands" section: from its heading to the next H2 (or EOF).
|
|
46
|
+
const heading = /^##\s+Slash commands\s*$/im.exec(content);
|
|
47
|
+
if (!heading)
|
|
48
|
+
return { hasSection: false, listed: [], counts: [] };
|
|
49
|
+
const rest = content.slice(heading.index + heading[0].length);
|
|
50
|
+
const nextH2 = /^##\s+/m.exec(rest);
|
|
51
|
+
const section = nextH2 ? rest.slice(0, nextH2.index) : rest;
|
|
52
|
+
// Listed commands: lines that begin with "/<name>" at column 0 (the fenced list).
|
|
53
|
+
const listed = [...section.matchAll(/^\/([a-z][a-z0-9-]*)/gm)].map((m) => m[1]);
|
|
54
|
+
// Count claims: "All N commands" / "All N ship".
|
|
55
|
+
const counts = [...section.matchAll(/All (\d+) (?:commands|ship)/g)].map((m) => Number(m[1]));
|
|
56
|
+
return { hasSection: true, listed, counts };
|
|
57
|
+
}
|
|
58
|
+
export function findViolations(actualNames, parsed) {
|
|
59
|
+
if (!parsed.hasSection) {
|
|
60
|
+
return ["README has no '## Slash commands' section — cannot verify the command list."];
|
|
61
|
+
}
|
|
62
|
+
const violations = [];
|
|
63
|
+
if (parsed.listed.length === 0) {
|
|
64
|
+
violations.push("the '## Slash commands' section lists no commands (expected the fenced /command block).");
|
|
65
|
+
}
|
|
66
|
+
const actual = new Set(actualNames);
|
|
67
|
+
const listed = new Set(parsed.listed);
|
|
68
|
+
for (const name of actualNames) {
|
|
69
|
+
if (!listed.has(name))
|
|
70
|
+
violations.push(`missing: /${name} ships in commands/ but is not listed in the README.`);
|
|
71
|
+
}
|
|
72
|
+
for (const name of parsed.listed) {
|
|
73
|
+
if (!actual.has(name))
|
|
74
|
+
violations.push(`extra: /${name} is listed in the README but has no commands/${name}.md.`);
|
|
75
|
+
}
|
|
76
|
+
if (parsed.counts.length === 0) {
|
|
77
|
+
violations.push("no 'All N commands' count claim found in the section.");
|
|
78
|
+
}
|
|
79
|
+
for (const n of parsed.counts) {
|
|
80
|
+
if (n !== actualNames.length) {
|
|
81
|
+
violations.push(`count claim states ${n} but commands/ ships ${actualNames.length}.`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return violations;
|
|
85
|
+
}
|
|
86
|
+
function main() {
|
|
87
|
+
const repoRoot = argv[2] ?? cwd();
|
|
88
|
+
const actual = listCommandFiles(repoRoot);
|
|
89
|
+
let content;
|
|
90
|
+
try {
|
|
91
|
+
content = readFileSync(join(repoRoot, README), "utf8");
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
console.error(`FAIL command-count: cannot read ${README} at ${repoRoot}.`);
|
|
95
|
+
exit(1);
|
|
96
|
+
}
|
|
97
|
+
const parsed = parseReadmeSlashCommands(content);
|
|
98
|
+
const violations = findViolations(actual, parsed);
|
|
99
|
+
if (violations.length === 0) {
|
|
100
|
+
console.log(`OK command-count: README lists all ${actual.length} command(s) and every count claim matches commands/.`);
|
|
101
|
+
exit(0);
|
|
102
|
+
}
|
|
103
|
+
console.error(`FAIL command-count: ${violations.length} issue(s) between the README "Slash commands" section and commands/.`);
|
|
104
|
+
console.error("");
|
|
105
|
+
for (const v of violations)
|
|
106
|
+
console.error(` ${v}`);
|
|
107
|
+
console.error("");
|
|
108
|
+
console.error("Fix: update the README '## Slash commands' fenced list and its 'All N commands' / 'All N ship' counts to match commands/*.md.");
|
|
109
|
+
exit(1);
|
|
110
|
+
}
|
|
111
|
+
const invokedDirectly = argv[1] !== undefined && import.meta.url.endsWith(argv[1].replace(/\\/g, "/"));
|
|
112
|
+
if (invokedDirectly || argv[1]?.endsWith("check-command-count.mjs")) {
|
|
113
|
+
main();
|
|
114
|
+
}
|