@blamejs/exceptd-skills 0.19.33 → 0.19.35
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/CHANGELOG.md +28 -0
- package/bin/exceptd.js +895 -2828
- package/data/_indexes/_meta.json +2 -2
- package/lib/auto-discovery.js +56 -286
- package/lib/canonical-eq.js +7 -40
- package/lib/citation-resolve.js +22 -70
- package/lib/collectors/ai-api.js +170 -76
- package/lib/collectors/cicd-pipeline-compromise.js +113 -136
- package/lib/collectors/citation-hygiene.js +72 -210
- package/lib/collectors/containers.js +41 -130
- package/lib/collectors/cred-stores.js +31 -115
- package/lib/collectors/crypto-codebase.js +55 -138
- package/lib/collectors/crypto.js +24 -54
- package/lib/collectors/hardening.js +20 -78
- package/lib/collectors/kernel.js +16 -46
- package/lib/collectors/library-author.js +198 -211
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +130 -118
- package/lib/collectors/scan-excludes.js +33 -139
- package/lib/collectors/secrets.js +62 -178
- package/lib/cross-ref-api.js +39 -123
- package/lib/currency-severity.js +8 -27
- package/lib/cve-batch.js +13 -21
- package/lib/cve-cli.js +13 -20
- package/lib/cve-curation.js +72 -239
- package/lib/cve-regression-watcher.js +29 -155
- package/lib/cvss.js +13 -54
- package/lib/doctor-bucketing.js +3 -19
- package/lib/exit-codes.js +10 -42
- package/lib/flag-suggest.js +7 -25
- package/lib/framework-gap.js +39 -113
- package/lib/gap-detectors.js +37 -159
- package/lib/id-validation.js +9 -30
- package/lib/job-queue.js +13 -36
- package/lib/lint-skills.js +88 -236
- package/lib/playbook-runner.js +759 -2107
- package/lib/prefetch.js +101 -376
- package/lib/refresh-external.js +199 -633
- package/lib/refresh-network.js +78 -311
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +85 -146
- package/lib/sign.js +43 -229
- package/lib/source-advisories.js +43 -194
- package/lib/source-ghsa.js +37 -120
- package/lib/source-osv.js +94 -266
- package/lib/ttp-mapper.js +28 -27
- package/lib/upstream-check-cli.js +36 -29
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +52 -121
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +78 -286
- package/lib/validate-vendor.js +16 -49
- package/lib/verify.js +56 -286
- package/lib/version-pins.js +5 -34
- package/lib/worker-pool.js +11 -30
- package/lib/xml-tokenizer.js +47 -152
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +17 -68
- package/orchestrator/event-bus.js +11 -74
- package/orchestrator/index.js +138 -413
- package/orchestrator/pipeline.js +28 -85
- package/orchestrator/scanner.js +34 -138
- package/orchestrator/scheduler.js +20 -84
- package/package.json +1 -1
- package/sbom.cdx.json +242 -242
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +29 -28
- package/scripts/backfill-theater-test.js +7 -64
- package/scripts/bootstrap.js +12 -44
- package/scripts/build-indexes.js +40 -154
- package/scripts/builders/activity-feed.js +4 -14
- package/scripts/builders/catalog-summaries.js +3 -10
- package/scripts/builders/currency.js +7 -20
- package/scripts/builders/cwe-chains.js +7 -30
- package/scripts/builders/did-ladders.js +6 -13
- package/scripts/builders/frequency.js +5 -19
- package/scripts/builders/jurisdiction-clocks.js +6 -25
- package/scripts/builders/recipes.js +6 -14
- package/scripts/builders/section-offsets.js +13 -51
- package/scripts/builders/stale-content.js +7 -28
- package/scripts/builders/summary-cards.js +8 -29
- package/scripts/builders/theater-fingerprints.js +21 -31
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +26 -57
- package/scripts/check-catalog-gap-budget.js +15 -32
- package/scripts/check-changelog-extract.js +18 -48
- package/scripts/check-codebase-patterns-currency.js +6 -22
- package/scripts/check-codebase-patterns.js +63 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +62 -81
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +83 -198
- package/scripts/check-test-subjects.js +21 -62
- package/scripts/check-ttp-references.js +14 -38
- package/scripts/check-ttp-upstream.js +8 -40
- package/scripts/check-version-bump.js +9 -61
- package/scripts/check-version-tags.js +20 -121
- package/scripts/predeploy.js +38 -184
- package/scripts/refresh-manifest-snapshot.js +16 -38
- package/scripts/refresh-mitre-atlas.js +7 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +7 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +7 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +63 -148
- package/scripts/release.js +69 -234
- package/scripts/run-e2e-scenarios.js +26 -73
- package/scripts/sync-manifest-metadata.js +10 -34
- package/scripts/sync-package-description.js +8 -17
- package/scripts/validate-vendor-online.js +13 -44
- package/scripts/verify-shipped-tarball.js +35 -141
|
@@ -1,91 +1,85 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* infra/terraform/policies dirs for OIDC trust JSON. Flips
|
|
10
|
-
* deterministic indicators that detect the published-Action and
|
|
11
|
-
* fork-PR attack classes documented in the playbook.
|
|
12
|
-
*
|
|
13
|
-
* Skipped indicators (require GitHub API or HSM/KMS access, left
|
|
14
|
-
* unflipped so the runner returns inconclusive):
|
|
15
|
-
*
|
|
16
|
-
* self-hosted-runner-non-ephemeral needs GitHub API (runners list)
|
|
17
|
-
* runner-scoped-signing-key needs HSM/KMS inspection
|
|
18
|
-
*
|
|
19
|
-
* Interface: see lib/collectors/README.md
|
|
4
|
+
* Companion collector for the `cicd-pipeline-compromise` playbook: walks CI
|
|
5
|
+
* workflow YAML and the infra / terraform / policies dirs for OIDC trust JSON.
|
|
6
|
+
* `self-hosted-runner-non-ephemeral` and `runner-scoped-signing-key` are left
|
|
7
|
+
* unflipped (GitHub runners API / HSM inspection), so they report inconclusive.
|
|
8
|
+
* Interface: lib/collectors/README.md.
|
|
20
9
|
*/
|
|
21
10
|
|
|
22
11
|
const fs = require("node:fs");
|
|
23
12
|
const path = require("node:path");
|
|
24
13
|
const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations } = require("./scan-excludes");
|
|
25
14
|
|
|
26
|
-
// Shared code-scope
|
|
27
|
-
// agent scratch). Threaded into the OIDC-policy descent so a trust JSON in a
|
|
28
|
-
// build-output dir (e.g. `dist/`) is not scanned — consistent with the other
|
|
29
|
-
// tree-walking collectors.
|
|
15
|
+
// Shared code-scope exclusions, so a trust JSON under `dist/` is not scanned.
|
|
30
16
|
const OIDC_WALK_EXCLUDES = codeExcludeSet();
|
|
31
17
|
|
|
32
18
|
const COLLECTOR_ID = "cicd-pipeline-compromise";
|
|
33
19
|
|
|
20
|
+
// Returns { text } when the file was read, otherwise { skipped, reason }: a file
|
|
21
|
+
// that is never scanned reads as a "miss" on every indicator it would have
|
|
22
|
+
// flipped, so the caller has to record the skip rather than drop it.
|
|
34
23
|
function readSafe(p, max = 512 * 1024) {
|
|
35
24
|
let fd;
|
|
36
25
|
try {
|
|
37
26
|
fd = fs.openSync(p, "r");
|
|
38
27
|
const s = fs.fstatSync(fd);
|
|
39
|
-
if (s.size > max) return
|
|
40
|
-
// readFileSync(fd) loops read() to EOF
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return fs.readFileSync(fd, "utf8");
|
|
45
|
-
} catch { return null; }
|
|
28
|
+
if (s.size > max) return { skipped: "file_too_large", reason: `${s.size} bytes exceeds the ${max}-byte scan limit; not scanned` };
|
|
29
|
+
// readFileSync(fd) loops read() to EOF; a single readSync can return short on
|
|
30
|
+
// a network or FUSE fd. Reading the open fd keeps fstat-then-read TOCTOU-free.
|
|
31
|
+
return { text: fs.readFileSync(fd, "utf8") };
|
|
32
|
+
} catch (e) { return { skipped: "read_error", reason: (e && e.message) || String(e) }; }
|
|
46
33
|
finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } } }
|
|
47
34
|
}
|
|
48
35
|
|
|
49
|
-
function
|
|
36
|
+
function skipEntry(artifact_id, rel, r) {
|
|
37
|
+
return {
|
|
38
|
+
artifact_id,
|
|
39
|
+
kind: r.skipped === "file_too_large" ? "file_too_large_skipped" : "read_failed",
|
|
40
|
+
reason: `${rel}: ${r.reason}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function walkWorkflows(root, errors = []) {
|
|
50
45
|
const out = [];
|
|
51
46
|
const wfDir = path.join(root, ".github", "workflows");
|
|
52
47
|
if (fs.existsSync(wfDir)) {
|
|
53
48
|
let entries;
|
|
54
49
|
try { entries = fs.readdirSync(wfDir, { withFileTypes: true }); }
|
|
55
|
-
catch {
|
|
50
|
+
catch (e) {
|
|
51
|
+
entries = [];
|
|
52
|
+
errors.push({ artifact_id: "workflow-yaml-inventory", kind: "readdir_failed", reason: `.github/workflows: ${e.message}` });
|
|
53
|
+
}
|
|
56
54
|
for (const e of entries) {
|
|
57
55
|
if (!e.isFile()) continue;
|
|
58
56
|
if (!/\.(ya?ml)$/i.test(e.name)) continue;
|
|
59
57
|
const full = path.join(wfDir, e.name);
|
|
60
|
-
const
|
|
61
|
-
|
|
58
|
+
const rel = path.relative(root, full).replace(/\\/g, "/");
|
|
59
|
+
const r = readSafe(full);
|
|
60
|
+
if (r.text != null) out.push({ full, rel, content: r.text });
|
|
61
|
+
else errors.push(skipEntry("workflow-yaml-inventory", rel, r));
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
// Also recognise the most common single-file CI YAMLs at repo root.
|
|
65
65
|
for (const top of [".gitlab-ci.yml", ".circleci/config.yml"]) {
|
|
66
66
|
const full = path.join(root, top);
|
|
67
67
|
if (fs.existsSync(full)) {
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
68
|
+
const r = readSafe(full);
|
|
69
|
+
if (r.text != null) out.push({ full, rel: top, content: r.text });
|
|
70
|
+
else errors.push(skipEntry("workflow-yaml-inventory", top, r));
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
return out;
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// - scalar: `on: push`
|
|
78
|
-
// - inline list: `on: [push, pull_request_target]`
|
|
79
|
-
// - block list: `on:\n - push\n - pull_request_target`
|
|
80
|
-
// - mapping: `on:\n push:\n pull_request_target:`
|
|
81
|
-
// Heuristic accepts all four.
|
|
76
|
+
// Trigger detection across the four canonical `on:` shapes: scalar
|
|
77
|
+
// (`on: push`), inline list, block list, and mapping.
|
|
82
78
|
function workflowHasTrigger(content, name) {
|
|
83
79
|
if (new RegExp(`^\\s*on:\\s*['"]?${name}['"]?\\s*(?:#.*)?$`, "m").test(content)) return true; // allow:dynamic-regex — `name` is a hardcoded trigger literal (pull_request_target / issue_comment / pull_request), never operator/file input
|
|
84
80
|
const listMatch = content.match(/^\s*on:\s*\[([^\]]*)\]/m);
|
|
85
81
|
if (listMatch && new RegExp(`(?:^|,)\\s*['"]?${name}['"]?\\s*(?:,|$)`).test(listMatch[1])) return true; // allow:dynamic-regex — `name` is a hardcoded trigger literal, never operator/file input
|
|
86
|
-
//
|
|
87
|
-
// continuation lines. Capture the block and inspect for either
|
|
88
|
-
// `- <name>` (list) or `<name>:` (mapping) within it.
|
|
82
|
+
// Block-list and mapping forms both follow `on:\n` with indented continuations.
|
|
89
83
|
const blockMatch = content.match(/^\s*on:\s*\n((?:[ \t]+[^\n]+\n?)+)/m);
|
|
90
84
|
if (blockMatch) {
|
|
91
85
|
if (new RegExp(`^[ \\t]+-\\s+['"]?${name}['"]?\\s*(?:#.*)?\\s*$`, "m").test(blockMatch[1])) return true; // allow:dynamic-regex — `name` is a hardcoded trigger literal, never operator/file input
|
|
@@ -94,13 +88,9 @@ function workflowHasTrigger(content, name) {
|
|
|
94
88
|
return false;
|
|
95
89
|
}
|
|
96
90
|
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// indentation (or de-indented line, meaning we've left steps[]).
|
|
101
|
-
// Binding the ref match to the checkout step prevents false hits
|
|
102
|
-
// when another unrelated step references the PR head while the
|
|
103
|
-
// actual checkout is safely fetching the base ref.
|
|
91
|
+
// True when an `actions/checkout` step block carries a `ref:` naming the PR head.
|
|
92
|
+
// Binding the ref to the checkout step keeps an unrelated step's reference to the
|
|
93
|
+
// PR head from reading as a hit while the checkout fetches the base ref.
|
|
104
94
|
function checkoutBindsPrHead(content) {
|
|
105
95
|
const lines = content.split(/\r?\n/);
|
|
106
96
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -114,8 +104,7 @@ function checkoutBindsPrHead(content) {
|
|
|
114
104
|
const indentM = line.match(/^(\s*)\S/);
|
|
115
105
|
if (!indentM) continue;
|
|
116
106
|
const indent = indentM[1].length;
|
|
117
|
-
//
|
|
118
|
-
// any line de-indented past the step base ends the block.
|
|
107
|
+
// The block ends at a sibling `-` on the same indent, or any de-indent.
|
|
119
108
|
if (indent < baseIndent) { blockEnd = j; break; }
|
|
120
109
|
if (indent === baseIndent && line.trim().startsWith("- ")) { blockEnd = j; break; }
|
|
121
110
|
}
|
|
@@ -139,18 +128,12 @@ function scanWorkflow(content, rel) {
|
|
|
139
128
|
const hasPRTarget = workflowHasTrigger(content, "pull_request_target");
|
|
140
129
|
const hasIssueComment = workflowHasTrigger(content, "issue_comment");
|
|
141
130
|
|
|
142
|
-
// pull-request-target-with-pr-checkout: PRT trigger AND the
|
|
143
|
-
// PR-head ref reference is bound to an actions/checkout step
|
|
144
|
-
// (not to an unrelated step that happens to read head_ref).
|
|
145
131
|
if (hasPRTarget && checkoutBindsPrHead(content)) {
|
|
146
132
|
hits["pull-request-target-with-pr-checkout"].push({ file: rel, snippet: "pull_request_target trigger + checkout of PR head" });
|
|
147
133
|
}
|
|
148
134
|
|
|
149
|
-
// workflow-injection-sink:
|
|
150
|
-
// interpolated
|
|
151
|
-
// file-wide presence of one of the dangerous expressions AND the
|
|
152
|
-
// expression appears outside an `env:` mapping context that would
|
|
153
|
-
// have made it safe.
|
|
135
|
+
// workflow-injection-sink: an attacker-controlled `${{ github.event.* }}`
|
|
136
|
+
// interpolated inside a `run:` block, outside a safe `env:` mapping.
|
|
154
137
|
if (hasPRTarget || hasIssueComment || workflowHasTrigger(content, "pull_request")) {
|
|
155
138
|
const dangerousExprs = [
|
|
156
139
|
/\$\{\{\s*github\.event\.pull_request\.(?:title|body|head\.ref)\s*\}\}/,
|
|
@@ -162,17 +145,12 @@ function scanWorkflow(content, rel) {
|
|
|
162
145
|
const lines = content.split(/\r?\n/);
|
|
163
146
|
for (let i = 0; i < lines.length; i++) {
|
|
164
147
|
const line = lines[i];
|
|
165
|
-
// Quick filter: dangerous expression on this line?
|
|
166
148
|
const matchedExpr = dangerousExprs.find(re => re.test(line));
|
|
167
149
|
if (!matchedExpr) continue;
|
|
168
|
-
//
|
|
169
|
-
// shape on a YAML env block), the shell sees it as a variable
|
|
170
|
-
// and it's not an injection sink. Walk back up to 3 lines to
|
|
171
|
-
// find the nearest preceding YAML key indicator.
|
|
150
|
+
// Inside an `env:` mapping the shell sees a variable, not a sink.
|
|
172
151
|
const ctx = lines.slice(Math.max(0, i - 3), i + 1).join("\n");
|
|
173
152
|
const isEnvBinding = /^\s+[A-Z_][A-Z0-9_]*:\s*['"]?\$\{\{\s*github\.event/m.test(ctx);
|
|
174
|
-
//
|
|
175
|
-
// run-block; otherwise it's an `env:` binding or `with:` arg.
|
|
153
|
+
// The expression counts only inside a run-block.
|
|
176
154
|
const inRun = /^\s+run:/m.test(ctx) || /^\s+\|/m.test(ctx) || lines[i].trim().startsWith("- run:");
|
|
177
155
|
if (inRun && !isEnvBinding) {
|
|
178
156
|
hits["workflow-injection-sink"].push({ file: rel, line: i + 1, snippet: line.trim().slice(0, 160) });
|
|
@@ -181,17 +159,14 @@ function scanWorkflow(content, rel) {
|
|
|
181
159
|
}
|
|
182
160
|
}
|
|
183
161
|
|
|
184
|
-
// actions-floating-tag-pin: `uses: <owner>/<repo>@<ref>` where ref
|
|
185
|
-
//
|
|
186
|
-
// GitHub repos excluded by the playbook predicate). Excludes local
|
|
187
|
-
// composite actions (`uses: ./`).
|
|
162
|
+
// actions-floating-tag-pin: `uses: <owner>/<repo>@<ref>` where ref is not a
|
|
163
|
+
// 40-char hex SHA; owner `actions` and local composite actions are excluded.
|
|
188
164
|
const lines2 = content.split(/\r?\n/);
|
|
189
165
|
for (let i = 0; i < lines2.length; i++) {
|
|
190
|
-
// A real `uses:` line is never multiple KB
|
|
191
|
-
// crafted whitespace run
|
|
166
|
+
// A real `uses:` line is never multiple KB; skipping overlong ones keeps a
|
|
167
|
+
// crafted whitespace run from driving regex backtracking.
|
|
192
168
|
if (lines2[i].length > 4096) continue;
|
|
193
|
-
//
|
|
194
|
-
// `- ` list marker — no overlapping `\s*` runs that backtrack.
|
|
169
|
+
// Indentation is anchored once, then an optional `- ` — no overlapping `\s*`.
|
|
195
170
|
const m = lines2[i].match(/^[ \t]*(?:-[ \t]*)?uses:\s*['"]?([^'"\s#]+)['"]?/);
|
|
196
171
|
if (!m) continue;
|
|
197
172
|
const refStr = m[1];
|
|
@@ -208,15 +183,12 @@ function scanWorkflow(content, rel) {
|
|
|
208
183
|
}
|
|
209
184
|
}
|
|
210
185
|
|
|
211
|
-
// secret-exposed-to-fork-pr: pull_request_target trigger
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
// to detect fork status — left to operator evidence.
|
|
186
|
+
// secret-exposed-to-fork-pr: a pull_request_target trigger plus a `secrets.X`
|
|
187
|
+
// reference for any X other than GITHUB_TOKEN. A plain pull_request from a fork
|
|
188
|
+
// needs runtime fork status, so it stays operator evidence.
|
|
215
189
|
if (hasPRTarget) {
|
|
216
|
-
//
|
|
217
|
-
// /secrets\.GITHUB_TOKEN/
|
|
218
|
-
// as GITHUB_TOKEN_PROD, silently treating them as the built-in token and
|
|
219
|
-
// dropping a real fork-PR secret exposure (false negative).
|
|
190
|
+
// The captured secret NAME is compared exactly: an unanchored
|
|
191
|
+
// /secrets\.GITHUB_TOKEN/ also matches a custom GITHUB_TOKEN_PROD.
|
|
220
192
|
const nonDefault = [];
|
|
221
193
|
for (const m of content.matchAll(/\$\{\{\s*secrets\.([A-Z_][A-Z0-9_]*)\s*\}\}/g)) {
|
|
222
194
|
if (m[1] !== "GITHUB_TOKEN") nonDefault.push(m[0]);
|
|
@@ -229,46 +201,44 @@ function scanWorkflow(content, rel) {
|
|
|
229
201
|
return hits;
|
|
230
202
|
}
|
|
231
203
|
|
|
232
|
-
function scanOidcPolicies(root) {
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
// sub-claim. The playbook lists `repo:<org>/*:*`, `repo:*:*`, and
|
|
236
|
-
// bare `*` as wildcard shapes.
|
|
204
|
+
function scanOidcPolicies(root, errors = []) {
|
|
205
|
+
// Walks the infra dirs to depth 4 for *.json naming
|
|
206
|
+
// token.actions.githubusercontent.com with a wildcarded sub-claim.
|
|
237
207
|
const rootDirs = ["infra", "terraform", "policies", ".aws", ".github"].map(d => path.join(root, d));
|
|
238
208
|
const finds = [];
|
|
239
209
|
function walk(dir, depth) {
|
|
240
210
|
if (depth > 4 || finds.length > 5) return;
|
|
241
211
|
let entries;
|
|
242
212
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
243
|
-
catch {
|
|
213
|
+
catch (e) {
|
|
214
|
+
errors.push({
|
|
215
|
+
artifact_id: "oidc-trust-policy-inventory",
|
|
216
|
+
kind: "readdir_failed",
|
|
217
|
+
reason: `${path.relative(root, dir).replace(/\\/g, "/")}: ${e.message}`,
|
|
218
|
+
});
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
244
221
|
for (const e of entries) {
|
|
245
222
|
if (OIDC_WALK_EXCLUDES.has(e.name)) continue;
|
|
246
223
|
const full = path.join(dir, e.name);
|
|
247
224
|
if (e.isDirectory()) {
|
|
248
|
-
//
|
|
249
|
-
// agent-created repo copies under `.claude/worktrees/<id>/`
|
|
250
|
-
// nested below a scanned policy/infra dir — rescanning them
|
|
251
|
-
// double-counts the same OIDC trust documents.
|
|
225
|
+
// A linked worktree holds the same trust documents; rescanning double-counts.
|
|
252
226
|
if (isLinkedWorktreeDir(full)) continue;
|
|
253
227
|
walk(full, depth + 1);
|
|
254
228
|
continue;
|
|
255
229
|
}
|
|
256
230
|
if (!e.isFile() || !/\.json$/i.test(e.name)) continue;
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
// pre-filter on the issuer host: it added nothing the anchored patterns
|
|
264
|
-
// don't already enforce, and a bare host-substring check reads as an
|
|
265
|
-
// incomplete URL sanitization.
|
|
266
|
-
// sub-claim wildcards. Cover the three shapes the playbook lists.
|
|
231
|
+
const rel = path.relative(root, full).replace(/\\/g, "/");
|
|
232
|
+
const r = readSafe(full);
|
|
233
|
+
if (r.text == null) { errors.push(skipEntry("oidc-trust-policy-inventory", rel, r)); continue; }
|
|
234
|
+
const text = r.text;
|
|
235
|
+
// Each pattern is bound to the leading `"` of the JSON key, so a lookalike
|
|
236
|
+
// issuer like `"eviltoken.actions…"` cannot match.
|
|
267
237
|
const subWildcard =
|
|
268
238
|
/"token\.actions\.githubusercontent\.com:sub"\s*:\s*"\*"/.test(text) ||
|
|
269
239
|
/"token\.actions\.githubusercontent\.com:sub"\s*:\s*"repo:\*[^"]*"/.test(text) ||
|
|
270
240
|
/"token\.actions\.githubusercontent\.com:sub"\s*:\s*"repo:[^"]*\/\*:[^"]*"/.test(text);
|
|
271
|
-
if (subWildcard) finds.push({ file:
|
|
241
|
+
if (subWildcard) finds.push({ file: rel, snippet: "OIDC sub-claim wildcarded across repos or branches" });
|
|
272
242
|
}
|
|
273
243
|
}
|
|
274
244
|
for (const rd of rootDirs) if (fs.existsSync(rd)) walk(rd, 0);
|
|
@@ -280,8 +250,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
280
250
|
const startTime = Date.now();
|
|
281
251
|
const root = path.resolve(cwd);
|
|
282
252
|
|
|
283
|
-
//
|
|
284
|
-
// repo we have no workflows / no OIDC trust JSON to walk.
|
|
253
|
+
// Outside a repo there are no workflows and no OIDC trust JSON to walk.
|
|
285
254
|
const cwdIsRepo = fs.existsSync(path.join(root, ".git"));
|
|
286
255
|
if (!cwdIsRepo) {
|
|
287
256
|
return {
|
|
@@ -302,7 +271,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
302
271
|
};
|
|
303
272
|
}
|
|
304
273
|
|
|
305
|
-
const workflows = walkWorkflows(root);
|
|
274
|
+
const workflows = walkWorkflows(root, errors);
|
|
306
275
|
const aggregateHits = {
|
|
307
276
|
"workflow-injection-sink": [],
|
|
308
277
|
"pull-request-target-with-pr-checkout": [],
|
|
@@ -314,21 +283,29 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
314
283
|
for (const [k, v] of Object.entries(h)) aggregateHits[k].push(...v);
|
|
315
284
|
}
|
|
316
285
|
|
|
317
|
-
const oidcWildcards = scanOidcPolicies(root);
|
|
286
|
+
const oidcWildcards = scanOidcPolicies(root, errors);
|
|
287
|
+
|
|
288
|
+
// A workflow this collector could not read may hold the very thing each
|
|
289
|
+
// indicator looks for, so `miss` over an incomplete inventory is a clean
|
|
290
|
+
// verdict on evidence that was never examined. collector_errors are advisory
|
|
291
|
+
// and change no verdict on their own; the gap has to reach the signal. A hit
|
|
292
|
+
// stands on what WAS read and is unaffected.
|
|
293
|
+
const workflowScanIncomplete = errors.some(
|
|
294
|
+
(e) => e.artifact_id === "workflow-yaml-inventory");
|
|
295
|
+
const oidcScanIncomplete = errors.some(
|
|
296
|
+
(e) => e.artifact_id === "oidc-trust-policy-inventory");
|
|
297
|
+
const verdict = (found, incomplete) => (found ? "hit" : incomplete ? "inconclusive" : "miss");
|
|
318
298
|
|
|
319
299
|
const signal_overrides = {
|
|
320
|
-
"workflow-injection-sink": aggregateHits["workflow-injection-sink"].length > 0
|
|
321
|
-
"pull-request-target-with-pr-checkout": aggregateHits["pull-request-target-with-pr-checkout"].length > 0
|
|
322
|
-
"actions-floating-tag-pin": aggregateHits["actions-floating-tag-pin"].length > 0
|
|
323
|
-
"secret-exposed-to-fork-pr": aggregateHits["secret-exposed-to-fork-pr"].length > 0
|
|
324
|
-
"wildcarded-oidc-sub-claim": oidcWildcards.length > 0
|
|
300
|
+
"workflow-injection-sink": verdict(aggregateHits["workflow-injection-sink"].length > 0, workflowScanIncomplete),
|
|
301
|
+
"pull-request-target-with-pr-checkout": verdict(aggregateHits["pull-request-target-with-pr-checkout"].length > 0, workflowScanIncomplete),
|
|
302
|
+
"actions-floating-tag-pin": verdict(aggregateHits["actions-floating-tag-pin"].length > 0, workflowScanIncomplete),
|
|
303
|
+
"secret-exposed-to-fork-pr": verdict(aggregateHits["secret-exposed-to-fork-pr"].length > 0, workflowScanIncomplete),
|
|
304
|
+
"wildcarded-oidc-sub-claim": verdict(oidcWildcards.length > 0, oidcScanIncomplete),
|
|
325
305
|
};
|
|
326
306
|
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
// triggered it. Line-scanned indicators (workflow-injection-sink,
|
|
330
|
-
// actions-floating-tag-pin) carry a real line; the trigger-shape and
|
|
331
|
-
// OIDC-wildcard indicators record the file only and surface as file-level.
|
|
307
|
+
// File locations for every indicator flipped to "hit", so a SARIF result points
|
|
308
|
+
// at the YAML or trust JSON behind it; without a line it surfaces as file-level.
|
|
332
309
|
const evidence_locations = {};
|
|
333
310
|
const evidenceSources = { ...aggregateHits, "wildcarded-oidc-sub-claim": oidcWildcards };
|
|
334
311
|
for (const [id, list] of Object.entries(evidenceSources)) {
|
|
@@ -340,14 +317,20 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
340
317
|
|
|
341
318
|
const artifacts = {
|
|
342
319
|
"workflow-yaml-inventory": {
|
|
343
|
-
value:
|
|
344
|
-
|
|
320
|
+
value: workflowScanIncomplete
|
|
321
|
+
? `${workflows.length} workflow(s) read, at least one skipped — inventory incomplete, see collector_errors`
|
|
322
|
+
: workflows.length ? workflows.map(w => w.rel).join(", ") : "no workflow files found at cwd",
|
|
323
|
+
captured: !workflowScanIncomplete,
|
|
324
|
+
...(workflowScanIncomplete ? { reason: "one or more workflow files or the workflow directory could not be read" } : {}),
|
|
345
325
|
},
|
|
346
326
|
"oidc-trust-policy-inventory": {
|
|
347
|
-
value:
|
|
348
|
-
? `${oidcWildcards.length} wildcarded sub-claim(s)
|
|
349
|
-
:
|
|
350
|
-
|
|
327
|
+
value: oidcScanIncomplete
|
|
328
|
+
? `${oidcWildcards.length} wildcarded sub-claim(s) found, at least one policy file skipped — inventory incomplete, see collector_errors`
|
|
329
|
+
: oidcWildcards.length
|
|
330
|
+
? `${oidcWildcards.length} wildcarded sub-claim(s): ${oidcWildcards.map(f => f.file).join(", ")}`
|
|
331
|
+
: "no wildcarded OIDC sub-claim found in infra / terraform / policies",
|
|
332
|
+
captured: !oidcScanIncomplete,
|
|
333
|
+
...(oidcScanIncomplete ? { reason: "one or more trust-policy files or directories could not be read" } : {}),
|
|
351
334
|
},
|
|
352
335
|
"actions-sha-pinning": {
|
|
353
336
|
value: `${aggregateHits["actions-floating-tag-pin"].length} non-SHA third-party uses: reference(s) across ${workflows.length} workflow(s)`,
|
|
@@ -374,20 +357,14 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
374
357
|
};
|
|
375
358
|
|
|
376
359
|
return {
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
// operator-owns-ci-fleet — REQUIRES explicit operator opt-in via
|
|
381
|
-
// `--attest-ownership` (or args.attestOwnership === true). The
|
|
382
|
-
// playbook gates this `on_fail: halt`; running collect against
|
|
383
|
-
// any cwd (e.g. `--cwd /other/repo`) does NOT implicitly attest
|
|
384
|
-
// ownership of that fleet's CI authorization scope. Operators
|
|
385
|
-
// who own the CI fleet they're auditing pass the flag; running
|
|
386
|
-
// collect | run without the flag halts at the runner's
|
|
387
|
-
// preflight gate (as the playbook intends).
|
|
360
|
+
// operator-owns-ci-fleet requires explicit opt-in through `--attest-ownership`:
|
|
361
|
+
// pointing collect at any cwd does NOT attest ownership of that fleet's CI
|
|
362
|
+
// authorization scope, and the playbook gates the precondition `on_fail: halt`.
|
|
388
363
|
precondition_checks: {
|
|
389
364
|
"cwd-is-repo": true,
|
|
390
|
-
|
|
365
|
+
// An attestation that the CI configuration was readable, so it cannot
|
|
366
|
+
// stay true over a file this collector failed to open.
|
|
367
|
+
"ci-config-readable": !workflowScanIncomplete,
|
|
391
368
|
"operator-owns-ci-fleet": args.attestOwnership === true || args["attest-ownership"] === true,
|
|
392
369
|
},
|
|
393
370
|
artifacts,
|