@blamejs/exceptd-skills 0.13.35 → 0.13.37

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/AGENTS.md CHANGED
@@ -372,7 +372,7 @@ This split costs every consumer the same translation work on every invocation. C
372
372
  exceptd collect secrets | exceptd run secrets --evidence -
373
373
  ```
374
374
 
375
- The collector library is small and grows as playbooks are touched. Three reference collectors ship today (`lib/collectors/secrets.js`, `lib/collectors/kernel.js`, `lib/collectors/sbom.js`); the rest are written when each playbook needs them. Until a playbook has a collector, the AI/operator owns evidence collection as before.
375
+ The collector library is small and grows as playbooks are touched. Six reference collectors ship today (`lib/collectors/secrets.js`, `lib/collectors/kernel.js`, `lib/collectors/sbom.js`, `lib/collectors/containers.js`, `lib/collectors/library-author.js`, `lib/collectors/crypto-codebase.js`); the rest are written when each playbook needs them. Until a playbook has a collector, the AI/operator owns evidence collection as before.
376
376
 
377
377
  ### Precision target for new `look.artifacts[].source` strings
378
378
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.37 — 2026-05-20
4
+
5
+ Sixth reference collector.
6
+
7
+ ### Features
8
+
9
+ - **`lib/collectors/crypto-codebase.js`** — audits a consumer repo for cryptographic-primitive misuse. Walks source files (JS/TS/Python/Go/Rust/Java/Ruby/PHP/C/C++/C#/Swift/Obj-C) and flips eight deterministic indicators from the `crypto-codebase` playbook: `weak-hash-import` (MD5 / SHA-1 with same-file flow into auth / integrity / token variables), `weak-cipher-mode` (AES-ECB, DES / 3DES, RC4), `rsa-1024-anywhere` (`modulusLength: 1024` and variants), `math-random-in-security-path` (`Math.random` / `random.random` / `mt_rand` / `rand()` within 200 chars of a `token` / `secret` / `key` / `salt` / `nonce` / `iv` / `seed` / `state` / `jwt` / `csrf` / `session` variable assignment), `pbkdf2-under-iterated` (OWASP 2023 thresholds — SHA256 < 600,000 / SHA512 < 210,000 / SHA1 < 1,300,000), `bcrypt-cost-low` (< 12), `hardcoded-key-material` (PEM markers outside test / spec / fixture / example / sample / demo / doc paths), `tls-old-protocol` (TLSv1.0 / TLSv1.1 / SSLv3 / SSLv23 in `secureProtocol` / `minVersion` / `ssl_version`). Three indicators flip conditionally: `ecdsa-without-pqc-roadmap` fires when classical signature use is observed AND no PQC sig impl AND no hybrid-migration roadmap in README / SECURITY.md; `no-ml-kem-implementation` fires when the library claims PQC-ready in README / SECURITY.md AND no ML-KEM / Kyber / liboqs / noble-post-quantum / oqsprovider call site exists; `fips-claim-without-runtime-activation` fires when the library claims FIPS validation AND no `crypto.setFips(true)` / `OSSL_PROVIDER_load(*, "fips", *)` / `Provider::load(*, "fips")` call site exists. `vendored-pqc-no-provenance` fires when a `vendor/` / `third_party/` subdirectory contains a Kyber / Dilithium / SLH-DSA / SPHINCS+ / Falcon source file AND no `_PROVENANCE.json` / `UPSTREAM` / `ORIGIN` / `.upstream-commit` / `PROVENANCE.md` marker exists at any ancestor up to the vendor root. `no-crypto-agility-abstraction` (behavioral / interface-shape) is left unflipped so the runner returns inconclusive — that one requires operator review of the public API surface.
10
+
11
+ ## 0.13.36 — 2026-05-20
12
+
13
+ Fifth reference collector.
14
+
15
+ ### Features
16
+
17
+ - **`lib/collectors/library-author.js`** — audits a publisher-side repository for supply-chain posture markers. Flips 11 deterministic indicators: `publish-workflow-uses-static-token`, `publish-workflow-no-id-token-write`, `publish-workflow-action-refs-mutable` (any `uses: <action>@<ref>` where ref isn't a 40-char SHA), `release-workflow-non-frozen-install` (`npm install` vs `npm ci`, cargo without `--locked`), `publish-workflow-runs-on-self-hosted`, `package-json-provenance-missing` (no `publishConfig.provenance: true`), `lockfile-missing-integrity`, `sbom-absent-or-unsigned`, `no-security-md`, `no-security-txt`, `vendored-no-provenance`. Indicators that require GitHub API / sigstore lookup / GPG identity inspection (`tag-protection-absent`, `private-vuln-reporting-disabled`, `no-rekor-entry-for-latest-release`, etc.) are left unflipped — operator-supplied evidence remains the path for those.
18
+
3
19
  ## 0.13.35 — 2026-05-20
4
20
 
5
21
  Fourth reference collector + a sbom-collector indicator-pattern correction.
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-21T00:30:25.921Z",
3
+ "generated_at": "2026-05-21T03:49:45.015Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 54,
6
6
  "source_hashes": {
7
- "manifest.json": "61181a50546ab0616fcaffabaf0316c4276705f5d25c03fe9248916416b404f6",
7
+ "manifest.json": "5eeb28b7edf5ebf368b201b1988d0b6ccbb7ca838ee7fe45e63a878e7d3650f9",
8
8
  "data/atlas-ttps.json": "d296c1d3e71807c9279b731f047e57796e85137f186586743a8cdad214b408f9",
9
9
  "data/attack-techniques.json": "49b6010b317edd219def135171ea8f3b1bbf1e00e9c5a08bf7237215ff54e2c3",
10
10
  "data/cve-catalog.json": "a09c83af3f9679a7ea73935726a1ff9de2cab94b4ab6321fc017fc147747d7c3",
@@ -0,0 +1,438 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * lib/collectors/crypto-codebase.js
5
+ *
6
+ * Companion collector for the `crypto-codebase` playbook. Walks the
7
+ * cwd tree, grepping source files for hash / cipher / KEX / signature
8
+ * / KDF / RNG / TLS / PQC / FIPS call sites. Flips signal_overrides
9
+ * only for indicators whose verdict can be determined deterministically
10
+ * from the codebase scan; behavioral indicators that require operator
11
+ * judgement (e.g. crypto-agility abstraction shape) are left unflipped
12
+ * so the runner returns inconclusive rather than a forced miss.
13
+ *
14
+ * Interface: see lib/collectors/README.md
15
+ */
16
+
17
+ const fs = require("node:fs");
18
+ const path = require("node:path");
19
+
20
+ const COLLECTOR_ID = "crypto-codebase";
21
+
22
+ const DEFAULT_MAX_DEPTH = 6;
23
+ const DEFAULT_EXCLUDES = new Set([
24
+ "node_modules", ".git", "dist", "build", "out",
25
+ ".venv", "venv", "__pycache__", ".pytest_cache",
26
+ "target", ".idea", ".vscode",
27
+ ]);
28
+
29
+ const SOURCE_EXTS = new Set([
30
+ ".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".mts", ".cts",
31
+ ".py", ".pyi",
32
+ ".go",
33
+ ".rs",
34
+ ".java", ".kt", ".kts", ".scala",
35
+ ".rb",
36
+ ".php",
37
+ ".c", ".h", ".cc", ".cpp", ".hpp", ".cxx",
38
+ ".cs",
39
+ ".swift",
40
+ ".m", ".mm",
41
+ ]);
42
+
43
+ const TEST_PATH_SEGMENTS = [
44
+ "/test/", "/tests/", "/spec/", "/specs/", "/__tests__/",
45
+ "/fixtures/", "/fixture/", "/examples/", "/example/",
46
+ "/docs/", "/doc/", "/sample/", "/samples/", "/demo/", "/demos/",
47
+ "/benchmarks/", "/benchmark/", "/bench/",
48
+ // Files whose purpose is grepping for these patterns — their source
49
+ // literally contains the patterns, so production-scope scans would
50
+ // match the scanner itself. The crypto-codebase playbook's intent
51
+ // is the consumer's source, not the scanner's regex catalogue.
52
+ "/lib/collectors/", "/scripts/check-version-tags",
53
+ ];
54
+
55
+ const MAX_FILE_BYTES = 1024 * 1024;
56
+
57
+ function isTestPath(rel) {
58
+ const norm = "/" + rel.replace(/\\/g, "/").toLowerCase() + "/";
59
+ for (const seg of TEST_PATH_SEGMENTS) {
60
+ if (norm.includes(seg)) return true;
61
+ }
62
+ if (/\.(test|spec)\.[a-z]+$/i.test(rel)) return true;
63
+ return false;
64
+ }
65
+
66
+ function walkTree(root, opts = {}) {
67
+ const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
68
+ const excludes = opts.excludes ?? DEFAULT_EXCLUDES;
69
+ const out = [];
70
+ const seen = new Set();
71
+
72
+ function walk(dir, depth) {
73
+ if (depth > maxDepth) return;
74
+ let entries;
75
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
76
+ catch { return; }
77
+ for (const entry of entries) {
78
+ if (excludes.has(entry.name)) continue;
79
+ const full = path.join(dir, entry.name);
80
+ let real;
81
+ try { real = fs.realpathSync(full); } catch { continue; }
82
+ if (seen.has(real)) continue;
83
+ seen.add(real);
84
+ if (entry.isDirectory()) {
85
+ walk(full, depth + 1);
86
+ } else if (entry.isFile()) {
87
+ out.push({ full, rel: path.relative(root, full), name: entry.name });
88
+ }
89
+ }
90
+ }
91
+ walk(root, 0);
92
+ return out;
93
+ }
94
+
95
+ function readSafe(full) {
96
+ try {
97
+ const s = fs.statSync(full);
98
+ if (s.size > MAX_FILE_BYTES) return null;
99
+ return fs.readFileSync(full, "utf8");
100
+ } catch { return null; }
101
+ }
102
+
103
+ const WEAK_HASH_RE = /(?:crypto\.createHash\(\s*['"](?:md5|sha1|sha-1)['"]|hashlib\.(?:md5|sha1)\s*\(|MessageDigest\.getInstance\(\s*['"](?:MD5|SHA-1|SHA1)['"]|crypto\/(?:md5|sha1)\b|Digest::(?:MD5|SHA1)\b)/i;
104
+ const WEAK_HASH_VAR_FLOW_RE = /(hash|hmac|sign|signature|integrity|token|jwt|verify|password)/i;
105
+
106
+ const WEAK_CIPHER_ECB_RE = /(?:aes-\d+-ecb|AES\/ECB\/|Cipher\.getInstance\(\s*['"]AES['"]\s*\))/i;
107
+ const WEAK_CIPHER_DES_RE = /(?:des-cbc|des-ede3|\bDES\/|\bDESede\/|["']3des["']|["']des["']|\bDES_(?:set|encrypt|decrypt|cbc))/i;
108
+ const WEAK_CIPHER_RC4_RE = /(?:["']rc4["']|\barc4\b|\bARCFOUR\b)/i;
109
+
110
+ const RSA_1024_RE = /(?:modulusLength\s*:\s*1024|key_size\s*=\s*1024|["']rsa["']\s*,\s*1024|RSA(?:KeyPair)?Generator[^]{0,80}?1024|Generate\w+Key\([^)]*1024)/;
111
+
112
+ const MATH_RANDOM_GLOBAL_RE = /\b(?:Math\.random\(|random\.random\(|random\.randint\(|random\.choice\(|mt_rand\(|srand\(|rand\(\s*\))/g;
113
+ const SECURITY_VAR_RE = /\b(?:token|secret|key|salt|nonce|iv|seed|state|jwt|jti|csrf|session)\w*\s*[:=]/i;
114
+
115
+ const PBKDF2_BLOCK_GLOBAL_RE = /\b(?:pbkdf2(?:Sync)?|hashlib\.pbkdf2_hmac)\s*\([^)]{0,400}/g;
116
+
117
+ const BCRYPT_BLOCK_GLOBAL_RE = /\b(?:bcrypt\.(?:hash|hashSync|gen_salt|genSalt|genSaltSync)|BCrypt::Password\.create)\s*\([^)]{0,200}/g;
118
+ // Captures either named-arg form (`cost: 12` / `rounds=12`) or the
119
+ // positional-arg trailing-integer form. Match against the block
120
+ // captured by BCRYPT_BLOCK_GLOBAL_RE — which truncates at the first
121
+ // `)`, so the trailing-int branch must match `, <digits>` followed
122
+ // by either end-of-block or whitespace, not a closing paren.
123
+ const BCRYPT_COST_RE = /(?:cost|rounds)\s*[:=]\s*(\d+)|,\s*(\d+)\s*$/;
124
+
125
+ const PEM_RE = /-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?(?:PRIVATE|PUBLIC) KEY-----|-----BEGIN CERTIFICATE-----/;
126
+
127
+ const TLS_OLD_PROTO_RE = /(?:secureProtocol\s*:\s*['"](?:TLSv1_method|TLSv1_1_method|SSLv23_method|SSLv3_method)['"]|minVersion\s*:\s*['"]TLSv1(?:\.0|\.1)?['"]|ssl_version\s*=\s*ssl\.PROTOCOL_TLSv1(?:_1)?|MinTlsVersion::TLSv1\b)/i;
128
+
129
+ const FIPS_ACTIVATION_RE = /(?:OSSL_PROVIDER_load\([^)]*\bfips\b|crypto\.setFips\s*\(\s*true|Provider::load[^,]*,\s*["']fips["']|set_fips_mode\s*\()/i;
130
+ const FIPS_CLAIM_RE = /\bfips(?:[- _]?(?:validated|compliance|compliant|140-?[23]|mode))\b/i;
131
+
132
+ const ML_KEM_IMPL_RE = /(?:ml[-_]?kem|kyber|noble-post-quantum|liboqs|oqsprovider|EVP_KEM_|OQS_KEM_|pqcrypto::|aws-lc-rs::pqc|circl\/kem\/kyber)/i;
133
+ const PQC_CLAIM_RE = /\b(?:pqc(?:[- _]?ready)?|post[- _]?quantum)\b/i;
134
+
135
+ const CLASSICAL_SIG_RE = /\b(?:ECDSA|secp(?:256k1|256r1|384r1|521r1)|Ed25519|Ed448|RSA-?PSS|RSA-?PKCS1)\b/i;
136
+ const PQC_SIG_IMPL_RE = /\b(?:ml[-_]?dsa|dilithium|slh[-_]?dsa|sphincs)\b/i;
137
+ const HYBRID_ROADMAP_RE = /\b(?:hybrid[- _](?:signature|pqc)|(?:pqc|post[- _]?quantum)[- _](?:migration|roadmap|timeline))\b/i;
138
+
139
+ const VENDORED_PQC_NAMES_RE = /(?:kyber|dilithium|sphincs|ml[-_]?kem|ml[-_]?dsa|slh[-_]?dsa|falcon)/i;
140
+
141
+ function scanWeakHash(content) {
142
+ if (!WEAK_HASH_RE.test(content)) return false;
143
+ return WEAK_HASH_VAR_FLOW_RE.test(content);
144
+ }
145
+
146
+ function scanMathRandom(content) {
147
+ const matches = [];
148
+ for (const m of content.matchAll(MATH_RANDOM_GLOBAL_RE)) {
149
+ const start = Math.max(0, m.index - 200);
150
+ const end = Math.min(content.length, m.index + 200);
151
+ const window = content.slice(start, end);
152
+ if (SECURITY_VAR_RE.test(window)) {
153
+ matches.push({ offset: m.index, snippet: m[0] });
154
+ }
155
+ }
156
+ return matches;
157
+ }
158
+
159
+ function scanPbkdf2(content) {
160
+ const hits = [];
161
+ for (const m of content.matchAll(PBKDF2_BLOCK_GLOBAL_RE)) {
162
+ const block = m[0];
163
+ let threshold = 210000;
164
+ if (/sha[-_]?256/i.test(block)) threshold = 600000;
165
+ else if (/sha1\b/i.test(block) || /sha-1\b/i.test(block)) threshold = 1300000;
166
+ // Take the max 4+ digit literal as the iteration count. Don't
167
+ // pre-filter common key-bit-size values (256/384/512/1024) — a
168
+ // call like `pbkdf2Sync(pw, salt, 1024, 32, 'sha256')` IS under-
169
+ // iterated at 1024 and must hit. The max() picks iteration over
170
+ // keylen in the typical positional shape (iter, keylen, algo).
171
+ const nums = [];
172
+ for (const nm of block.matchAll(/\b(\d{4,8})\b/g)) {
173
+ nums.push(Number(nm[1]));
174
+ }
175
+ if (nums.length === 0) continue;
176
+ const iter = Math.max(...nums);
177
+ if (iter < threshold) {
178
+ hits.push({ offset: m.index, threshold, iter });
179
+ }
180
+ }
181
+ return hits;
182
+ }
183
+
184
+ function scanBcrypt(content) {
185
+ const hits = [];
186
+ for (const m of content.matchAll(BCRYPT_BLOCK_GLOBAL_RE)) {
187
+ const block = m[0];
188
+ const cm = block.match(BCRYPT_COST_RE);
189
+ if (!cm) continue;
190
+ const cost = Number(cm[1] || cm[2]);
191
+ if (!Number.isFinite(cost) || cost === 0) continue;
192
+ if (cost < 12) hits.push({ offset: m.index, cost });
193
+ }
194
+ return hits;
195
+ }
196
+
197
+ function isVendored(rel) {
198
+ const norm = "/" + rel.replace(/\\/g, "/").toLowerCase() + "/";
199
+ return /\/(?:vendor|third_party|3rdparty|external|deps)\//.test(norm);
200
+ }
201
+
202
+ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
203
+ const errors = [];
204
+ const startTime = Date.now();
205
+ const root = path.resolve(cwd);
206
+
207
+ let files;
208
+ try {
209
+ files = walkTree(root);
210
+ } catch (e) {
211
+ errors.push({ kind: "walk_failed", reason: e.message });
212
+ files = [];
213
+ }
214
+ if (files.length > 50000) {
215
+ errors.push({
216
+ kind: "file_count_capped",
217
+ reason: `walked ${files.length} files; capping content scan at 50000.`,
218
+ });
219
+ files = files.slice(0, 50000);
220
+ }
221
+
222
+ const sourceFiles = files.filter(f => SOURCE_EXTS.has(path.extname(f.name).toLowerCase()));
223
+
224
+ const hits = {
225
+ "weak-hash-import": [],
226
+ "weak-cipher-mode": [],
227
+ "rsa-1024-anywhere": [],
228
+ "math-random-in-security-path": [],
229
+ "pbkdf2-under-iterated": [],
230
+ "bcrypt-cost-low": [],
231
+ "hardcoded-key-material": [],
232
+ "tls-old-protocol": [],
233
+ };
234
+
235
+ let sawClassicalSig = false;
236
+ let sawPqcSigImpl = false;
237
+ let sawHybridRoadmap = false;
238
+ let sawPqcClaim = false;
239
+ let sawMlKemImpl = false;
240
+ let sawFipsClaim = false;
241
+ let sawFipsActivation = false;
242
+
243
+ for (const f of sourceFiles) {
244
+ const content = readSafe(f.full);
245
+ if (content == null) {
246
+ errors.push({ artifact_id: "source-files", kind: "read_failed", reason: f.rel });
247
+ continue;
248
+ }
249
+ const isTest = isTestPath(f.rel);
250
+
251
+ if (!isTest) {
252
+ if (scanWeakHash(content)) {
253
+ hits["weak-hash-import"].push({ file: f.rel });
254
+ }
255
+ if (WEAK_CIPHER_ECB_RE.test(content) || WEAK_CIPHER_DES_RE.test(content) || WEAK_CIPHER_RC4_RE.test(content)) {
256
+ hits["weak-cipher-mode"].push({ file: f.rel });
257
+ }
258
+ if (RSA_1024_RE.test(content)) {
259
+ hits["rsa-1024-anywhere"].push({ file: f.rel });
260
+ }
261
+ const mrHits = scanMathRandom(content);
262
+ for (const h of mrHits) hits["math-random-in-security-path"].push({ file: f.rel, offset: h.offset });
263
+
264
+ const pHits = scanPbkdf2(content);
265
+ for (const h of pHits) hits["pbkdf2-under-iterated"].push({ file: f.rel, iter: h.iter, threshold: h.threshold });
266
+
267
+ const bHits = scanBcrypt(content);
268
+ for (const h of bHits) hits["bcrypt-cost-low"].push({ file: f.rel, cost: h.cost });
269
+
270
+ if (PEM_RE.test(content)) {
271
+ hits["hardcoded-key-material"].push({ file: f.rel });
272
+ }
273
+ if (TLS_OLD_PROTO_RE.test(content)) {
274
+ hits["tls-old-protocol"].push({ file: f.rel });
275
+ }
276
+ }
277
+
278
+ // Cross-file evidence for the conditional indicators (ecdsa-
279
+ // without-pqc-roadmap, no-ml-kem-implementation, fips-claim-
280
+ // without-runtime-activation). Production-context only — a
281
+ // PQC / FIPS reference inside `tests/` / `fixtures/` / `examples/`
282
+ // doesn't count as evidence the library SHIPS that capability.
283
+ if (!isTest) {
284
+ if (CLASSICAL_SIG_RE.test(content)) sawClassicalSig = true;
285
+ if (PQC_SIG_IMPL_RE.test(content)) sawPqcSigImpl = true;
286
+ if (ML_KEM_IMPL_RE.test(content)) sawMlKemImpl = true;
287
+ if (FIPS_ACTIVATION_RE.test(content)) sawFipsActivation = true;
288
+ }
289
+ }
290
+
291
+ const docFiles = files.filter(f =>
292
+ /^README(\.md|\.rst|\.txt)?$/i.test(f.name) ||
293
+ /^SECURITY\.md$/i.test(f.name) ||
294
+ /^package\.json$/i.test(f.name) ||
295
+ /^Cargo\.toml$/i.test(f.name) ||
296
+ /^pyproject\.toml$/i.test(f.name)
297
+ );
298
+ for (const f of docFiles) {
299
+ const content = readSafe(f.full);
300
+ if (content == null) continue;
301
+ if (PQC_CLAIM_RE.test(content)) sawPqcClaim = true;
302
+ if (HYBRID_ROADMAP_RE.test(content)) sawHybridRoadmap = true;
303
+ if (FIPS_CLAIM_RE.test(content)) sawFipsClaim = true;
304
+ }
305
+
306
+ const vendoredPqcFiles = files.filter(f => isVendored(f.rel) && VENDORED_PQC_NAMES_RE.test(f.rel));
307
+ let vendoredPqcNoProvenance = "miss";
308
+ if (vendoredPqcFiles.length > 0) {
309
+ const provenanceMarkers = new Set(["_PROVENANCE.json", "UPSTREAM", "ORIGIN", ".upstream-commit", "PROVENANCE.md"]);
310
+ // Walk from the file's directory up to the repo root, checking
311
+ // each ancestor for a provenance marker. The marker can live at
312
+ // the immediate sibling (`vendor/kyber/_PROVENANCE.json`), at
313
+ // the vendor root (`vendor/_PROVENANCE.json`), or anywhere in
314
+ // between for deeply-nested vendor trees. Stop at the repo root
315
+ // (cwd) so we don't escape into the parent filesystem.
316
+ let anyMissing = false;
317
+ for (const f of vendoredPqcFiles) {
318
+ let dir = path.dirname(f.full);
319
+ let found = false;
320
+ while (true) {
321
+ let entries;
322
+ try { entries = fs.readdirSync(dir); } catch { break; }
323
+ if (entries.some(e => provenanceMarkers.has(e))) { found = true; break; }
324
+ if (path.resolve(dir) === root) break;
325
+ const parent = path.dirname(dir);
326
+ if (parent === dir) break;
327
+ // Guard against escaping the repo root via symlinks.
328
+ if (path.relative(root, parent).startsWith("..")) break;
329
+ dir = parent;
330
+ }
331
+ if (!found) { anyMissing = true; break; }
332
+ }
333
+ vendoredPqcNoProvenance = anyMissing ? "hit" : "miss";
334
+ }
335
+
336
+ let ecdsaWithoutRoadmap;
337
+ if (sawClassicalSig) {
338
+ ecdsaWithoutRoadmap = (!sawPqcSigImpl && !sawHybridRoadmap) ? "hit" : "miss";
339
+ }
340
+
341
+ let noMlKemImpl;
342
+ if (sawPqcClaim) {
343
+ noMlKemImpl = sawMlKemImpl ? "miss" : "hit";
344
+ }
345
+
346
+ let fipsTheater;
347
+ if (sawFipsClaim) {
348
+ fipsTheater = sawFipsActivation ? "miss" : "hit";
349
+ }
350
+
351
+ const signal_overrides = {};
352
+ for (const id of Object.keys(hits)) {
353
+ signal_overrides[id] = hits[id].length > 0 ? "hit" : "miss";
354
+ }
355
+ signal_overrides["vendored-pqc-no-provenance"] = vendoredPqcNoProvenance;
356
+ if (ecdsaWithoutRoadmap !== undefined) signal_overrides["ecdsa-without-pqc-roadmap"] = ecdsaWithoutRoadmap;
357
+ if (noMlKemImpl !== undefined) signal_overrides["no-ml-kem-implementation"] = noMlKemImpl;
358
+ if (fipsTheater !== undefined) signal_overrides["fips-claim-without-runtime-activation"] = fipsTheater;
359
+
360
+ const summarize = (id) => {
361
+ const list = hits[id];
362
+ if (list.length === 0) return "0 hits";
363
+ const head = list.slice(0, 5).map(h => h.file + (h.iter ? ` (iter=${h.iter}<${h.threshold})` : "") + (h.cost ? ` (cost=${h.cost})` : "")).join("; ");
364
+ return `${list.length} hit(s): ${head}` + (list.length > 5 ? "; …" : "");
365
+ };
366
+
367
+ const artifacts = {
368
+ "package-manifests": {
369
+ value: docFiles.filter(f => /(package\.json|Cargo\.toml|pyproject\.toml)/i.test(f.name)).map(f => f.rel).join(", ") || "no manifest found at root",
370
+ captured: true,
371
+ },
372
+ "hash-primitive-call-sites": {
373
+ value: summarize("weak-hash-import"),
374
+ captured: true,
375
+ },
376
+ "cipher-and-kex-call-sites": {
377
+ value: summarize("weak-cipher-mode"),
378
+ captured: true,
379
+ },
380
+ "signature-call-sites": {
381
+ value: sawClassicalSig
382
+ ? `classical signature use observed; pqc_sig_impl=${sawPqcSigImpl}; hybrid_roadmap=${sawHybridRoadmap}`
383
+ : "no signature call sites detected",
384
+ captured: true,
385
+ },
386
+ "kdf-call-sites": {
387
+ value: `pbkdf2 under-iterated: ${summarize("pbkdf2-under-iterated")}; bcrypt low: ${summarize("bcrypt-cost-low")}`,
388
+ captured: true,
389
+ },
390
+ "rng-call-sites": {
391
+ value: summarize("math-random-in-security-path"),
392
+ captured: true,
393
+ },
394
+ "hardcoded-key-material": {
395
+ value: summarize("hardcoded-key-material"),
396
+ captured: true,
397
+ },
398
+ "tls-config-construction": {
399
+ value: summarize("tls-old-protocol"),
400
+ captured: true,
401
+ },
402
+ "pqc-adoption-signals": {
403
+ value: `pqc_claim=${sawPqcClaim}; ml_kem_impl=${sawMlKemImpl}; pqc_sig_impl=${sawPqcSigImpl}; hybrid_roadmap=${sawHybridRoadmap}`,
404
+ captured: true,
405
+ },
406
+ "fips-provider-activation": {
407
+ value: `fips_claim=${sawFipsClaim}; fips_activation_in_source=${sawFipsActivation}`,
408
+ captured: true,
409
+ },
410
+ "vendored-crypto-tree": {
411
+ value: vendoredPqcFiles.length
412
+ ? vendoredPqcFiles.slice(0, 5).map(f => f.rel).join("; ") + (vendoredPqcFiles.length > 5 ? "; …" : "")
413
+ : "no vendored PQC primitives detected",
414
+ captured: true,
415
+ },
416
+ };
417
+
418
+ return {
419
+ precondition_checks: {
420
+ "repo-context": sourceFiles.length > 0,
421
+ },
422
+ artifacts,
423
+ signal_overrides,
424
+ collector_meta: {
425
+ collector_id: COLLECTOR_ID,
426
+ collector_version: "2026-05-20",
427
+ platform: process.platform,
428
+ captured_at: new Date().toISOString(),
429
+ cwd: root,
430
+ duration_ms: Date.now() - startTime,
431
+ files_walked: files.length,
432
+ source_files_scanned: sourceFiles.length,
433
+ },
434
+ collector_errors: errors,
435
+ };
436
+ }
437
+
438
+ module.exports = { playbook_id: COLLECTOR_ID, collect };