@blamejs/exceptd-skills 0.19.33 → 0.19.34
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 +10 -0
- package/bin/exceptd.js +896 -2824
- 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 +20 -54
- package/lib/collectors/cicd-pipeline-compromise.js +40 -108
- 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 +57 -206
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +34 -106
- package/lib/collectors/scan-excludes.js +31 -138
- 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 -152
- 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 +35 -114
- 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 +64 -232
- package/lib/playbook-runner.js +693 -2095
- package/lib/prefetch.js +100 -376
- package/lib/refresh-external.js +199 -627
- package/lib/refresh-network.js +75 -307
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +77 -145
- 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 +14 -24
- package/lib/upstream-check-cli.js +10 -28
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +43 -119
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +69 -275
- 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 -412
- 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 +241 -241
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +6 -16
- 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 +12 -27
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +11 -54
- 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 +50 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +13 -73
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +66 -197
- 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 +3 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +3 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +2 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +40 -137
- package/scripts/release.js +69 -232
- package/scripts/run-e2e-scenarios.js +24 -71
- 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 -140
package/lib/citation-resolve.js
CHANGED
|
@@ -1,24 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* CVE: local catalog -> resolved cache -> (opt-in) one NVD lookup, cached.
|
|
11
|
-
* RFC: local index -> resolved cache -> (opt-in) one datatracker lookup.
|
|
12
|
-
*
|
|
13
|
-
* The resolved cache lives at .cache/upstream/resolved/<kind>/<id>.json with a
|
|
14
|
-
* 7-day TTL. The FIRST agent to resolve an uncatalogued id pays one network
|
|
15
|
-
* call and writes the cache; sibling agents (and later offline runs) read it —
|
|
16
|
-
* turning N agents x M citations of redundant lookups into one lookup per id.
|
|
17
|
-
*
|
|
18
|
-
* Network is opt-out: --air-gap / EXCEPTD_AIR_GAP=1 / { noNetwork:true } make
|
|
19
|
-
* resolution offline-only (catalog + cache), returning status "unknown" with a
|
|
20
|
-
* reason rather than reaching out. Network-resolved records are transient
|
|
21
|
-
* (cache only) and are never written into the signed catalog.
|
|
4
|
+
* Answers "is this CVE/RFC citation valid?" offline-first: local catalog or
|
|
5
|
+
* index, then the resolved cache, then one opt-in network lookup. --air-gap /
|
|
6
|
+
* EXCEPTD_AIR_GAP=1 / { noNetwork:true } make it offline-only, returning status
|
|
7
|
+
* "unknown". Network-resolved records live in the cache only and never enter
|
|
8
|
+
* the signed catalog.
|
|
22
9
|
*/
|
|
23
10
|
|
|
24
11
|
const fs = require("node:fs");
|
|
@@ -45,26 +32,16 @@ function rfcIndex() {
|
|
|
45
32
|
return _rfc;
|
|
46
33
|
}
|
|
47
34
|
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
// attestations), so a record is only trusted if it carries a matching content
|
|
52
|
-
// digest AND its own `resolved_at` is within the freshness window. A file an
|
|
53
|
-
// attacker (or a corrupt/half-written process) edits in place without
|
|
54
|
-
// recomputing `_digest` is rejected as a cache-miss — it can never launder a
|
|
55
|
-
// rejected/fabricated citation into "published". This is the resolved-cache
|
|
56
|
-
// analogue of the prefetch cache's sha256+signature model; full maintainer
|
|
57
|
-
// signing isn't possible operator-side (no private key), so the digest binds
|
|
58
|
-
// the record to itself and makes tampering detectable.
|
|
35
|
+
// The resolved-id cache feeds security verdicts, so a record is trusted only when
|
|
36
|
+
// its `_digest` matches and its own `resolved_at` is inside the freshness window —
|
|
37
|
+
// an in-place edit cannot launder a rejected citation into "published".
|
|
59
38
|
function cachePath(kind, id) {
|
|
60
39
|
// Read the env at call time so tests can isolate the cache per-case.
|
|
61
40
|
const dir = process.env.EXCEPTD_RESOLVE_CACHE_DIR || RESOLVE_CACHE_DIR;
|
|
62
41
|
const safe = id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
63
42
|
return path.join(dir, kind, `${safe}.json`);
|
|
64
43
|
}
|
|
65
|
-
// sha256 over
|
|
66
|
-
// `resolved_at` IS covered, so the staleness clock can't be rewritten apart
|
|
67
|
-
// from the verdict.
|
|
44
|
+
// sha256 over canonical bytes (sorted keys, `_digest` excluded); covers `resolved_at`.
|
|
68
45
|
function recordDigest(record) {
|
|
69
46
|
const canon = {};
|
|
70
47
|
for (const k of Object.keys(record).sort()) {
|
|
@@ -78,20 +55,14 @@ function cacheGet(kind, id) {
|
|
|
78
55
|
const p = cachePath(kind, id);
|
|
79
56
|
const record = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
80
57
|
if (!record || typeof record !== "object") return null;
|
|
81
|
-
// Integrity: a record without a matching digest is tampered/corrupt → miss.
|
|
82
58
|
if (typeof record._digest !== "string" || record._digest !== recordDigest(record)) return null;
|
|
83
|
-
//
|
|
84
|
-
// `touch` can reset). Reject future-dated records as a poisoning signal,
|
|
85
|
-
// mirroring the prefetch cache's future-date guard.
|
|
59
|
+
// resolved_at, not file mtime which `touch` resets; a future-dated record is poisoning.
|
|
86
60
|
const ts = Date.parse(record.resolved_at || "");
|
|
87
61
|
if (!Number.isFinite(ts)) return null;
|
|
88
62
|
const age = Date.now() - ts;
|
|
89
63
|
if (age < -60_000 || age > CACHE_TTL_MS) return null;
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
// record written under one filename but carrying a different internal
|
|
93
|
-
// id/kind would otherwise be served for the wrong lookup (a swapped-file
|
|
94
|
-
// poisoning that the self-digest cannot catch). Mismatch → cache miss.
|
|
64
|
+
// The digest proves self-consistency, not that this is the record FOR this
|
|
65
|
+
// id/kind — a valid record filed under another name must not be served.
|
|
95
66
|
if (record.kind !== kind) return null;
|
|
96
67
|
if (kind === "cve") {
|
|
97
68
|
if (typeof record.id !== "string" || record.id.toUpperCase() !== String(id).toUpperCase()) return null;
|
|
@@ -108,9 +79,7 @@ function cachePut(kind, id, record) {
|
|
|
108
79
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
109
80
|
const signed = { ...record };
|
|
110
81
|
signed._digest = recordDigest(signed);
|
|
111
|
-
// Random suffix
|
|
112
|
-
// process — a Promise.all fan-out or worker threads sharing a pid — don't
|
|
113
|
-
// race the same tmp path. Matches lib/prefetch.js writeFileAtomic.
|
|
82
|
+
// Random suffix, not just pid: an in-process fan-out would race the same tmp path.
|
|
114
83
|
const tmp = `${p}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
115
84
|
fs.writeFileSync(tmp, JSON.stringify(signed));
|
|
116
85
|
fs.renameSync(tmp, p); // atomic — concurrent readers never see a half-written file
|
|
@@ -127,9 +96,7 @@ function isAirGap(opts) {
|
|
|
127
96
|
* from: format | catalog | cache | network | offline | error
|
|
128
97
|
*/
|
|
129
98
|
async function resolveCve(id, opts = {}) {
|
|
130
|
-
// Trim before the format test
|
|
131
|
-
// identifier is "fabricated/malformed" (empty form) rather than a literal
|
|
132
|
-
// whitespace string fed straight into CVE_RE.
|
|
99
|
+
// Trim before the format test so a whitespace-only id is malformed, not fed to CVE_RE.
|
|
133
100
|
const cveId = String(id || "").trim().toUpperCase();
|
|
134
101
|
const base = { id: cveId, kind: "cve" };
|
|
135
102
|
|
|
@@ -153,10 +120,7 @@ async function resolveCve(id, opts = {}) {
|
|
|
153
120
|
};
|
|
154
121
|
}
|
|
155
122
|
|
|
156
|
-
// 1b. alias lookup —
|
|
157
|
-
// (e.g. a CVE for a sub-incident folded into a campaign-level MAL-* key).
|
|
158
|
-
// Catalogued-by-alias must resolve offline too, or `exceptd cve <alias>`
|
|
159
|
-
// would report unknown for an incident the catalog actually covers.
|
|
123
|
+
// 1b. alias lookup — a catalogued-by-alias id must resolve offline too.
|
|
160
124
|
for (const k of Object.keys(catalog)) {
|
|
161
125
|
if (k === "_meta") continue;
|
|
162
126
|
const e = catalog[k];
|
|
@@ -178,7 +142,6 @@ async function resolveCve(id, opts = {}) {
|
|
|
178
142
|
const cached = cacheGet("cve", cveId);
|
|
179
143
|
if (cached) return { ...cached, from: "cache" };
|
|
180
144
|
|
|
181
|
-
// 3. offline / air-gap: cannot resolve uncatalogued ids without network
|
|
182
145
|
if (isAirGap(opts)) {
|
|
183
146
|
return { ...base, status: "unknown", from: "offline",
|
|
184
147
|
reason: "air-gap: not in local catalog and no cached resolution — verify against NVD when online" };
|
|
@@ -188,9 +151,7 @@ async function resolveCve(id, opts = {}) {
|
|
|
188
151
|
reason: "not in local catalog and no cached resolution (network disabled)" };
|
|
189
152
|
}
|
|
190
153
|
|
|
191
|
-
//
|
|
192
|
-
// opts._validateCve is a test seam (inject a fake validator); production uses
|
|
193
|
-
// the real NVD-backed validator.
|
|
154
|
+
// Resolve once via NVD, then cache. opts._validateCve is a test seam.
|
|
194
155
|
let validateCve = opts._validateCve;
|
|
195
156
|
if (!validateCve) {
|
|
196
157
|
try { ({ validateCve } = require("../sources/validators/cve-validator.js")); }
|
|
@@ -203,11 +164,8 @@ async function resolveCve(id, opts = {}) {
|
|
|
203
164
|
if (v.status === "unreachable") {
|
|
204
165
|
return { ...base, status: "unknown", from: "offline", reason: "NVD unreachable — retry online" };
|
|
205
166
|
}
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
// answer, it returns match/drift with sources.nvd.reachable === false. Do NOT
|
|
209
|
-
// declare "published" on KEV/EPSS alone during an NVD outage; that would
|
|
210
|
-
// falsely validate an unconfirmed (or nonexistent) identifier.
|
|
167
|
+
// "unreachable" means EVERY source failed, so an NVD outage with KEV/EPSS still
|
|
168
|
+
// answering slips through. NVD is the existence authority: never publish on KEV alone.
|
|
211
169
|
const nvd = v.fetched && v.fetched.sources && v.fetched.sources.nvd;
|
|
212
170
|
if (!nvd || nvd.reachable !== true) {
|
|
213
171
|
return { ...base, status: "unknown", from: "offline",
|
|
@@ -223,9 +181,7 @@ async function resolveCve(id, opts = {}) {
|
|
|
223
181
|
id: cveId, kind: "cve", status,
|
|
224
182
|
cvss: v.fetched?.cvss_score ?? null,
|
|
225
183
|
kev: v.fetched?.in_kev ?? null,
|
|
226
|
-
// NVD
|
|
227
|
-
// so an agent can confirm status=published applies to the right product
|
|
228
|
-
// without a second manual NVD lookup.
|
|
184
|
+
// The NVD description carries the product/scope a citation must match.
|
|
229
185
|
product: v.fetched?.description ?? null,
|
|
230
186
|
nvd_vuln_status: v.fetched?.nvd_vuln_status ?? null,
|
|
231
187
|
cve_tags: v.fetched?.cve_tags || [],
|
|
@@ -238,11 +194,8 @@ async function resolveCve(id, opts = {}) {
|
|
|
238
194
|
|
|
239
195
|
/**
|
|
240
196
|
* Resolve an RFC citation. Returns { id, kind:"rfc", number, title, rfc_status,
|
|
241
|
-
* found, from, ... }.
|
|
242
|
-
*
|
|
243
|
-
* number->title resolution, including "is this RFC superseded?", is fully
|
|
244
|
-
* offline. A number absent from the index is almost certainly nonexistent (or
|
|
245
|
-
* an UNKNOWN-status placeholder); the optional network step confirms.
|
|
197
|
+
* found, from, ... }. A number absent from the local index is almost certainly
|
|
198
|
+
* nonexistent; the optional network step confirms.
|
|
246
199
|
*/
|
|
247
200
|
async function resolveRfc(id, opts = {}) {
|
|
248
201
|
const raw = String(id || "").trim();
|
|
@@ -255,7 +208,7 @@ async function resolveRfc(id, opts = {}) {
|
|
|
255
208
|
const num = Number(m[1]);
|
|
256
209
|
const key = `RFC-${num}`;
|
|
257
210
|
|
|
258
|
-
// 1. local index (offline
|
|
211
|
+
// 1. local index (offline)
|
|
259
212
|
const entry = rfcIndex()[key];
|
|
260
213
|
if (entry && typeof entry === "object") {
|
|
261
214
|
return {
|
|
@@ -268,7 +221,6 @@ async function resolveRfc(id, opts = {}) {
|
|
|
268
221
|
};
|
|
269
222
|
}
|
|
270
223
|
|
|
271
|
-
// 2. resolved cache
|
|
272
224
|
const cached = cacheGet("rfc", String(num));
|
|
273
225
|
if (cached) return { ...cached, from: "cache" };
|
|
274
226
|
|
package/lib/collectors/ai-api.js
CHANGED
|
@@ -1,21 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Companion collector for the `ai-api` playbook. Scans shell rc
|
|
7
|
-
* files for cleartext AI API key exports, plus the standard
|
|
8
|
-
* credential carriers (~/.aws, ~/.kube, ~/.config/gcloud) for
|
|
4
|
+
* Companion collector for the `ai-api` playbook: scans shell rc files for
|
|
5
|
+
* cleartext AI API key exports, plus ~/.aws, ~/.kube and ~/.config/gcloud for
|
|
9
6
|
* long-lived credentials likely to authenticate against AI APIs.
|
|
10
|
-
*
|
|
11
|
-
* Non-deterministic indicators (ai-api-egress-from-unexpected-
|
|
12
|
-
* process, ai-api-anomalous-volume, ai-api-beaconing-cadence,
|
|
13
|
-
* base64-or-encoded-payload-in-prompts) require ss/netstat/auditd
|
|
14
|
-
* traces and process-list correlation that fall outside the
|
|
15
|
-
* stdlib-only collector contract. They stay unflipped — the runner
|
|
16
|
-
* returns inconclusive and operator-supplied evidence completes
|
|
17
|
-
* the verdict.
|
|
18
|
-
*
|
|
19
7
|
* Interface: see lib/collectors/README.md
|
|
20
8
|
*/
|
|
21
9
|
|
|
@@ -31,10 +19,8 @@ function readSafe(full, max = 256 * 1024) {
|
|
|
31
19
|
fd = fs.openSync(full, "r");
|
|
32
20
|
const s = fs.fstatSync(fd);
|
|
33
21
|
if (s.size > max) return null;
|
|
34
|
-
// readFileSync(fd) loops
|
|
35
|
-
//
|
|
36
|
-
// leave the buffer tail NUL-filled and silently drop trailing content.
|
|
37
|
-
// Reading via the already-open fd keeps the fstat-then-read TOCTOU-free.
|
|
22
|
+
// readFileSync(fd) loops to EOF; a single readSync can return short on a
|
|
23
|
+
// network or FUSE fd. Reading the open fd keeps fstat-then-read TOCTOU-free.
|
|
38
24
|
return fs.readFileSync(fd, "utf8");
|
|
39
25
|
} catch { return null; }
|
|
40
26
|
finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } } }
|
|
@@ -44,9 +30,7 @@ function fileExists(full) {
|
|
|
44
30
|
try { return fs.statSync(full).isFile(); } catch { return false; }
|
|
45
31
|
}
|
|
46
32
|
|
|
47
|
-
// Cleartext
|
|
48
|
-
// `export VAR=value` and `VAR=value` shell shapes plus fish-style
|
|
49
|
-
// `set -gx VAR value`.
|
|
33
|
+
// Cleartext key exports: `export VAR=value`, `VAR=value`, fish `set -gx VAR value`.
|
|
50
34
|
const AI_KEY_PATTERNS = [
|
|
51
35
|
{ id: "openai", re: /(?:^|\n)\s*(?:export\s+|set\s+-gx\s+)?OPENAI_API_KEY\s*[= ]\s*['"]?sk-[A-Za-z0-9_-]{20,}/m },
|
|
52
36
|
{ id: "anthropic", re: /(?:^|\n)\s*(?:export\s+|set\s+-gx\s+)?ANTHROPIC_API_KEY\s*[= ]\s*['"]?sk-ant-[A-Za-z0-9_-]{20,}/m },
|
|
@@ -56,20 +40,15 @@ const AI_KEY_PATTERNS = [
|
|
|
56
40
|
{ id: "cohere", re: /(?:^|\n)\s*(?:export\s+|set\s+-gx\s+)?COHERE_API_KEY\s*[= ]\s*['"]?[A-Za-z0-9-]{30,}/m },
|
|
57
41
|
];
|
|
58
42
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// patterns above end at the prefix; widen to grab the trailing token.
|
|
43
|
+
// The patterns above end at the prefix; these capture the exported value so
|
|
44
|
+
// the placeholder and entropy-floor FP checks have something to evaluate.
|
|
62
45
|
const AI_KEY_VALUE_RE = {
|
|
63
46
|
openai: /OPENAI_API_KEY\s*[= ]\s*['"]?(sk-[A-Za-z0-9_-]+)/,
|
|
64
47
|
anthropic: /ANTHROPIC_API_KEY\s*[= ]\s*['"]?(sk-ant-[A-Za-z0-9_-]+)/,
|
|
65
48
|
huggingface: /(?:HUGGINGFACE_TOKEN|HF_TOKEN)\s*[= ]\s*['"]?(hf_[A-Za-z0-9]+)/,
|
|
66
|
-
// Azure
|
|
67
|
-
// the entropy body.
|
|
68
|
-
//
|
|
69
|
-
// runner downgraded a real cleartext-key hit to inconclusive — the indicator
|
|
70
|
-
// fired then vanished for half the supported vendors. The 30-char entropy
|
|
71
|
-
// floor (the `: 30` else-branch below) and PLACEHOLDER_RE both apply, since
|
|
72
|
-
// the prefix-strip leaves these values unchanged.
|
|
49
|
+
// Azure, Google and Cohere keys carry no vendor prefix, so the captured value
|
|
50
|
+
// IS the entropy body. Drop one of these and cleartextFpIndices attests nothing
|
|
51
|
+
// for such a dotfile, downgrading a real hit to inconclusive.
|
|
73
52
|
azure: /AZURE_OPENAI(?:_API)?_KEY\s*[= ]\s*['"]?([A-Za-z0-9]{20,})/,
|
|
74
53
|
google: /(?:GOOGLE_API_KEY|GOOGLE_GENAI_API_KEY|GEMINI_API_KEY)\s*[= ]\s*['"]?([A-Za-z0-9_-]{20,})/,
|
|
75
54
|
cohere: /COHERE_API_KEY\s*[= ]\s*['"]?([A-Za-z0-9-]{30,})/,
|
|
@@ -85,12 +64,9 @@ function scanShellRc(content) {
|
|
|
85
64
|
return hits;
|
|
86
65
|
}
|
|
87
66
|
|
|
88
|
-
//
|
|
89
|
-
// cleartext-api-key-in-dotfile
|
|
90
|
-
//
|
|
91
|
-
// only attested if every export satisfies it). Canonical home rc / dotfile
|
|
92
|
-
// paths are never under examples/tests/fixtures, so the path check [1] is
|
|
93
|
-
// always satisfied here.
|
|
67
|
+
// The satisfiable false_positive_checks_required indices for
|
|
68
|
+
// cleartext-api-key-in-dotfile, intersected across every export found. Canonical
|
|
69
|
+
// home dotfiles are never under examples/tests/fixtures, so [1] always holds.
|
|
94
70
|
function cleartextFpIndices(content) {
|
|
95
71
|
const sat = new Set(["0", "1", "2"]);
|
|
96
72
|
let sawAny = false;
|
|
@@ -101,8 +77,7 @@ function cleartextFpIndices(content) {
|
|
|
101
77
|
const value = m[1];
|
|
102
78
|
// [0] not a documented placeholder / sk-test- fixture
|
|
103
79
|
if (PLACEHOLDER_RE.test(value)) sat.delete("0");
|
|
104
|
-
// [2] entropy floor: OpenAI
|
|
105
|
-
// HuggingFace hf_* >= 30.
|
|
80
|
+
// [2] entropy floor, post-prefix: OpenAI 48, Anthropic 40, others 30.
|
|
106
81
|
const floor = vendor === "openai" ? 48 : vendor === "anthropic" ? 40 : 30;
|
|
107
82
|
const body = value.replace(/^sk-ant-(?:api03|admin01)-|^sk-(?:proj-|svcacct-|admin-)?|^hf_/, "");
|
|
108
83
|
if (body.length < floor) sat.delete("2");
|
|
@@ -128,9 +103,8 @@ function parseAwsCredentials(content) {
|
|
|
128
103
|
const staticProfiles = [];
|
|
129
104
|
const accessKeyIds = [];
|
|
130
105
|
for (const [name, kv] of Object.entries(profiles)) {
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
// session token; IAM-user long-lived keys do not).
|
|
106
|
+
// Long-lived means an access key id with no session-token sibling: STS
|
|
107
|
+
// temporary credentials always carry one, IAM-user keys never do.
|
|
134
108
|
if (kv["aws_access_key_id"] && !kv["aws_session_token"]) {
|
|
135
109
|
staticProfiles.push(name);
|
|
136
110
|
accessKeyIds.push(kv["aws_access_key_id"]);
|
|
@@ -187,12 +161,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
187
161
|
const root = path.resolve(cwd);
|
|
188
162
|
const home = (env && env.HOME) || (env && env.USERPROFILE) || os.homedir();
|
|
189
163
|
|
|
190
|
-
// Shell rc + dotfile candidates.
|
|
191
164
|
const shellRcs = [
|
|
192
165
|
".bashrc", ".bash_profile", ".zshrc", ".zprofile", ".profile",
|
|
193
166
|
path.join(".config", "fish", "config.fish"),
|
|
194
167
|
].map(rel => path.join(home, rel));
|
|
195
|
-
// Glob fish/conf.d/*.
|
|
196
168
|
try {
|
|
197
169
|
const fishConfD = path.join(home, ".config", "fish", "conf.d");
|
|
198
170
|
for (const e of fs.readdirSync(fishConfD)) {
|
|
@@ -227,7 +199,6 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
227
199
|
}
|
|
228
200
|
const cleartextAnyHit = Object.keys(cleartextHitsByFile).length > 0;
|
|
229
201
|
|
|
230
|
-
// AWS / GCP / kube reuse.
|
|
231
202
|
const awsCredsPath = path.join(home, ".aws", "credentials");
|
|
232
203
|
const awsCredsContent = fileExists(awsCredsPath) ? readSafe(awsCredsPath) : null;
|
|
233
204
|
const awsParsed = parseAwsCredentials(awsCredsContent);
|
|
@@ -249,13 +220,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
249
220
|
"kubeconfig-with-static-token": kubeStaticToken ? "hit" : "miss",
|
|
250
221
|
};
|
|
251
222
|
|
|
252
|
-
// Per-indicator __fp_checks attestation.
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
// entropy, sample-credential, cluster-locality) are evaluated deterministically.
|
|
256
|
-
// Network / sts-validity checks are left unattested so the runner still
|
|
257
|
-
// downgrades those. Without this, a real cleartext key or static token
|
|
258
|
-
// surfaced by `collect` is downgraded to inconclusive after `run`.
|
|
223
|
+
// Per-indicator __fp_checks attestation. Path checks hold because every store
|
|
224
|
+
// read here is a canonical home path; network and STS-validity checks stay
|
|
225
|
+
// unattested. With no attestation at all, a real hit downgrades to inconclusive.
|
|
259
226
|
if (cleartextAnyHit && cleartextFp && cleartextFp.size) {
|
|
260
227
|
const att = {};
|
|
261
228
|
for (const idx of cleartextFp) att[idx] = true;
|
|
@@ -278,8 +245,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
278
245
|
// [1] client_email is a real *@*.gserviceaccount.com (not example/test)
|
|
279
246
|
const ce = gcloudParsed.clientEmail || "";
|
|
280
247
|
if (/@[^@\s]+\.gserviceaccount\.com$/i.test(ce) && !/@example\.com$|@test\./i.test(ce)) att["1"] = true;
|
|
281
|
-
// [2] canonical ADC path
|
|
282
|
-
// redirecting away from it
|
|
248
|
+
// [2] canonical ADC path, with no GOOGLE_APPLICATION_CREDENTIALS redirect
|
|
283
249
|
if (!(env && env.GOOGLE_APPLICATION_CREDENTIALS)) att["2"] = true;
|
|
284
250
|
if (Object.keys(att).length) signal_overrides["gcp-service-account-json__fp_checks"] = att;
|
|
285
251
|
}
|
|
@@ -1,32 +1,18 @@
|
|
|
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";
|
|
@@ -37,10 +23,8 @@ function readSafe(p, max = 512 * 1024) {
|
|
|
37
23
|
fd = fs.openSync(p, "r");
|
|
38
24
|
const s = fs.fstatSync(fd);
|
|
39
25
|
if (s.size > max) return null;
|
|
40
|
-
// readFileSync(fd) loops read() to EOF
|
|
41
|
-
//
|
|
42
|
-
// leave the buffer tail NUL-filled and silently drop trailing content.
|
|
43
|
-
// Reading via the already-open fd keeps the fstat-then-read TOCTOU-free.
|
|
26
|
+
// readFileSync(fd) loops read() to EOF; a single readSync can return short on
|
|
27
|
+
// a network or FUSE fd. Reading the open fd keeps fstat-then-read TOCTOU-free.
|
|
44
28
|
return fs.readFileSync(fd, "utf8");
|
|
45
29
|
} catch { return null; }
|
|
46
30
|
finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } } }
|
|
@@ -72,20 +56,13 @@ function walkWorkflows(root) {
|
|
|
72
56
|
return out;
|
|
73
57
|
}
|
|
74
58
|
|
|
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.
|
|
59
|
+
// Trigger detection across the four canonical `on:` shapes: scalar
|
|
60
|
+
// (`on: push`), inline list, block list, and mapping.
|
|
82
61
|
function workflowHasTrigger(content, name) {
|
|
83
62
|
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
63
|
const listMatch = content.match(/^\s*on:\s*\[([^\]]*)\]/m);
|
|
85
64
|
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.
|
|
65
|
+
// Block-list and mapping forms both follow `on:\n` with indented continuations.
|
|
89
66
|
const blockMatch = content.match(/^\s*on:\s*\n((?:[ \t]+[^\n]+\n?)+)/m);
|
|
90
67
|
if (blockMatch) {
|
|
91
68
|
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 +71,9 @@ function workflowHasTrigger(content, name) {
|
|
|
94
71
|
return false;
|
|
95
72
|
}
|
|
96
73
|
|
|
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.
|
|
74
|
+
// True when an `actions/checkout` step block carries a `ref:` naming the PR head.
|
|
75
|
+
// Binding the ref to the checkout step keeps an unrelated step's reference to the
|
|
76
|
+
// PR head from reading as a hit while the checkout fetches the base ref.
|
|
104
77
|
function checkoutBindsPrHead(content) {
|
|
105
78
|
const lines = content.split(/\r?\n/);
|
|
106
79
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -114,8 +87,7 @@ function checkoutBindsPrHead(content) {
|
|
|
114
87
|
const indentM = line.match(/^(\s*)\S/);
|
|
115
88
|
if (!indentM) continue;
|
|
116
89
|
const indent = indentM[1].length;
|
|
117
|
-
//
|
|
118
|
-
// any line de-indented past the step base ends the block.
|
|
90
|
+
// The block ends at a sibling `-` on the same indent, or any de-indent.
|
|
119
91
|
if (indent < baseIndent) { blockEnd = j; break; }
|
|
120
92
|
if (indent === baseIndent && line.trim().startsWith("- ")) { blockEnd = j; break; }
|
|
121
93
|
}
|
|
@@ -139,18 +111,12 @@ function scanWorkflow(content, rel) {
|
|
|
139
111
|
const hasPRTarget = workflowHasTrigger(content, "pull_request_target");
|
|
140
112
|
const hasIssueComment = workflowHasTrigger(content, "issue_comment");
|
|
141
113
|
|
|
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
114
|
if (hasPRTarget && checkoutBindsPrHead(content)) {
|
|
146
115
|
hits["pull-request-target-with-pr-checkout"].push({ file: rel, snippet: "pull_request_target trigger + checkout of PR head" });
|
|
147
116
|
}
|
|
148
117
|
|
|
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.
|
|
118
|
+
// workflow-injection-sink: an attacker-controlled `${{ github.event.* }}`
|
|
119
|
+
// interpolated inside a `run:` block, outside a safe `env:` mapping.
|
|
154
120
|
if (hasPRTarget || hasIssueComment || workflowHasTrigger(content, "pull_request")) {
|
|
155
121
|
const dangerousExprs = [
|
|
156
122
|
/\$\{\{\s*github\.event\.pull_request\.(?:title|body|head\.ref)\s*\}\}/,
|
|
@@ -162,17 +128,12 @@ function scanWorkflow(content, rel) {
|
|
|
162
128
|
const lines = content.split(/\r?\n/);
|
|
163
129
|
for (let i = 0; i < lines.length; i++) {
|
|
164
130
|
const line = lines[i];
|
|
165
|
-
// Quick filter: dangerous expression on this line?
|
|
166
131
|
const matchedExpr = dangerousExprs.find(re => re.test(line));
|
|
167
132
|
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.
|
|
133
|
+
// Inside an `env:` mapping the shell sees a variable, not a sink.
|
|
172
134
|
const ctx = lines.slice(Math.max(0, i - 3), i + 1).join("\n");
|
|
173
135
|
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.
|
|
136
|
+
// The expression counts only inside a run-block.
|
|
176
137
|
const inRun = /^\s+run:/m.test(ctx) || /^\s+\|/m.test(ctx) || lines[i].trim().startsWith("- run:");
|
|
177
138
|
if (inRun && !isEnvBinding) {
|
|
178
139
|
hits["workflow-injection-sink"].push({ file: rel, line: i + 1, snippet: line.trim().slice(0, 160) });
|
|
@@ -181,17 +142,14 @@ function scanWorkflow(content, rel) {
|
|
|
181
142
|
}
|
|
182
143
|
}
|
|
183
144
|
|
|
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: ./`).
|
|
145
|
+
// actions-floating-tag-pin: `uses: <owner>/<repo>@<ref>` where ref is not a
|
|
146
|
+
// 40-char hex SHA; owner `actions` and local composite actions are excluded.
|
|
188
147
|
const lines2 = content.split(/\r?\n/);
|
|
189
148
|
for (let i = 0; i < lines2.length; i++) {
|
|
190
|
-
// A real `uses:` line is never multiple KB
|
|
191
|
-
// crafted whitespace run
|
|
149
|
+
// A real `uses:` line is never multiple KB; skipping overlong ones keeps a
|
|
150
|
+
// crafted whitespace run from driving regex backtracking.
|
|
192
151
|
if (lines2[i].length > 4096) continue;
|
|
193
|
-
//
|
|
194
|
-
// `- ` list marker — no overlapping `\s*` runs that backtrack.
|
|
152
|
+
// Indentation is anchored once, then an optional `- ` — no overlapping `\s*`.
|
|
195
153
|
const m = lines2[i].match(/^[ \t]*(?:-[ \t]*)?uses:\s*['"]?([^'"\s#]+)['"]?/);
|
|
196
154
|
if (!m) continue;
|
|
197
155
|
const refStr = m[1];
|
|
@@ -208,15 +166,12 @@ function scanWorkflow(content, rel) {
|
|
|
208
166
|
}
|
|
209
167
|
}
|
|
210
168
|
|
|
211
|
-
// secret-exposed-to-fork-pr: pull_request_target trigger
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
// to detect fork status — left to operator evidence.
|
|
169
|
+
// secret-exposed-to-fork-pr: a pull_request_target trigger plus a `secrets.X`
|
|
170
|
+
// reference for any X other than GITHUB_TOKEN. A plain pull_request from a fork
|
|
171
|
+
// needs runtime fork status, so it stays operator evidence.
|
|
215
172
|
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).
|
|
173
|
+
// The captured secret NAME is compared exactly: an unanchored
|
|
174
|
+
// /secrets\.GITHUB_TOKEN/ also matches a custom GITHUB_TOKEN_PROD.
|
|
220
175
|
const nonDefault = [];
|
|
221
176
|
for (const m of content.matchAll(/\$\{\{\s*secrets\.([A-Z_][A-Z0-9_]*)\s*\}\}/g)) {
|
|
222
177
|
if (m[1] !== "GITHUB_TOKEN") nonDefault.push(m[0]);
|
|
@@ -230,10 +185,8 @@ function scanWorkflow(content, rel) {
|
|
|
230
185
|
}
|
|
231
186
|
|
|
232
187
|
function scanOidcPolicies(root) {
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
// sub-claim. The playbook lists `repo:<org>/*:*`, `repo:*:*`, and
|
|
236
|
-
// bare `*` as wildcard shapes.
|
|
188
|
+
// Walks the infra dirs to depth 4 for *.json naming
|
|
189
|
+
// token.actions.githubusercontent.com with a wildcarded sub-claim.
|
|
237
190
|
const rootDirs = ["infra", "terraform", "policies", ".aws", ".github"].map(d => path.join(root, d));
|
|
238
191
|
const finds = [];
|
|
239
192
|
function walk(dir, depth) {
|
|
@@ -245,10 +198,7 @@ function scanOidcPolicies(root) {
|
|
|
245
198
|
if (OIDC_WALK_EXCLUDES.has(e.name)) continue;
|
|
246
199
|
const full = path.join(dir, e.name);
|
|
247
200
|
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.
|
|
201
|
+
// A linked worktree holds the same trust documents; rescanning double-counts.
|
|
252
202
|
if (isLinkedWorktreeDir(full)) continue;
|
|
253
203
|
walk(full, depth + 1);
|
|
254
204
|
continue;
|
|
@@ -256,14 +206,8 @@ function scanOidcPolicies(root) {
|
|
|
256
206
|
if (!e.isFile() || !/\.json$/i.test(e.name)) continue;
|
|
257
207
|
const text = readSafe(full);
|
|
258
208
|
if (!text) continue;
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
// (`"token.actions.githubusercontent.com:sub"`), so a lookalike issuer
|
|
262
|
-
// such as `"eviltoken.actions…"` cannot match. No separate substring
|
|
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.
|
|
209
|
+
// Each pattern is bound to the leading `"` of the JSON key, so a lookalike
|
|
210
|
+
// issuer like `"eviltoken.actions…"` cannot match.
|
|
267
211
|
const subWildcard =
|
|
268
212
|
/"token\.actions\.githubusercontent\.com:sub"\s*:\s*"\*"/.test(text) ||
|
|
269
213
|
/"token\.actions\.githubusercontent\.com:sub"\s*:\s*"repo:\*[^"]*"/.test(text) ||
|
|
@@ -280,8 +224,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
280
224
|
const startTime = Date.now();
|
|
281
225
|
const root = path.resolve(cwd);
|
|
282
226
|
|
|
283
|
-
//
|
|
284
|
-
// repo we have no workflows / no OIDC trust JSON to walk.
|
|
227
|
+
// Outside a repo there are no workflows and no OIDC trust JSON to walk.
|
|
285
228
|
const cwdIsRepo = fs.existsSync(path.join(root, ".git"));
|
|
286
229
|
if (!cwdIsRepo) {
|
|
287
230
|
return {
|
|
@@ -324,11 +267,8 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
324
267
|
"wildcarded-oidc-sub-claim": oidcWildcards.length > 0 ? "hit" : "miss",
|
|
325
268
|
};
|
|
326
269
|
|
|
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.
|
|
270
|
+
// File locations for every indicator flipped to "hit", so a SARIF result points
|
|
271
|
+
// at the YAML or trust JSON behind it; without a line it surfaces as file-level.
|
|
332
272
|
const evidence_locations = {};
|
|
333
273
|
const evidenceSources = { ...aggregateHits, "wildcarded-oidc-sub-claim": oidcWildcards };
|
|
334
274
|
for (const [id, list] of Object.entries(evidenceSources)) {
|
|
@@ -374,17 +314,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
374
314
|
};
|
|
375
315
|
|
|
376
316
|
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).
|
|
317
|
+
// operator-owns-ci-fleet requires explicit opt-in through `--attest-ownership`:
|
|
318
|
+
// pointing collect at any cwd does NOT attest ownership of that fleet's CI
|
|
319
|
+
// authorization scope, and the playbook gates the precondition `on_fail: halt`.
|
|
388
320
|
precondition_checks: {
|
|
389
321
|
"cwd-is-repo": true,
|
|
390
322
|
"ci-config-readable": true,
|