@blamejs/exceptd-skills 0.19.34 → 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 +18 -0
- package/bin/exceptd.js +0 -5
- package/data/_indexes/_meta.json +2 -2
- package/lib/collectors/ai-api.js +150 -22
- package/lib/collectors/cicd-pipeline-compromise.js +73 -28
- package/lib/collectors/library-author.js +141 -5
- package/lib/collectors/sbom.js +96 -12
- package/lib/collectors/scan-excludes.js +3 -2
- package/lib/cve-regression-watcher.js +0 -3
- package/lib/framework-gap.js +5 -0
- package/lib/lint-skills.js +24 -4
- package/lib/playbook-runner.js +68 -14
- package/lib/prefetch.js +3 -2
- package/lib/refresh-external.js +0 -6
- package/lib/refresh-network.js +3 -4
- package/lib/scoring.js +8 -1
- package/lib/ttp-mapper.js +14 -3
- package/lib/upstream-check-cli.js +26 -1
- package/lib/validate-cve-catalog.js +9 -2
- package/lib/validate-playbooks.js +9 -11
- package/manifest.json +53 -53
- package/orchestrator/index.js +0 -1
- package/package.json +1 -1
- package/sbom.cdx.json +78 -78
- package/scripts/audit-perf.js +24 -13
- package/scripts/builders/theater-fingerprints.js +9 -4
- package/scripts/check-agents-md-collectors.js +15 -3
- package/scripts/check-codebase-patterns.js +16 -3
- package/scripts/check-manifest-snapshot.js +49 -8
- package/scripts/check-test-coverage.js +17 -1
- package/scripts/refresh-mitre-atlas.js +5 -1
- package/scripts/refresh-mitre-ics-attack.js +5 -1
- package/scripts/refresh-rfc-index.js +6 -1
- package/scripts/refresh-upstream-catalogs.js +25 -13
- package/scripts/release.js +0 -2
- package/scripts/run-e2e-scenarios.js +2 -2
- package/scripts/verify-shipped-tarball.js +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.19.35 — 2026-08-25
|
|
4
|
+
|
|
5
|
+
A scan that could not read something no longer reports that it found nothing there. The evidence collectors treated an unreadable file the same as an absent one: a credential store over the size cap, a workflow file without read permission, a lockfile that failed to parse all produced the output of a clean scan. A credential store that exists but went unread now reports its indicator as inconclusive rather than a miss, and its artifact as uncaptured with the reason attached. An unparseable Google Cloud credentials file reports the same way, since a parse that failed says nothing about what the file holds. A file read successfully but empty stays a completed scan, because the collector did read it and there was nothing in it — the two cases were previously indistinguishable, and both read as clean.
|
|
6
|
+
|
|
7
|
+
The same failure closed in the pipeline collector, where it ran deeper. A workflow file too large or unreadable was skipped in silence, and every indicator it would have flipped stayed at miss. Read, parse and directory-walk failures now travel to the operator as warnings — but a warning changes no verdict on its own, so the gap reaches the indicators too: where the workflow or trust-policy inventory is incomplete, the indicators drawn from it report inconclusive rather than a clean miss, the inventory is marked uncaptured, and the readability precondition no longer attests a file the collector could not open. A repository whose one vulnerable workflow was the file that could not be read was previously reported clean.
|
|
8
|
+
|
|
9
|
+
Release workflows that resolve dependencies at build time are detected for pip, pnpm and Bundler alongside npm and cargo, so a Python or Ruby publisher no longer reads as clean. Each install command is judged on its own flags: a hash-pinned install no longer vouches for an unpinned one elsewhere in the same file, and commands chained on one line or split across lines with a trailing backslash are read as the commands they are. The requirement file is recognised whether it is attached to the short option or separated from it, and an install is not reported when the file it names is itself hash-pinned, since one hashed requirement puts pip into hash-checking mode for all of them. A bare `pnpm install` is no longer reported, because pnpm enforces the frozen lockfile whenever CI is set; only an explicit opt-out is. Bundler's job-wide frozen setting suppresses the finding only when it is switched on — `bundle config set frozen false` named the setting and disabled it, and was read as though it enabled it. A requirement file is resolved against the job's working directory as well as the repository root, so a hash-pinned file in a subdirectory is no longer reported as unpinned; where copies resolve in several directories, every one of them has to be pinned. Findings point at the line their command starts on.
|
|
10
|
+
|
|
11
|
+
Capping an upstream-catalog refresh now bounds every source. The RFC index, ATLAS and ICS-ATT&CK commands accepted the cap and discarded it, so a run asked to import a bounded batch imported the whole upstream index — and the RFC index is an order of magnitude larger than any other source the refresh reads. The cap bounds new additions only; context fields on rows already curated are still completed. The ICS-ATT&CK refresh also stops importing a cross-listed enterprise technique it cannot assign an ICS tactic to. Those rows arrived with an empty tactic and were counted as missing context by the next audit, so the import spent the gap budget on itself; they are skipped now, and the run reports how many and which.
|
|
12
|
+
|
|
13
|
+
A mistyped flag on the upstream freshness probe is refused with a non-zero exit instead of being ignored. A misspelled air-gap flag previously left the check running as though nothing had been passed, so a run believed to be offline reached the network.
|
|
14
|
+
|
|
15
|
+
Diagnostics no longer disappear when output is piped. The catalog validator's enum-mismatch and entry-count-drift messages and the skill linter's help and unknown-argument text were written to a stream the process could exit before draining, which a terminal hid and a CI log collector did not.
|
|
16
|
+
|
|
17
|
+
A malformed analyze result no longer aborts the closing phase. The evidence bundle, notification deadlines, exception language and downstream chaining are still produced, and the shape problem is reported alongside them naming the part of the result that was wrong. That holds for a single unusable entry among good ones as well as for a malformed result overall: the unusable entries are dropped and counted, the rest still reach the advisory. Catalog scoring validation likewise reports a null or non-object entry and continues through the rest of the catalog rather than stopping at the first one, and asking for the documented gaps behind an attack pattern without supplying a gap catalog returns the same answer an empty one gives instead of raising.
|
|
18
|
+
|
|
19
|
+
The manifest snapshot gate reports where it used to crash or wave through. A baseline written before a surface field existed ended the run with a stack trace on precisely the case the gate exists to report; it is now recorded as an additive change. A corrupt value where a list belongs stops the gate with the skill and field named, rather than reporting that skill's entire surface as newly added and passing.
|
|
20
|
+
|
|
3
21
|
## 0.19.34 — 2026-08-23
|
|
4
22
|
|
|
5
23
|
Comment volume across the shipped JavaScript falls from 16,627 lines to 5,585, against 36,875 lines of code that are byte-for-byte unchanged. What went is the reasoning that produced a line rather than the constraint on it: pull-request references, version histories, "this used to", and module docblocks that toured a file's exports above the exports themselves. What stayed is what the code cannot say for itself — units and nullability on an exported function, the specification clause an emitter satisfies, the trap whose deletion invites a specific dangerous edit, and the cross-file coupling no grep will surface.
|
package/bin/exceptd.js
CHANGED
|
@@ -544,11 +544,7 @@ function main() {
|
|
|
544
544
|
// Strip only `--prefetch` (the alias). `--no-network` must reach prefetch.js:
|
|
545
545
|
// dropping it leaves prefetch fetching, so a cold cache hits every upstream.
|
|
546
546
|
effectiveCmd = "prefetch";
|
|
547
|
-
const wantedNoNetwork = rest.includes("--no-network");
|
|
548
547
|
effectiveRest = rest.filter(a => a !== "--prefetch");
|
|
549
|
-
if (wantedNoNetwork && !effectiveRest.includes("--no-network")) {
|
|
550
|
-
// Unreachable while the filter above spares --no-network.
|
|
551
|
-
}
|
|
552
548
|
} else if (cmd === "refresh" && rest.includes("--indexes-only")) {
|
|
553
549
|
effectiveCmd = "build-indexes";
|
|
554
550
|
effectiveRest = rest.filter(a => a !== "--indexes-only");
|
|
@@ -6937,7 +6933,6 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
6937
6933
|
if (!handled) {
|
|
6938
6934
|
// stdin closed with no evidence event. Before erroring, try the raw text as a
|
|
6939
6935
|
// bare submission — `echo '{...}' | ai-run secrets` pipes the body unwrapped.
|
|
6940
|
-
const raw = (process.stdin._consumed || "") || buf;
|
|
6941
6936
|
const allText = process.stdin._allText;
|
|
6942
6937
|
if (allText && allText.trim()) {
|
|
6943
6938
|
try {
|
package/data/_indexes/_meta.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": "1.1.0",
|
|
3
|
-
"generated_at": "2026-08-
|
|
3
|
+
"generated_at": "2026-08-25T14:19:57.183Z",
|
|
4
4
|
"generator": "scripts/build-indexes.js",
|
|
5
5
|
"source_count": 64,
|
|
6
6
|
"source_hashes": {
|
|
7
|
-
"manifest.json": "
|
|
7
|
+
"manifest.json": "ec8d77f3c80b458eeb5901346be0e0f8fde9903da2de9ed85740372beddb8261",
|
|
8
8
|
"README.md": "bc575c178c21e3d07f1491710e24b26cad3a3cee012c43641b6ea7e83468e64e",
|
|
9
9
|
"data/atlas-ttps.json": "a14089671efcb4f06dd0f82d688fe49276c1370e167e4483d76e9c0a40ef41eb",
|
|
10
10
|
"data/attack-techniques.json": "2179122ff8347bc691f245d5fd2601ca81fd1d6f790feae44abca86d3c08f865",
|
package/lib/collectors/ai-api.js
CHANGED
|
@@ -13,16 +13,40 @@ const os = require("node:os");
|
|
|
13
13
|
|
|
14
14
|
const COLLECTOR_ID = "ai-api";
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// One definition, named at every call site. Written as a literal in both the
|
|
17
|
+
// default and the callers, the two drift apart the moment the cap is raised,
|
|
18
|
+
// and the only symptom is a credential store that quietly stops being scanned
|
|
19
|
+
// at the old size.
|
|
20
|
+
const SCAN_CAP_BYTES = 256 * 1024;
|
|
21
|
+
|
|
22
|
+
// `skip` routes a non-read onto the collector_errors channel:
|
|
23
|
+
// { errors, artifact_id, label }. A null return is indistinguishable between
|
|
24
|
+
// "over the cap" and "unreadable", and both mean the file went unscanned — a
|
|
25
|
+
// clean verdict over an unscanned credential store is the failure mode, so the
|
|
26
|
+
// reason travels with the submission. `label` is home-relative: the absolute
|
|
27
|
+
// path is operator-identifying and collector_meta already carries `home`.
|
|
28
|
+
function readSafe(full, max = SCAN_CAP_BYTES, skip = null) {
|
|
29
|
+
const note = (kind, reason) => {
|
|
30
|
+
if (!skip || !Array.isArray(skip.errors)) return;
|
|
31
|
+
const entry = { kind, reason: `${skip.label || path.basename(full)}: ${reason}` };
|
|
32
|
+
if (skip.artifact_id) entry.artifact_id = skip.artifact_id;
|
|
33
|
+
skip.errors.push(entry);
|
|
34
|
+
};
|
|
17
35
|
let fd;
|
|
18
36
|
try {
|
|
19
37
|
fd = fs.openSync(full, "r");
|
|
20
38
|
const s = fs.fstatSync(fd);
|
|
21
|
-
if (s.size > max)
|
|
39
|
+
if (s.size > max) {
|
|
40
|
+
note("file_too_large_skipped", `${s.size} bytes exceeds ${max}-byte scan limit; not scanned`);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
22
43
|
// readFileSync(fd) loops to EOF; a single readSync can return short on a
|
|
23
44
|
// network or FUSE fd. Reading the open fd keeps fstat-then-read TOCTOU-free.
|
|
24
45
|
return fs.readFileSync(fd, "utf8");
|
|
25
|
-
} catch {
|
|
46
|
+
} catch (e) {
|
|
47
|
+
note("read_failed", e.message);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
26
50
|
finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } } }
|
|
27
51
|
}
|
|
28
52
|
|
|
@@ -30,6 +54,27 @@ function fileExists(full) {
|
|
|
30
54
|
try { return fs.statSync(full).isFile(); } catch { return false; }
|
|
31
55
|
}
|
|
32
56
|
|
|
57
|
+
// A credential store is one of three things, and only "absent" is a negative
|
|
58
|
+
// finding. Truthiness collapses the other two: an empty file reads as "" and is
|
|
59
|
+
// a completed scan, not a skipped one, while readSafe signals a skip with null.
|
|
60
|
+
// The distinction is null-vs-string, and it drives the artifact's captured flag
|
|
61
|
+
// and the indicator's verdict together so the two cannot disagree.
|
|
62
|
+
const ABSENT = "absent";
|
|
63
|
+
const UNREAD = "unread";
|
|
64
|
+
const READ = "read";
|
|
65
|
+
function storeState(exists, content) {
|
|
66
|
+
if (!exists) return ABSENT;
|
|
67
|
+
return content === null ? UNREAD : READ;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A store that was never read cannot answer the question its indicator asks.
|
|
71
|
+
// `miss` there is a clean bill of health over an unscanned file; a hit stands
|
|
72
|
+
// on its own evidence and is unaffected by a sibling going unread.
|
|
73
|
+
function verdict(found, undetermined) {
|
|
74
|
+
if (found) return "hit";
|
|
75
|
+
return undetermined ? "inconclusive" : "miss";
|
|
76
|
+
}
|
|
77
|
+
|
|
33
78
|
// Cleartext key exports: `export VAR=value`, `VAR=value`, fish `set -gx VAR value`.
|
|
34
79
|
const AI_KEY_PATTERNS = [
|
|
35
80
|
{ id: "openai", re: /(?:^|\n)\s*(?:export\s+|set\s+-gx\s+)?OPENAI_API_KEY\s*[= ]\s*['"]?sk-[A-Za-z0-9_-]{20,}/m },
|
|
@@ -129,7 +174,12 @@ function parseGcloudAdc(content) {
|
|
|
129
174
|
privateKey: typeof j?.private_key === "string" ? j.private_key : "",
|
|
130
175
|
clientEmail: typeof j?.client_email === "string" ? j.client_email : "",
|
|
131
176
|
};
|
|
132
|
-
} catch
|
|
177
|
+
} catch (e) {
|
|
178
|
+
// Unparseable ADC is NOT evidence the file holds no service account; the
|
|
179
|
+
// parse failure travels so `gcp-service-account-json: miss` is not read as
|
|
180
|
+
// a clean bill of health.
|
|
181
|
+
return { hasServiceAccount: false, parse_error: e.message };
|
|
182
|
+
}
|
|
133
183
|
}
|
|
134
184
|
|
|
135
185
|
function parseKubeStaticToken(content) {
|
|
@@ -170,7 +220,17 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
170
220
|
for (const e of fs.readdirSync(fishConfD)) {
|
|
171
221
|
if (e.endsWith(".fish")) shellRcs.push(path.join(fishConfD, e));
|
|
172
222
|
}
|
|
173
|
-
} catch {
|
|
223
|
+
} catch (e) {
|
|
224
|
+
// No fish conf.d is the normal case. A permission or I/O failure on one that
|
|
225
|
+
// IS there means those fragments went unscanned for key exports.
|
|
226
|
+
if (e.code !== "ENOENT") {
|
|
227
|
+
errors.push({
|
|
228
|
+
artifact_id: "shell-rc-files",
|
|
229
|
+
kind: "readdir_failed",
|
|
230
|
+
reason: `.config/fish/conf.d: ${e.message} — fragments not scanned`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
174
234
|
|
|
175
235
|
const dotfileKeys = [
|
|
176
236
|
".openai", ".anthropic",
|
|
@@ -180,13 +240,24 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
180
240
|
path.join(".config", "azure-openai"),
|
|
181
241
|
].map(rel => path.join(home, rel));
|
|
182
242
|
|
|
183
|
-
|
|
243
|
+
// Two artifacts share one scan loop. The carrier's own artifact_id travels
|
|
244
|
+
// with it so a skipped `~/.anthropic` is reported against dotfile-api-keys
|
|
245
|
+
// rather than pointing the operator at the shell-rc row.
|
|
246
|
+
const allKeyCarriers = [
|
|
247
|
+
...shellRcs.map(p => ({ path: p, artifact_id: "shell-rc-files" })),
|
|
248
|
+
...dotfileKeys.map(p => ({ path: p, artifact_id: "dotfile-api-keys" })),
|
|
249
|
+
];
|
|
184
250
|
const cleartextHitsByFile = {};
|
|
251
|
+
const cleartextUnread = [];
|
|
185
252
|
let cleartextFp = null;
|
|
186
|
-
for (const p of allKeyCarriers) {
|
|
253
|
+
for (const { path: p, artifact_id } of allKeyCarriers) {
|
|
187
254
|
if (!fileExists(p)) continue;
|
|
188
|
-
const c = readSafe(p
|
|
189
|
-
|
|
255
|
+
const c = readSafe(p, SCAN_CAP_BYTES, {
|
|
256
|
+
errors, artifact_id, label: path.relative(home, p),
|
|
257
|
+
});
|
|
258
|
+
// Present but unread: the file could still hold a key, so it is a gap in
|
|
259
|
+
// the scan rather than a carrier with nothing in it.
|
|
260
|
+
if (c === null) { cleartextUnread.push(path.relative(home, p)); continue; }
|
|
190
261
|
const hits = scanShellRc(c);
|
|
191
262
|
if (hits.length > 0) {
|
|
192
263
|
cleartextHitsByFile[path.relative(home, p)] = hits;
|
|
@@ -200,24 +271,64 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
200
271
|
const cleartextAnyHit = Object.keys(cleartextHitsByFile).length > 0;
|
|
201
272
|
|
|
202
273
|
const awsCredsPath = path.join(home, ".aws", "credentials");
|
|
203
|
-
const
|
|
274
|
+
const awsCredsExists = fileExists(awsCredsPath);
|
|
275
|
+
const awsCredsContent = awsCredsExists
|
|
276
|
+
? readSafe(awsCredsPath, SCAN_CAP_BYTES, {
|
|
277
|
+
errors, artifact_id: "aws-credentials", label: path.relative(home, awsCredsPath),
|
|
278
|
+
})
|
|
279
|
+
: null;
|
|
204
280
|
const awsParsed = parseAwsCredentials(awsCredsContent);
|
|
205
281
|
const longLivedAws = awsParsed.staticProfiles.length > 0;
|
|
206
282
|
|
|
207
283
|
const gcloudAdcPath = path.join(home, ".config", "gcloud", "application_default_credentials.json");
|
|
208
|
-
const
|
|
284
|
+
const gcloudAdcExists = fileExists(gcloudAdcPath);
|
|
285
|
+
const gcloudContent = gcloudAdcExists
|
|
286
|
+
? readSafe(gcloudAdcPath, SCAN_CAP_BYTES, {
|
|
287
|
+
errors, artifact_id: "gcp-credentials", label: path.relative(home, gcloudAdcPath),
|
|
288
|
+
})
|
|
289
|
+
: null;
|
|
209
290
|
const gcloudParsed = parseGcloudAdc(gcloudContent);
|
|
291
|
+
if (gcloudParsed.parse_error) {
|
|
292
|
+
errors.push({
|
|
293
|
+
artifact_id: "gcp-credentials",
|
|
294
|
+
kind: "parse_failed",
|
|
295
|
+
reason: `${path.relative(home, gcloudAdcPath)}: ${gcloudParsed.parse_error} — service-account presence undetermined, not absent`,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
210
298
|
|
|
211
299
|
const kubeCfgPath = (env && env.KUBECONFIG) || path.join(home, ".kube", "config");
|
|
212
|
-
|
|
300
|
+
// KUBECONFIG can point outside $HOME, where a home-relative label degrades to
|
|
301
|
+
// a `..` chain; the basename is enough to name the file that went unread.
|
|
302
|
+
// The separator is required: a bare prefix test also matches a SIBLING whose
|
|
303
|
+
// name merely starts with $HOME ("/home/rob" vs "/home/robert-backup"), which
|
|
304
|
+
// produces exactly the `../…` chain this branch exists to avoid — and leaks
|
|
305
|
+
// another account's directory name into the warning.
|
|
306
|
+
const kubeInHome = kubeCfgPath === home || kubeCfgPath.startsWith(home + path.sep);
|
|
307
|
+
const kubeLabel = kubeInHome ? path.relative(home, kubeCfgPath) : path.basename(kubeCfgPath);
|
|
308
|
+
const kubeCfgExists = fileExists(kubeCfgPath);
|
|
309
|
+
const kubeContent = kubeCfgExists
|
|
310
|
+
? readSafe(kubeCfgPath, SCAN_CAP_BYTES, {
|
|
311
|
+
errors, artifact_id: "kube-config", label: kubeLabel,
|
|
312
|
+
})
|
|
313
|
+
: null;
|
|
213
314
|
const kubeParsed = parseKubeStaticToken(kubeContent);
|
|
214
315
|
const kubeStaticToken = kubeParsed.found;
|
|
215
316
|
|
|
317
|
+
const awsState = storeState(awsCredsExists, awsCredsContent);
|
|
318
|
+
const gcloudState = storeState(gcloudAdcExists, gcloudContent);
|
|
319
|
+
const kubeState = storeState(kubeCfgExists, kubeContent);
|
|
320
|
+
|
|
216
321
|
const signal_overrides = {
|
|
217
|
-
"cleartext-api-key-in-dotfile": cleartextAnyHit
|
|
218
|
-
"long-lived-aws-keys": longLivedAws
|
|
219
|
-
|
|
220
|
-
|
|
322
|
+
"cleartext-api-key-in-dotfile": verdict(cleartextAnyHit, cleartextUnread.length > 0),
|
|
323
|
+
"long-lived-aws-keys": verdict(longLivedAws, awsState === UNREAD),
|
|
324
|
+
// Unread and unparseable are the same answer here: nothing in the file was
|
|
325
|
+
// validated, so its service-account presence is undetermined rather than
|
|
326
|
+
// absent. JSON.parse never runs on an unread store, so the state carries it.
|
|
327
|
+
"gcp-service-account-json": verdict(
|
|
328
|
+
gcloudParsed.hasServiceAccount,
|
|
329
|
+
gcloudState === UNREAD || Boolean(gcloudParsed.parse_error),
|
|
330
|
+
),
|
|
331
|
+
"kubeconfig-with-static-token": verdict(kubeStaticToken, kubeState === UNREAD),
|
|
221
332
|
};
|
|
222
333
|
|
|
223
334
|
// Per-indicator __fp_checks attestation. Path checks hold because every store
|
|
@@ -271,15 +382,32 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
271
382
|
value: dotfileKeys.filter(p => fileExists(p)).map(p => path.relative(home, p)).join(", ") || "no AI vendor dotfile carriers found at the canonical paths",
|
|
272
383
|
captured: true,
|
|
273
384
|
},
|
|
274
|
-
|
|
385
|
+
// A null content is two different worlds: the store is not there, or it IS
|
|
386
|
+
// there and went unread (over the scan cap, permissions, I/O). Only the
|
|
387
|
+
// first is an absence. The second is captured:false with the reason on
|
|
388
|
+
// collector_errors — asserting "absent" over a credential file that exists
|
|
389
|
+
// is the same clean-verdict-over-an-unscanned-store failure the error
|
|
390
|
+
// channel was added to close.
|
|
391
|
+
"aws-credentials": awsState === READ
|
|
275
392
|
? { value: `${awsParsed.staticProfiles.length} long-lived profile(s): ${awsParsed.staticProfiles.join(", ") || "none"}`, captured: true }
|
|
276
|
-
:
|
|
277
|
-
|
|
393
|
+
: awsState === UNREAD
|
|
394
|
+
? { value: "~/.aws/credentials present but unread — profile inventory undetermined, not absent", captured: false, reason: "read skipped or failed; see collector_errors for the reason" }
|
|
395
|
+
: { value: "~/.aws/credentials absent", captured: true },
|
|
396
|
+
// Read-but-unparseable is a fourth outcome, distinct from read, unread and
|
|
397
|
+
// absent: the bytes arrived and nothing in them was validated. Reporting
|
|
398
|
+
// captured:true there states service_account=false about a file never parsed.
|
|
399
|
+
"gcp-credentials": gcloudState === READ && !gcloudParsed.parse_error
|
|
278
400
|
? { value: `application_default_credentials.json present; service_account=${gcloudParsed.hasServiceAccount}`, captured: true }
|
|
279
|
-
:
|
|
280
|
-
|
|
401
|
+
: gcloudState === READ
|
|
402
|
+
? { value: "application_default_credentials.json present but unparseable — service-account presence undetermined, not absent", captured: false, reason: "JSON parse failed; see collector_errors for the reason" }
|
|
403
|
+
: gcloudState === UNREAD
|
|
404
|
+
? { value: "application_default_credentials.json present but unread — service-account presence undetermined, not absent", captured: false, reason: "read skipped or failed; see collector_errors for the reason" }
|
|
405
|
+
: { value: "no gcloud ADC at the canonical path", captured: true, reason: "credentials.db / legacy_credentials/*/adc.json inspection deferred (no stdlib SQLite reader)" },
|
|
406
|
+
"kube-config": kubeState === READ
|
|
281
407
|
? { value: `kubeconfig present; static_token=${kubeStaticToken}`, captured: true }
|
|
282
|
-
:
|
|
408
|
+
: kubeState === UNREAD
|
|
409
|
+
? { value: "kubeconfig present but unread — static-token presence undetermined, not absent", captured: false, reason: "read skipped or failed; see collector_errors for the reason" }
|
|
410
|
+
: { value: "no kubeconfig at the canonical path", captured: true },
|
|
283
411
|
"ai-sdk-inventory": {
|
|
284
412
|
value: "skipped — npm/pip global listing deferred to operator/AI evidence",
|
|
285
413
|
captured: false,
|
|
@@ -17,40 +17,57 @@ const OIDC_WALK_EXCLUDES = codeExcludeSet();
|
|
|
17
17
|
|
|
18
18
|
const COLLECTOR_ID = "cicd-pipeline-compromise";
|
|
19
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.
|
|
20
23
|
function readSafe(p, max = 512 * 1024) {
|
|
21
24
|
let fd;
|
|
22
25
|
try {
|
|
23
26
|
fd = fs.openSync(p, "r");
|
|
24
27
|
const s = fs.fstatSync(fd);
|
|
25
|
-
if (s.size > max) return
|
|
28
|
+
if (s.size > max) return { skipped: "file_too_large", reason: `${s.size} bytes exceeds the ${max}-byte scan limit; not scanned` };
|
|
26
29
|
// readFileSync(fd) loops read() to EOF; a single readSync can return short on
|
|
27
30
|
// a network or FUSE fd. Reading the open fd keeps fstat-then-read TOCTOU-free.
|
|
28
|
-
return fs.readFileSync(fd, "utf8");
|
|
29
|
-
} catch { return
|
|
31
|
+
return { text: fs.readFileSync(fd, "utf8") };
|
|
32
|
+
} catch (e) { return { skipped: "read_error", reason: (e && e.message) || String(e) }; }
|
|
30
33
|
finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } } }
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
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 = []) {
|
|
34
45
|
const out = [];
|
|
35
46
|
const wfDir = path.join(root, ".github", "workflows");
|
|
36
47
|
if (fs.existsSync(wfDir)) {
|
|
37
48
|
let entries;
|
|
38
49
|
try { entries = fs.readdirSync(wfDir, { withFileTypes: true }); }
|
|
39
|
-
catch {
|
|
50
|
+
catch (e) {
|
|
51
|
+
entries = [];
|
|
52
|
+
errors.push({ artifact_id: "workflow-yaml-inventory", kind: "readdir_failed", reason: `.github/workflows: ${e.message}` });
|
|
53
|
+
}
|
|
40
54
|
for (const e of entries) {
|
|
41
55
|
if (!e.isFile()) continue;
|
|
42
56
|
if (!/\.(ya?ml)$/i.test(e.name)) continue;
|
|
43
57
|
const full = path.join(wfDir, e.name);
|
|
44
|
-
const
|
|
45
|
-
|
|
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));
|
|
46
62
|
}
|
|
47
63
|
}
|
|
48
64
|
// Also recognise the most common single-file CI YAMLs at repo root.
|
|
49
65
|
for (const top of [".gitlab-ci.yml", ".circleci/config.yml"]) {
|
|
50
66
|
const full = path.join(root, top);
|
|
51
67
|
if (fs.existsSync(full)) {
|
|
52
|
-
const
|
|
53
|
-
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));
|
|
54
71
|
}
|
|
55
72
|
}
|
|
56
73
|
return out;
|
|
@@ -184,7 +201,7 @@ function scanWorkflow(content, rel) {
|
|
|
184
201
|
return hits;
|
|
185
202
|
}
|
|
186
203
|
|
|
187
|
-
function scanOidcPolicies(root) {
|
|
204
|
+
function scanOidcPolicies(root, errors = []) {
|
|
188
205
|
// Walks the infra dirs to depth 4 for *.json naming
|
|
189
206
|
// token.actions.githubusercontent.com with a wildcarded sub-claim.
|
|
190
207
|
const rootDirs = ["infra", "terraform", "policies", ".aws", ".github"].map(d => path.join(root, d));
|
|
@@ -193,7 +210,14 @@ function scanOidcPolicies(root) {
|
|
|
193
210
|
if (depth > 4 || finds.length > 5) return;
|
|
194
211
|
let entries;
|
|
195
212
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
196
|
-
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
|
+
}
|
|
197
221
|
for (const e of entries) {
|
|
198
222
|
if (OIDC_WALK_EXCLUDES.has(e.name)) continue;
|
|
199
223
|
const full = path.join(dir, e.name);
|
|
@@ -204,15 +228,17 @@ function scanOidcPolicies(root) {
|
|
|
204
228
|
continue;
|
|
205
229
|
}
|
|
206
230
|
if (!e.isFile() || !/\.json$/i.test(e.name)) continue;
|
|
207
|
-
const
|
|
208
|
-
|
|
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;
|
|
209
235
|
// Each pattern is bound to the leading `"` of the JSON key, so a lookalike
|
|
210
236
|
// issuer like `"eviltoken.actions…"` cannot match.
|
|
211
237
|
const subWildcard =
|
|
212
238
|
/"token\.actions\.githubusercontent\.com:sub"\s*:\s*"\*"/.test(text) ||
|
|
213
239
|
/"token\.actions\.githubusercontent\.com:sub"\s*:\s*"repo:\*[^"]*"/.test(text) ||
|
|
214
240
|
/"token\.actions\.githubusercontent\.com:sub"\s*:\s*"repo:[^"]*\/\*:[^"]*"/.test(text);
|
|
215
|
-
if (subWildcard) finds.push({ file:
|
|
241
|
+
if (subWildcard) finds.push({ file: rel, snippet: "OIDC sub-claim wildcarded across repos or branches" });
|
|
216
242
|
}
|
|
217
243
|
}
|
|
218
244
|
for (const rd of rootDirs) if (fs.existsSync(rd)) walk(rd, 0);
|
|
@@ -245,7 +271,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
245
271
|
};
|
|
246
272
|
}
|
|
247
273
|
|
|
248
|
-
const workflows = walkWorkflows(root);
|
|
274
|
+
const workflows = walkWorkflows(root, errors);
|
|
249
275
|
const aggregateHits = {
|
|
250
276
|
"workflow-injection-sink": [],
|
|
251
277
|
"pull-request-target-with-pr-checkout": [],
|
|
@@ -257,14 +283,25 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
257
283
|
for (const [k, v] of Object.entries(h)) aggregateHits[k].push(...v);
|
|
258
284
|
}
|
|
259
285
|
|
|
260
|
-
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");
|
|
261
298
|
|
|
262
299
|
const signal_overrides = {
|
|
263
|
-
"workflow-injection-sink": aggregateHits["workflow-injection-sink"].length > 0
|
|
264
|
-
"pull-request-target-with-pr-checkout": aggregateHits["pull-request-target-with-pr-checkout"].length > 0
|
|
265
|
-
"actions-floating-tag-pin": aggregateHits["actions-floating-tag-pin"].length > 0
|
|
266
|
-
"secret-exposed-to-fork-pr": aggregateHits["secret-exposed-to-fork-pr"].length > 0
|
|
267
|
-
"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),
|
|
268
305
|
};
|
|
269
306
|
|
|
270
307
|
// File locations for every indicator flipped to "hit", so a SARIF result points
|
|
@@ -280,14 +317,20 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
280
317
|
|
|
281
318
|
const artifacts = {
|
|
282
319
|
"workflow-yaml-inventory": {
|
|
283
|
-
value:
|
|
284
|
-
|
|
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" } : {}),
|
|
285
325
|
},
|
|
286
326
|
"oidc-trust-policy-inventory": {
|
|
287
|
-
value:
|
|
288
|
-
? `${oidcWildcards.length} wildcarded sub-claim(s)
|
|
289
|
-
:
|
|
290
|
-
|
|
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" } : {}),
|
|
291
334
|
},
|
|
292
335
|
"actions-sha-pinning": {
|
|
293
336
|
value: `${aggregateHits["actions-floating-tag-pin"].length} non-SHA third-party uses: reference(s) across ${workflows.length} workflow(s)`,
|
|
@@ -319,7 +362,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
319
362
|
// authorization scope, and the playbook gates the precondition `on_fail: halt`.
|
|
320
363
|
precondition_checks: {
|
|
321
364
|
"cwd-is-repo": true,
|
|
322
|
-
|
|
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,
|
|
323
368
|
"operator-owns-ci-fleet": args.attestOwnership === true || args["attest-ownership"] === true,
|
|
324
369
|
},
|
|
325
370
|
artifacts,
|