@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
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
|
|
|
@@ -25,18 +13,40 @@ const os = require("node:os");
|
|
|
25
13
|
|
|
26
14
|
const COLLECTOR_ID = "ai-api";
|
|
27
15
|
|
|
28
|
-
|
|
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
|
+
};
|
|
29
35
|
let fd;
|
|
30
36
|
try {
|
|
31
37
|
fd = fs.openSync(full, "r");
|
|
32
38
|
const s = fs.fstatSync(fd);
|
|
33
|
-
if (s.size > max)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
//
|
|
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
|
+
}
|
|
43
|
+
// readFileSync(fd) loops to EOF; a single readSync can return short on a
|
|
44
|
+
// network or FUSE fd. Reading the open fd keeps fstat-then-read TOCTOU-free.
|
|
38
45
|
return fs.readFileSync(fd, "utf8");
|
|
39
|
-
} catch {
|
|
46
|
+
} catch (e) {
|
|
47
|
+
note("read_failed", e.message);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
40
50
|
finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } } }
|
|
41
51
|
}
|
|
42
52
|
|
|
@@ -44,9 +54,28 @@ function fileExists(full) {
|
|
|
44
54
|
try { return fs.statSync(full).isFile(); } catch { return false; }
|
|
45
55
|
}
|
|
46
56
|
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
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
|
+
|
|
78
|
+
// Cleartext key exports: `export VAR=value`, `VAR=value`, fish `set -gx VAR value`.
|
|
50
79
|
const AI_KEY_PATTERNS = [
|
|
51
80
|
{ id: "openai", re: /(?:^|\n)\s*(?:export\s+|set\s+-gx\s+)?OPENAI_API_KEY\s*[= ]\s*['"]?sk-[A-Za-z0-9_-]{20,}/m },
|
|
52
81
|
{ 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 +85,15 @@ const AI_KEY_PATTERNS = [
|
|
|
56
85
|
{ id: "cohere", re: /(?:^|\n)\s*(?:export\s+|set\s+-gx\s+)?COHERE_API_KEY\s*[= ]\s*['"]?[A-Za-z0-9-]{30,}/m },
|
|
57
86
|
];
|
|
58
87
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// patterns above end at the prefix; widen to grab the trailing token.
|
|
88
|
+
// The patterns above end at the prefix; these capture the exported value so
|
|
89
|
+
// the placeholder and entropy-floor FP checks have something to evaluate.
|
|
62
90
|
const AI_KEY_VALUE_RE = {
|
|
63
91
|
openai: /OPENAI_API_KEY\s*[= ]\s*['"]?(sk-[A-Za-z0-9_-]+)/,
|
|
64
92
|
anthropic: /ANTHROPIC_API_KEY\s*[= ]\s*['"]?(sk-ant-[A-Za-z0-9_-]+)/,
|
|
65
93
|
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.
|
|
94
|
+
// Azure, Google and Cohere keys carry no vendor prefix, so the captured value
|
|
95
|
+
// IS the entropy body. Drop one of these and cleartextFpIndices attests nothing
|
|
96
|
+
// for such a dotfile, downgrading a real hit to inconclusive.
|
|
73
97
|
azure: /AZURE_OPENAI(?:_API)?_KEY\s*[= ]\s*['"]?([A-Za-z0-9]{20,})/,
|
|
74
98
|
google: /(?:GOOGLE_API_KEY|GOOGLE_GENAI_API_KEY|GEMINI_API_KEY)\s*[= ]\s*['"]?([A-Za-z0-9_-]{20,})/,
|
|
75
99
|
cohere: /COHERE_API_KEY\s*[= ]\s*['"]?([A-Za-z0-9-]{30,})/,
|
|
@@ -85,12 +109,9 @@ function scanShellRc(content) {
|
|
|
85
109
|
return hits;
|
|
86
110
|
}
|
|
87
111
|
|
|
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.
|
|
112
|
+
// The satisfiable false_positive_checks_required indices for
|
|
113
|
+
// cleartext-api-key-in-dotfile, intersected across every export found. Canonical
|
|
114
|
+
// home dotfiles are never under examples/tests/fixtures, so [1] always holds.
|
|
94
115
|
function cleartextFpIndices(content) {
|
|
95
116
|
const sat = new Set(["0", "1", "2"]);
|
|
96
117
|
let sawAny = false;
|
|
@@ -101,8 +122,7 @@ function cleartextFpIndices(content) {
|
|
|
101
122
|
const value = m[1];
|
|
102
123
|
// [0] not a documented placeholder / sk-test- fixture
|
|
103
124
|
if (PLACEHOLDER_RE.test(value)) sat.delete("0");
|
|
104
|
-
// [2] entropy floor: OpenAI
|
|
105
|
-
// HuggingFace hf_* >= 30.
|
|
125
|
+
// [2] entropy floor, post-prefix: OpenAI 48, Anthropic 40, others 30.
|
|
106
126
|
const floor = vendor === "openai" ? 48 : vendor === "anthropic" ? 40 : 30;
|
|
107
127
|
const body = value.replace(/^sk-ant-(?:api03|admin01)-|^sk-(?:proj-|svcacct-|admin-)?|^hf_/, "");
|
|
108
128
|
if (body.length < floor) sat.delete("2");
|
|
@@ -128,9 +148,8 @@ function parseAwsCredentials(content) {
|
|
|
128
148
|
const staticProfiles = [];
|
|
129
149
|
const accessKeyIds = [];
|
|
130
150
|
for (const [name, kv] of Object.entries(profiles)) {
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
// session token; IAM-user long-lived keys do not).
|
|
151
|
+
// Long-lived means an access key id with no session-token sibling: STS
|
|
152
|
+
// temporary credentials always carry one, IAM-user keys never do.
|
|
134
153
|
if (kv["aws_access_key_id"] && !kv["aws_session_token"]) {
|
|
135
154
|
staticProfiles.push(name);
|
|
136
155
|
accessKeyIds.push(kv["aws_access_key_id"]);
|
|
@@ -155,7 +174,12 @@ function parseGcloudAdc(content) {
|
|
|
155
174
|
privateKey: typeof j?.private_key === "string" ? j.private_key : "",
|
|
156
175
|
clientEmail: typeof j?.client_email === "string" ? j.client_email : "",
|
|
157
176
|
};
|
|
158
|
-
} 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
|
+
}
|
|
159
183
|
}
|
|
160
184
|
|
|
161
185
|
function parseKubeStaticToken(content) {
|
|
@@ -187,18 +211,26 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
187
211
|
const root = path.resolve(cwd);
|
|
188
212
|
const home = (env && env.HOME) || (env && env.USERPROFILE) || os.homedir();
|
|
189
213
|
|
|
190
|
-
// Shell rc + dotfile candidates.
|
|
191
214
|
const shellRcs = [
|
|
192
215
|
".bashrc", ".bash_profile", ".zshrc", ".zprofile", ".profile",
|
|
193
216
|
path.join(".config", "fish", "config.fish"),
|
|
194
217
|
].map(rel => path.join(home, rel));
|
|
195
|
-
// Glob fish/conf.d/*.
|
|
196
218
|
try {
|
|
197
219
|
const fishConfD = path.join(home, ".config", "fish", "conf.d");
|
|
198
220
|
for (const e of fs.readdirSync(fishConfD)) {
|
|
199
221
|
if (e.endsWith(".fish")) shellRcs.push(path.join(fishConfD, e));
|
|
200
222
|
}
|
|
201
|
-
} 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
|
+
}
|
|
202
234
|
|
|
203
235
|
const dotfileKeys = [
|
|
204
236
|
".openai", ".anthropic",
|
|
@@ -208,13 +240,24 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
208
240
|
path.join(".config", "azure-openai"),
|
|
209
241
|
].map(rel => path.join(home, rel));
|
|
210
242
|
|
|
211
|
-
|
|
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
|
+
];
|
|
212
250
|
const cleartextHitsByFile = {};
|
|
251
|
+
const cleartextUnread = [];
|
|
213
252
|
let cleartextFp = null;
|
|
214
|
-
for (const p of allKeyCarriers) {
|
|
253
|
+
for (const { path: p, artifact_id } of allKeyCarriers) {
|
|
215
254
|
if (!fileExists(p)) continue;
|
|
216
|
-
const c = readSafe(p
|
|
217
|
-
|
|
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; }
|
|
218
261
|
const hits = scanShellRc(c);
|
|
219
262
|
if (hits.length > 0) {
|
|
220
263
|
cleartextHitsByFile[path.relative(home, p)] = hits;
|
|
@@ -227,35 +270,70 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
227
270
|
}
|
|
228
271
|
const cleartextAnyHit = Object.keys(cleartextHitsByFile).length > 0;
|
|
229
272
|
|
|
230
|
-
// AWS / GCP / kube reuse.
|
|
231
273
|
const awsCredsPath = path.join(home, ".aws", "credentials");
|
|
232
|
-
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;
|
|
233
280
|
const awsParsed = parseAwsCredentials(awsCredsContent);
|
|
234
281
|
const longLivedAws = awsParsed.staticProfiles.length > 0;
|
|
235
282
|
|
|
236
283
|
const gcloudAdcPath = path.join(home, ".config", "gcloud", "application_default_credentials.json");
|
|
237
|
-
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;
|
|
238
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
|
+
}
|
|
239
298
|
|
|
240
299
|
const kubeCfgPath = (env && env.KUBECONFIG) || path.join(home, ".kube", "config");
|
|
241
|
-
|
|
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;
|
|
242
314
|
const kubeParsed = parseKubeStaticToken(kubeContent);
|
|
243
315
|
const kubeStaticToken = kubeParsed.found;
|
|
244
316
|
|
|
317
|
+
const awsState = storeState(awsCredsExists, awsCredsContent);
|
|
318
|
+
const gcloudState = storeState(gcloudAdcExists, gcloudContent);
|
|
319
|
+
const kubeState = storeState(kubeCfgExists, kubeContent);
|
|
320
|
+
|
|
245
321
|
const signal_overrides = {
|
|
246
|
-
"cleartext-api-key-in-dotfile": cleartextAnyHit
|
|
247
|
-
"long-lived-aws-keys": longLivedAws
|
|
248
|
-
|
|
249
|
-
|
|
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),
|
|
250
332
|
};
|
|
251
333
|
|
|
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`.
|
|
334
|
+
// Per-indicator __fp_checks attestation. Path checks hold because every store
|
|
335
|
+
// read here is a canonical home path; network and STS-validity checks stay
|
|
336
|
+
// unattested. With no attestation at all, a real hit downgrades to inconclusive.
|
|
259
337
|
if (cleartextAnyHit && cleartextFp && cleartextFp.size) {
|
|
260
338
|
const att = {};
|
|
261
339
|
for (const idx of cleartextFp) att[idx] = true;
|
|
@@ -278,8 +356,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
278
356
|
// [1] client_email is a real *@*.gserviceaccount.com (not example/test)
|
|
279
357
|
const ce = gcloudParsed.clientEmail || "";
|
|
280
358
|
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
|
|
359
|
+
// [2] canonical ADC path, with no GOOGLE_APPLICATION_CREDENTIALS redirect
|
|
283
360
|
if (!(env && env.GOOGLE_APPLICATION_CREDENTIALS)) att["2"] = true;
|
|
284
361
|
if (Object.keys(att).length) signal_overrides["gcp-service-account-json__fp_checks"] = att;
|
|
285
362
|
}
|
|
@@ -305,15 +382,32 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
305
382
|
value: dotfileKeys.filter(p => fileExists(p)).map(p => path.relative(home, p)).join(", ") || "no AI vendor dotfile carriers found at the canonical paths",
|
|
306
383
|
captured: true,
|
|
307
384
|
},
|
|
308
|
-
|
|
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
|
|
309
392
|
? { value: `${awsParsed.staticProfiles.length} long-lived profile(s): ${awsParsed.staticProfiles.join(", ") || "none"}`, captured: true }
|
|
310
|
-
:
|
|
311
|
-
|
|
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
|
|
312
400
|
? { value: `application_default_credentials.json present; service_account=${gcloudParsed.hasServiceAccount}`, captured: true }
|
|
313
|
-
:
|
|
314
|
-
|
|
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
|
|
315
407
|
? { value: `kubeconfig present; static_token=${kubeStaticToken}`, captured: true }
|
|
316
|
-
:
|
|
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 },
|
|
317
411
|
"ai-sdk-inventory": {
|
|
318
412
|
value: "skipped — npm/pip global listing deferred to operator/AI evidence",
|
|
319
413
|
captured: false,
|