@blamejs/exceptd-skills 0.19.32 → 0.19.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/exceptd.js +896 -2824
  3. package/data/_indexes/_meta.json +8 -8
  4. package/data/_indexes/activity-feed.json +2 -2
  5. package/data/_indexes/catalog-summaries.json +7 -7
  6. package/data/_indexes/chains.json +60118 -0
  7. package/data/attack-techniques.json +267 -7
  8. package/data/cve-catalog.json +9991 -3
  9. package/data/cwe-catalog.json +109 -2
  10. package/data/framework-control-gaps.json +578 -3
  11. package/data/zeroday-lessons.json +8330 -1
  12. package/lib/auto-discovery.js +56 -286
  13. package/lib/canonical-eq.js +7 -40
  14. package/lib/citation-resolve.js +22 -70
  15. package/lib/collectors/ai-api.js +20 -54
  16. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  17. package/lib/collectors/citation-hygiene.js +72 -210
  18. package/lib/collectors/containers.js +41 -130
  19. package/lib/collectors/cred-stores.js +31 -115
  20. package/lib/collectors/crypto-codebase.js +55 -138
  21. package/lib/collectors/crypto.js +24 -54
  22. package/lib/collectors/hardening.js +20 -78
  23. package/lib/collectors/kernel.js +16 -46
  24. package/lib/collectors/library-author.js +57 -206
  25. package/lib/collectors/mcp.js +24 -70
  26. package/lib/collectors/runtime.js +24 -86
  27. package/lib/collectors/sbom.js +34 -106
  28. package/lib/collectors/scan-excludes.js +31 -138
  29. package/lib/collectors/secrets.js +62 -178
  30. package/lib/cross-ref-api.js +39 -123
  31. package/lib/currency-severity.js +8 -27
  32. package/lib/cve-batch.js +13 -21
  33. package/lib/cve-cli.js +13 -20
  34. package/lib/cve-curation.js +72 -239
  35. package/lib/cve-regression-watcher.js +29 -152
  36. package/lib/cvss.js +13 -54
  37. package/lib/doctor-bucketing.js +3 -19
  38. package/lib/exit-codes.js +10 -42
  39. package/lib/flag-suggest.js +7 -25
  40. package/lib/framework-gap.js +35 -114
  41. package/lib/gap-detectors.js +37 -159
  42. package/lib/id-validation.js +9 -30
  43. package/lib/job-queue.js +13 -36
  44. package/lib/lint-skills.js +64 -232
  45. package/lib/playbook-runner.js +693 -2095
  46. package/lib/prefetch.js +100 -376
  47. package/lib/refresh-external.js +199 -627
  48. package/lib/refresh-network.js +75 -307
  49. package/lib/rfc-cli.js +23 -68
  50. package/lib/scoring.js +77 -145
  51. package/lib/sign.js +43 -229
  52. package/lib/source-advisories.js +43 -194
  53. package/lib/source-ghsa.js +37 -120
  54. package/lib/source-osv.js +94 -266
  55. package/lib/ttp-mapper.js +14 -24
  56. package/lib/upstream-check-cli.js +10 -28
  57. package/lib/upstream-check.js +19 -44
  58. package/lib/validate-catalog-meta.js +17 -61
  59. package/lib/validate-cve-catalog.js +43 -119
  60. package/lib/validate-indexes.js +25 -76
  61. package/lib/validate-package.js +16 -62
  62. package/lib/validate-playbooks.js +69 -275
  63. package/lib/validate-vendor.js +16 -49
  64. package/lib/verify.js +56 -286
  65. package/lib/version-pins.js +5 -34
  66. package/lib/worker-pool.js +11 -30
  67. package/lib/xml-tokenizer.js +47 -152
  68. package/manifest.json +53 -53
  69. package/orchestrator/dispatcher.js +17 -68
  70. package/orchestrator/event-bus.js +11 -74
  71. package/orchestrator/index.js +138 -412
  72. package/orchestrator/pipeline.js +28 -85
  73. package/orchestrator/scanner.js +34 -138
  74. package/orchestrator/scheduler.js +20 -84
  75. package/package.json +2 -2
  76. package/sbom.cdx.json +253 -253
  77. package/scripts/audit-catalog-gaps.js +9 -62
  78. package/scripts/audit-cross-skill.js +5 -31
  79. package/scripts/audit-perf.js +6 -16
  80. package/scripts/backfill-theater-test.js +7 -64
  81. package/scripts/bootstrap.js +12 -44
  82. package/scripts/build-indexes.js +40 -154
  83. package/scripts/builders/activity-feed.js +4 -14
  84. package/scripts/builders/catalog-summaries.js +3 -10
  85. package/scripts/builders/currency.js +7 -20
  86. package/scripts/builders/cwe-chains.js +7 -30
  87. package/scripts/builders/did-ladders.js +6 -13
  88. package/scripts/builders/frequency.js +5 -19
  89. package/scripts/builders/jurisdiction-clocks.js +6 -25
  90. package/scripts/builders/recipes.js +6 -14
  91. package/scripts/builders/section-offsets.js +13 -51
  92. package/scripts/builders/stale-content.js +7 -28
  93. package/scripts/builders/summary-cards.js +8 -29
  94. package/scripts/builders/theater-fingerprints.js +12 -27
  95. package/scripts/builders/token-budget.js +4 -31
  96. package/scripts/check-agents-md-collectors.js +11 -54
  97. package/scripts/check-catalog-gap-budget.js +15 -32
  98. package/scripts/check-changelog-extract.js +18 -48
  99. package/scripts/check-codebase-patterns-currency.js +6 -22
  100. package/scripts/check-codebase-patterns.js +50 -143
  101. package/scripts/check-epss-consistency.js +9 -64
  102. package/scripts/check-framework-gap-coverage.js +13 -31
  103. package/scripts/check-manifest-snapshot.js +13 -73
  104. package/scripts/check-sbom-currency.js +44 -142
  105. package/scripts/check-test-count.js +15 -52
  106. package/scripts/check-test-coverage.js +66 -197
  107. package/scripts/check-test-subjects.js +21 -62
  108. package/scripts/check-ttp-references.js +14 -38
  109. package/scripts/check-ttp-upstream.js +8 -40
  110. package/scripts/check-version-bump.js +9 -61
  111. package/scripts/check-version-tags.js +20 -121
  112. package/scripts/predeploy.js +38 -184
  113. package/scripts/refresh-manifest-snapshot.js +16 -38
  114. package/scripts/refresh-mitre-atlas.js +3 -8
  115. package/scripts/refresh-mitre-attack.js +1 -8
  116. package/scripts/refresh-mitre-d3fend.js +3 -9
  117. package/scripts/refresh-mitre-ics-attack.js +3 -8
  118. package/scripts/refresh-reverse-refs.js +27 -94
  119. package/scripts/refresh-rfc-index.js +2 -10
  120. package/scripts/refresh-sbom.js +31 -161
  121. package/scripts/refresh-upstream-catalogs.js +40 -137
  122. package/scripts/release.js +69 -232
  123. package/scripts/run-e2e-scenarios.js +24 -71
  124. package/scripts/sync-manifest-metadata.js +10 -34
  125. package/scripts/sync-package-description.js +8 -17
  126. package/scripts/validate-vendor-online.js +13 -44
  127. package/scripts/verify-shipped-tarball.js +35 -140
@@ -1,21 +1,10 @@
1
1
  "use strict";
2
2
 
3
3
  /**
4
- * lib/collectors/cred-stores.js
5
- *
6
- * Companion collector for the `cred-stores` playbook. Inspects local
7
- * credential carriers (~/.aws/credentials, ~/.kube/config, ~/.docker/
8
- * config.json, ~/.npmrc, ~/.pypirc, ~/.config/gcloud/application_
9
- * default_credentials.json, project-level .npmrc / .pypirc), and
10
- * flips signal_overrides for the deterministic indicators. Defers
11
- * non-deterministic indicators (ssh-key-rsa-short-bits, ssh-key-old,
12
- * gpg-key-old-or-weak, all-stores-empty-or-federated) so the runner
4
+ * Companion collector for the `cred-stores` playbook. Reads $HOME credential
5
+ * dotfiles plus project .npmrc / .pypirc under cwd, flipping signal_overrides
6
+ * for the deterministic indicators only — the rest stay unset so the runner
13
7
  * returns inconclusive rather than a forced miss.
14
- *
15
- * Scope: $HOME credential dotfiles + project-level .npmrc / .pypirc
16
- * under cwd. Posix-mode-bits indicators are skipped on win32 (ACL
17
- * audit out of scope).
18
- *
19
8
  * Interface: see lib/collectors/README.md
20
9
  */
21
10
 
@@ -31,10 +20,8 @@ function readSafe(full, max = 512 * 1024) {
31
20
  fd = fs.openSync(full, "r");
32
21
  const s = fs.fstatSync(fd);
33
22
  if (s.size > max) return null;
34
- // readFileSync(fd) loops read() to EOF — a single readSync may return
35
- // fewer than s.size bytes on network/FUSE/sync-backed fds, which would
36
- // leave the buffer tail NUL-filled and silently drop trailing content.
37
- // Reading via the already-open fd keeps the fstat-then-read TOCTOU-free.
23
+ // readFileSync(fd) loops to EOF — a single readSync can come back short on a
24
+ // network fd and NUL-fill the tail. Reading the open fd is also TOCTOU-free.
38
25
  return fs.readFileSync(fd, "utf8");
39
26
  } catch { return null; }
40
27
  finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } } }
@@ -54,8 +41,6 @@ function fileExists(full) {
54
41
  try { return fs.statSync(full).isFile(); } catch { return false; }
55
42
  }
56
43
 
57
- // AWS credentials INI: any [profile] block carrying
58
- // `aws_access_key_id` AND no `sso_session` / `credential_process`.
59
44
  function parseAwsCredentials(content) {
60
45
  if (!content) return { staticProfiles: [], federatedProfiles: [], staticKeys: {} };
61
46
  const lines = content.split(/\r?\n/);
@@ -77,22 +62,13 @@ function parseAwsCredentials(content) {
77
62
  }
78
63
  const staticProfiles = [];
79
64
  const federatedProfiles = [];
80
- // Per-static-profile aws_access_key_id, so doc-fixture demotion can key off
81
- // the exact parsed value instead of re-finding the first name-matching block.
82
- // A duplicate profile name resolves to the LAST occurrence's keys here, which
83
- // is the same precedence the AWS SDK applies.
65
+ // Per-profile aws_access_key_id. A duplicate profile name resolves to the last
66
+ // occurrence, the precedence the AWS SDK applies.
84
67
  const staticKeys = {};
85
68
  for (const [name, kv] of Object.entries(profiles)) {
86
69
  const hasKey = !!kv["aws_access_key_id"];
87
- // role_arn is NOT a federation marker for this predicate. A profile that
88
- // carries a static aws_access_key_id (AKIA*) alongside a role_arn is the
89
- // canonical source_profile/assume-role setup where the static key IS the
90
- // long-lived credential that bootstraps the assumed role — a real static-key
91
- // exposure. The playbook's aws-static-key-present predicate lists only
92
- // sso_session / credential_process as federation; treating role_arn as
93
- // federation here suppressed a genuinely-present IAM user key. A pure
94
- // assume-role profile (role_arn, no own access key) is still excluded by the
95
- // hasKey gate below.
70
+ // role_arn is not federation: a static key alongside role_arn is the
71
+ // source_profile bootstrap. Federation is sso_session / credential_process.
96
72
  const hasFederation = !!(kv["sso_session"] || kv["credential_process"]);
97
73
  if (hasKey && !hasFederation) {
98
74
  staticProfiles.push(name);
@@ -103,14 +79,10 @@ function parseAwsCredentials(content) {
103
79
  return { staticProfiles, federatedProfiles, staticKeys };
104
80
  }
105
81
 
106
- // kubeconfig: users[].user.token field present (non-empty) with no
107
- // users[].user.exec sibling. Use a tolerant line-based scan rather
108
- // than pulling a YAML parser into the stdlib-only contract.
82
+ // kubeconfig: a non-empty users[].user.token with no users[].user.exec sibling.
83
+ // Line-based rather than parsed, to stay inside the stdlib-only contract.
109
84
  function parseKubeConfig(content) {
110
85
  if (!content) return { hasStaticToken: false, hasExec: false };
111
- // Find every users: block + each `- name: ...` user entry and
112
- // its sub-keys. The kubeconfig schema is regular enough that a
113
- // line-window scan is reliable.
114
86
  const lines = content.split(/\r?\n/);
115
87
  let inUsers = false;
116
88
  let userIndent = -1;
@@ -129,7 +101,6 @@ function parseKubeConfig(content) {
129
101
  continue;
130
102
  }
131
103
  if (buf.length) {
132
- // If we leave the users list (dedent), close current block.
133
104
  if (raw.trim() === "" || /^\S/.test(raw)) {
134
105
  if (/^\S/.test(raw) && !/^users:/.test(raw)) {
135
106
  blocks.push(buf.join("\n")); buf = [];
@@ -143,11 +114,8 @@ function parseKubeConfig(content) {
143
114
  }
144
115
  if (buf.length) blocks.push(buf.join("\n"));
145
116
 
146
- // Match `token:` / `token-data:` ONLY at the user-block indent level
147
- // (i.e. inside `user:`). Auth-provider blocks carry sub-keys like
148
- // `access-token`, `id-token`, `refresh-token` which are dynamic /
149
- // cached tokens, not static-credential evidence. Use a line-prefix
150
- // anchor + auth-provider-vs-user proximity check to refuse those.
117
+ // `token:` / `token-data:` counts only under `user:` — an auth-provider block's
118
+ // tokens are cached dynamic ones, not static-credential evidence.
151
119
  let hasStaticToken = false;
152
120
  let hasExec = false;
153
121
  const userKvRe = /^(\s+)(token|token-data)\s*:\s*(\S[^\n]*)/gm;
@@ -157,16 +125,12 @@ function parseKubeConfig(content) {
157
125
  let blockHasStatic = false;
158
126
  for (const m of block.matchAll(userKvRe)) {
159
127
  const upto = block.slice(0, m.index);
160
- // Indentation-agnostic key anchors: a kubeconfig may use 2-space, 4-space,
161
- // or tab indentation. Hardcoding "\n user:" missed a user-level static
162
- // token under any other indentation and mislabeled it as an auth-provider
163
- // (dynamic) token.
128
+ // Indentation-agnostic anchors: kubeconfig indent varies, and a fixed-width
129
+ // anchor mislabels a user-level static token as an auth-provider one.
164
130
  const userMatches = [...upto.matchAll(/\n[ \t]*user\s*:/g)];
165
131
  const lastUserAt = userMatches.length ? userMatches[userMatches.length - 1].index : -1;
166
132
  const apMatches = [...upto.matchAll(/\n[ \t]*auth-provider\s*:/g)];
167
133
  const lastAuthProviderAt = apMatches.length ? apMatches[apMatches.length - 1].index : -1;
168
- // Reject when the closest enclosing key is auth-provider rather
169
- // than user — those are dynamic tokens, not static credentials.
170
134
  if (lastAuthProviderAt > lastUserAt) continue;
171
135
  const value = m[3];
172
136
  if (!value || value.startsWith("null")) continue;
@@ -236,7 +200,6 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
236
200
  const presence = {};
237
201
  for (const [id, p] of Object.entries(carriers)) presence[id] = fileExists(p);
238
202
 
239
- // Read carriers we care about.
240
203
  const awsCredsContent = presence["aws-credentials"] ? readSafe(carriers["aws-credentials"]) : null;
241
204
  const awsCfgContent = presence["aws-config"] ? readSafe(carriers["aws-config"]) : null;
242
205
  const kubeContent = presence["kube-config"] ? readSafe(carriers["kube-config"]) : null;
@@ -247,28 +210,18 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
247
210
  const pypirProjContent = presence["pypirc-project"] ? readSafe(carriers["pypirc-project"]) : null;
248
211
  const gcloudAdcContent = presence["gcloud-adc"] ? readSafe(carriers["gcloud-adc"]) : null;
249
212
 
250
- // Indicator predicates.
251
213
  const awsCredsParsed = parseAwsCredentials(awsCredsContent);
252
214
  const awsCfgParsed = parseAwsCredentials(awsCfgContent);
253
215
  const ssoCacheFiles = (() => {
254
216
  try { return fs.readdirSync(ssoCacheDir).filter(f => f.endsWith(".json")); }
255
217
  catch { return []; }
256
218
  })();
257
- // aws-static-key-present: any AKIA* key with no federation. Apply
258
- // the playbook's catalogued FP[0] (AWS-published doc-fixture key)
259
- // and FP[2] (break-glass profile-name pattern) directly in the
260
- // collector — they're deterministic and the collector has the
261
- // evidence locally. FP[1] requires `aws sts get-caller-identity`
262
- // which is out of stdlib scope, so the collector cannot attest it;
263
- // the runner downgrades hit → inconclusive with that one
264
- // unsatisfied, which is the honest outcome.
219
+ // aws-static-key-present: any AKIA* key with no federation. FP[0] doc-fixture
220
+ // key and FP[2] break-glass profile are deterministic and applied here.
265
221
  const AWS_DOC_FIXTURE_KEY = "AKIAIOSFODNN7EXAMPLE";
266
222
  const realAwsProfiles = awsCredsParsed.staticProfiles.filter(p => {
267
- // Demote off this profile's EXACT parsed key value (FP[0]) and its name
268
- // (FP[2]). Keying off the parsed value — not the first raw block whose
269
- // name matches — means a duplicate profile name whose first occurrence
270
- // holds the doc-fixture key cannot demote the later real key under the
271
- // same name (the parser resolves the live last-occurrence value).
223
+ // Demote on the exact parsed key, so a duplicate name whose first occurrence
224
+ // holds the fixture key cannot demote the real one.
272
225
  const keyVal = awsCredsParsed.staticKeys[p];
273
226
  if (keyVal === AWS_DOC_FIXTURE_KEY) return false; // FP[0]
274
227
  if (/^breakglass-/i.test(p) || /^break-glass-/i.test(p)) return false; // FP[2]
@@ -280,15 +233,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
280
233
  const gcloudParsed = parseGcloudAdc(gcloudAdcContent);
281
234
  const dockerParsed = parseDockerConfig(dockerContent);
282
235
 
283
- // docker-cleartext-auth FP checks (per playbook):
284
- // FP[0] — vendor-token user pattern (decoded `user:pass`) is
285
- // `<token>` / `AWS` / `oauth2accesstoken` / a zero-UUID;
286
- // treat as a deliberately published convention, demote.
287
- // FP[1] — local-only registry (loopback IP, *.local, *.svc.
288
- // cluster.local, kind.local) on a dev workstation, demote.
289
- // FP[2] — global credsStore overrides per-registry omission —
290
- // the collector already accounts for this via
291
- // parseDockerConfig.hasCredHelper.
236
+ // docker-cleartext-auth FP checks: FP[0] vendor-token user in the decoded
237
+ // `user:pass`, FP[1] local-only registry, FP[2] a global credsStore, which
238
+ // parseDockerConfig already folds into hasCredHelper.
292
239
  const VENDOR_TOKEN_USERS = new Set([
293
240
  "<token>", "AWS", "oauth2accesstoken",
294
241
  "00000000-0000-0000-0000-000000000000",
@@ -324,10 +271,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
324
271
  (pypirHomeContent && PYPI_TOKEN_RE.test(pypirHomeContent)) ||
325
272
  (pypirProjContent && PYPI_TOKEN_RE.test(pypirProjContent));
326
273
 
327
- // credentials-file-bad-perms: POSIX only. Any of the listed
328
- // carriers with mode != 0600. Per playbook the indicator covers
329
- // `~/.config/gcloud/*` too, so include the gcloud ADC file (and
330
- // its parent dir mode != 0700 expectation per the spec).
274
+ // credentials-file-bad-perms: POSIX only — carriers at 0600, the gcloud dir at 0700.
331
275
  let credsFileBadPerms;
332
276
  const permViolations = [];
333
277
  if (isPosix) {
@@ -342,8 +286,8 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
342
286
  ];
343
287
  for (const [id, p, expectedMode] of permTargets) {
344
288
  if (!presence[id]) continue;
345
- // FP[1]: 0-byte placeholder OR symlink to broker socket / tmpfs —
346
- // mode bits don't carry the same blast radius. Skip these.
289
+ // FP[1]: a 0-byte placeholder or a symlink to a broker socket carries no
290
+ // blast radius in its mode bits.
347
291
  let lstat;
348
292
  try { lstat = fs.lstatSync(p); } catch { continue; }
349
293
  if (lstat.size === 0) continue;
@@ -354,7 +298,6 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
354
298
  permViolations.push({ id, mode_octal: "0" + m.toString(8) });
355
299
  }
356
300
  }
357
- // Also check the gcloud directory itself (expected 0700 per spec).
358
301
  const gcloudDir = path.join(home, ".config", "gcloud");
359
302
  try {
360
303
  const gs = fs.statSync(gcloudDir);
@@ -380,28 +323,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
380
323
  signal_overrides["credentials-file-bad-perms"] = credsFileBadPerms;
381
324
  }
382
325
 
383
- // Per-indicator __fp_checks attestation. The runner gates a 'hit'
384
- // verdict on false_positive_checks_required[] entries; an
385
- // unsatisfied check downgrades to 'inconclusive'. Attest exactly
386
- // the checks the collector itself ran (don't attest network /
387
- // operator-judgement checks). Use the index-keyed form because
388
- // false_positive_checks_required entries are free-text prose, not
389
- // ids — the index is the stable cross-reference.
390
- //
391
- // aws-static-key-present:
392
- // [0] doc-fixture demotion (AKIAIOSFODNN7EXAMPLE) — DONE
393
- // [1] live-key sts check — NOT DONE (needs network)
394
- // [2] break-glass profile-name pattern — DONE
395
- //
396
- // docker-cleartext-auth:
397
- // [0] vendor-token user pattern — DONE
398
- // [1] local-only registry — DONE
399
- // [2] global credsStore — DONE
400
- //
401
- // credentials-file-bad-perms:
402
- // [0] Windows / WSL skip — DONE (POSIX guard)
403
- // [1] 0-byte / symlink skip — DONE
404
- // [2] ACL-by-design (operator interview) — NOT DONE
326
+ // Per-indicator __fp_checks attestation: the runner downgrades a 'hit' to
327
+ // 'inconclusive' for every unattested false_positive_checks_required[] entry.
328
+ // Keys are indices into it; aws[1] (network) and perms[2] stay unattested.
405
329
  if (awsStaticKey) {
406
330
  signal_overrides["aws-static-key-present__fp_checks"] = { "0": true, "2": true };
407
331
  }
@@ -412,11 +336,8 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
412
336
  signal_overrides["credentials-file-bad-perms__fp_checks"] = { "0": true, "1": true };
413
337
  }
414
338
 
415
- // Artifact-level captures (one entry per artifact id in
416
- // data/playbooks/cred-stores.json look.artifacts[]). We only
417
- // populate the ones the collector actually reads; the rest are
418
- // marked captured=false with a "reason" so the runner records
419
- // partial-evidence coverage rather than a phantom miss.
339
+ // One entry per artifact id in data/playbooks/cred-stores.json look.artifacts[].
340
+ // What cannot be read is captured=false with a reason, not a phantom miss.
420
341
  const artifacts = {
421
342
  "aws-credentials": presence["aws-credentials"]
422
343
  ? { value: `present (${awsCredsParsed.staticProfiles.length} static profile(s), ${awsCredsParsed.federatedProfiles.length} federated)`, captured: true }
@@ -470,12 +391,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
470
391
  captured: false,
471
392
  reason: "secret-tool / security dump-keychain require interactive auth or platform-specific binaries; out of stdlib collector scope",
472
393
  },
473
- // Surface the POSIX-mode-bit skip on Windows so operators see the
474
- // indicator was considered but skipped, mirroring the secrets
475
- // collector's world-writable-secret-files skip artifact. Without
476
- // this, credentials-file-bad-perms silently absent from
477
- // signal_overrides looks indistinguishable from "indicator not
478
- // catalogued".
394
+ // Names the win32 skip explicitly; an absent override would look uncatalogued.
479
395
  "credentials-file-perms-check": isPosix
480
396
  ? {
481
397
  value: permViolations.length > 0
@@ -1,17 +1,9 @@
1
1
  "use strict";
2
2
 
3
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
4
+ * Companion collector for the `crypto-codebase` playbook: greps the cwd source
5
+ * tree for hash / cipher / KEX / signature / KDF / RNG / TLS / PQC / FIPS call
6
+ * sites. Interface: see lib/collectors/README.md
15
7
  */
16
8
 
17
9
  const fs = require("node:fs");
@@ -21,10 +13,7 @@ const { codeExcludeSet, walkTree, buildEvidenceLocations, lineFromOffset } = req
21
13
  const COLLECTOR_ID = "crypto-codebase";
22
14
 
23
15
  const DEFAULT_MAX_DEPTH = 6;
24
- // Shared code-scope exclusions: dependency caches, build output, VCS +
25
- // agent/editor scratch (including `.claude/`). No crypto-codebase-specific
26
- // extras — the shared defaults already cover every directory this scan
27
- // should never descend into.
16
+ // Shared code-scope exclusions; no collector-specific extras needed.
28
17
  const DEFAULT_EXCLUDES = codeExcludeSet();
29
18
 
30
19
  const SOURCE_EXTS = new Set([
@@ -41,12 +30,9 @@ const SOURCE_EXTS = new Set([
41
30
  ".m", ".mm",
42
31
  ]);
43
32
 
44
- // The exact marker set the crypto-codebase playbook's `repo-has-source-tree`
45
- // gate evaluates (data/playbooks/crypto-codebase.json: exists_any([...])).
46
- // The collector attests the gate by mirroring this predicate against the
47
- // scanned cwd — NOT by counting SOURCE_EXTS files — so a valid package repo
48
- // whose only marker is a manifest or an empty src/ dir (no extension-matched
49
- // source yet) still attests true, matching what the gate would compute.
33
+ // The exact marker set the playbook's `repo-has-source-tree` gate evaluates
34
+ // (data/playbooks/crypto-codebase.json: exists_any([...])). Mirrored here, NOT
35
+ // counted from SOURCE_EXTS files, so an empty src/ attests what the gate computes.
50
36
  const SOURCE_TREE_MARKERS = [
51
37
  "package.json", "pyproject.toml", "go.mod", "Cargo.toml",
52
38
  "pom.xml", "build.gradle", "src", "lib", "crates",
@@ -57,10 +43,7 @@ const TEST_PATH_SEGMENTS = [
57
43
  "/fixtures/", "/fixture/", "/examples/", "/example/",
58
44
  "/docs/", "/doc/", "/sample/", "/samples/", "/demo/", "/demos/",
59
45
  "/benchmarks/", "/benchmark/", "/bench/",
60
- // Files whose purpose is grepping for these patterns — their source
61
- // literally contains the patterns, so production-scope scans would
62
- // match the scanner itself. The crypto-codebase playbook's intent
63
- // is the consumer's source, not the scanner's regex catalogue.
46
+ // These files contain the scan patterns; scanning them matches the scanner.
64
47
  "/lib/collectors/", "/scripts/check-version-tags",
65
48
  ];
66
49
 
@@ -71,19 +54,15 @@ function isTestPath(rel) {
71
54
  for (const seg of TEST_PATH_SEGMENTS) {
72
55
  if (norm.includes(seg)) return true;
73
56
  }
74
- // `foo.test.js`, `bar.spec.py` (dot-separated)
75
57
  if (/\.(test|spec)\.[a-z]+$/i.test(rel)) return true;
76
- // `foo_test.go` (Go convention), `_test.py` (some Python projects)
58
+ // Go's `foo_test.go` convention.
77
59
  if (/(?:^|[\\/])[^\\/]+_test\.[a-z]+$/i.test(rel)) return true;
78
60
  return false;
79
61
  }
80
62
 
81
63
  function readSafe(full) {
82
64
  try {
83
- // Read raw bytes, enforce the 1 MB cap on the buffer length, then decode.
84
- // Replaces a statSync-before-read on the hot path with a single read; the
85
- // cap is byte-based, so Buffer.length is the correct measure and an
86
- // oversized file is rejected before any UTF-8 decode.
65
+ // The cap is byte-based: enforced on the buffer, before any UTF-8 decode.
87
66
  const raw = fs.readFileSync(full);
88
67
  if (raw.length > MAX_FILE_BYTES) return null;
89
68
  return raw.toString("utf8");
@@ -91,25 +70,15 @@ function readSafe(full) {
91
70
  }
92
71
 
93
72
  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;
94
- // Token vocabulary signaling a SECURITY-CRITICAL use of the hash
95
- // primitive (not content-fingerprinting / cache-key / build-id usage,
96
- // where MD5 / SHA-1 are legitimate by design).
97
- //
98
- // "integrity" was previously in this set but matched non-security
99
- // integrity contexts (Hugo's content-integrity fingerprinting, sphinx
100
- // docs build-ids, etag generators) — too broad. Drop it; the
101
- // remaining vocabulary still catches crypto-security uses.
73
+ // Token vocabulary signalling a SECURITY-CRITICAL use of the hash primitive, as
74
+ // opposed to fingerprinting / cache-key / build-id use where MD5 and SHA-1 are
75
+ // legitimate. "integrity" stays out: it also matches fingerprinting and etags.
102
76
  const WEAK_HASH_VAR_FLOW_RE = /(hmac|sign|signature|token|jwt|verify|password|hash[-_]?(?:credential|secret|password|key|auth))/i;
103
- // Strong, unambiguous security-context tokens. When ANY of these
104
- // appear in the file content, the hit fires regardless of the
105
- // filename — a file named `hashing.go` that genuinely uses MD5 on
106
- // a `password` / `token` / `jwt` is a real positive, no matter
107
- // what the filename suggests.
77
+ // Unambiguous security-context tokens: any of these fires the hit regardless of
78
+ // the filename — `hashing.go` running MD5 over a `password` is a real positive.
108
79
  const STRONG_SECURITY_VAR_RE = /\b(token|password|jwt|secret|credential|api[-_]?key|access[-_]?key|private[-_]?key)\b/i;
109
- // Filename-path demotion candidates: paths that suggest
110
- // content-addressable / fingerprint / etag / cache-key concerns.
111
- // Demotion fires only when STRONG_SECURITY_VAR_RE did NOT match —
112
- // the filename is a tiebreaker, never an override.
80
+ // Paths suggesting fingerprint / etag / cache-key concerns. Demotion fires only
81
+ // when STRONG_SECURITY_VAR_RE did NOT match: the filename is a tiebreaker only.
113
82
  const NON_SECURITY_HASH_FILE_RE = /(?:^|[\\/])(?:integrity|hashing|fingerprint|content[-_]?hash|cache[-_]?key|etag|build[-_]?id)\.(?:go|py|rs|java|js|ts|rb|php|cs|swift|m|cpp|cc|c|h|hpp)$/i;
114
83
 
115
84
  const WEAK_CIPHER_ECB_RE = /(?:aes-\d+-ecb|AES\/ECB\/|Cipher\.getInstance\(\s*['"]AES['"]\s*\))/i;
@@ -124,31 +93,17 @@ const SECURITY_VAR_RE = /\b(?:token|secret|key|salt|nonce|iv|seed|state|jwt|jti|
124
93
  const PBKDF2_BLOCK_GLOBAL_RE = /\b(?:pbkdf2(?:Sync)?|hashlib\.pbkdf2_hmac)\s*\([^)]{0,400}/g;
125
94
 
126
95
  const BCRYPT_BLOCK_GLOBAL_RE = /\b(?:bcrypt\.(?:hash|hashSync|gen_salt|genSalt|genSaltSync)|BCrypt::Password\.create)\s*\([^)]{0,200}/g;
127
- // Captures either named-arg form (`cost: 12` / `rounds=12`) or the
128
- // positional-arg trailing-integer form. Match against the block
129
- // captured by BCRYPT_BLOCK_GLOBAL_RE — which truncates at the first
130
- // `)`, so the trailing-int branch must match `, <digits>` followed
131
- // by either end-of-block or whitespace, not a closing paren.
96
+ // Named-arg form (`cost: 12` / `rounds=12`) or the positional trailing integer.
97
+ // The captured block truncates at the first `)`, so the trailing-int branch ends
98
+ // at end-of-block, not at a paren.
132
99
  const BCRYPT_COST_RE = /(?:cost|rounds)\s*[:=]\s*(\d+)|,\s*(\d+)\s*$/;
133
100
 
134
- // `hardcoded-key-material` is a SECRET-leak signal, so it must match only an
135
- // actual embedded private key — a full PEM block: a `BEGIN ... PRIVATE KEY`
136
- // header, a base64 body, and a matching `END` marker.
137
- //
138
- // - Public keys and certificates are published by design (BIMI trust
139
- // anchors, release-signing public keys, autoupdate pubkeys), so the
140
- // header is `PRIVATE KEY` only.
141
- // - Requiring the base64 body + `END` marker distinguishes a real pasted
142
- // key from a bare marker used as a *detection pattern* (a redaction
143
- // library's `/-----BEGIN OPENSSH PRIVATE KEY-----/` regex literal) or a
144
- // documentation placeholder (`privateKeyPem: "-----BEGIN PRIVATE KEY-----
145
- // ..."`), neither of which carries a body or an `END`.
146
- //
147
- // The body class is base64 + whitespace only; `-` is excluded so the run
148
- // halts at the first `-` of `-----END` — no backtracking, ReDoS-safe. The
149
- // `-----END` requirement is the primary discriminator (detectors and
150
- // placeholders have no closing marker); the {20,4000} bound additionally
151
- // rejects the ~1-char run a bare marker leaves before the next punctuation.
101
+ // `hardcoded-key-material` matches only a full PEM block: a `BEGIN ... PRIVATE
102
+ // KEY` header, a base64 body, and a matching `END`. Public keys and certificates
103
+ // are published by design, so the header is PRIVATE KEY only; requiring the body
104
+ // and the `END` separates a pasted key from a bare marker in a detection pattern
105
+ // or a placeholder. The body class excludes `-`, so the run halts at the first
106
+ // `-` of `-----END`, with no backtracking.
152
107
  const PEM_PRIVATE_RE = /-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----[A-Za-z0-9+/=\s]{20,4000}-----END/;
153
108
 
154
109
  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;
@@ -167,17 +122,9 @@ const VENDORED_PQC_NAMES_RE = /(?:kyber|dilithium|sphincs|ml[-_]?kem|ml[-_]?dsa|
167
122
 
168
123
  function scanWeakHash(content, rel) {
169
124
  if (!WEAK_HASH_RE.test(content)) return false;
170
- // Strong security tokens fire the indicator regardless of the
171
- // filename — a file named `integrity.go` that genuinely uses
172
- // md5 on a password is a real positive.
173
125
  if (STRONG_SECURITY_VAR_RE.test(content)) return true;
174
- // Otherwise the var-flow regex needs to match (hash / hmac /
175
- // sign / signature / verify / ...). If neither STRONG nor
176
- // WEAK_HASH_VAR_FLOW fired, this isn't a security-context use.
177
126
  if (!WEAK_HASH_VAR_FLOW_RE.test(content)) return false;
178
- // Var-flow matched a soft / ambiguous keyword. Use the filename
179
- // as a tiebreaker: paths like `integrity.go` / `hashing.go` /
180
- // `fingerprint.py` indicate content-addressable use, demote.
127
+ // Only a soft keyword matched, so the filename is the tiebreaker.
181
128
  if (rel && NON_SECURITY_HASH_FILE_RE.test(rel)) return false;
182
129
  return true;
183
130
  }
@@ -202,11 +149,9 @@ function scanPbkdf2(content) {
202
149
  let threshold = 210000;
203
150
  if (/sha[-_]?256/i.test(block)) threshold = 600000;
204
151
  else if (/sha1\b/i.test(block) || /sha-1\b/i.test(block)) threshold = 1300000;
205
- // Take the max 4+ digit literal as the iteration count. Don't
206
- // pre-filter common key-bit-size values (256/384/512/1024) — a
207
- // call like `pbkdf2Sync(pw, salt, 1024, 32, 'sha256')` IS under-
208
- // iterated at 1024 and must hit. The max() picks iteration over
209
- // keylen in the typical positional shape (iter, keylen, algo).
152
+ // The max 4+ digit literal is the iteration count. Common key-bit sizes are
153
+ // deliberately not pre-filtered — `pbkdf2Sync(pw, salt, 1024, 32, 'sha256')`
154
+ // IS under-iterated — and max() picks iteration over keylen in (iter, keylen).
210
155
  const nums = [];
211
156
  for (const nm of block.matchAll(/\b(\d{4,8})\b/g)) {
212
157
  nums.push(Number(nm[1]));
@@ -297,9 +242,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
297
242
  if (RSA_1024_RE.test(content)) {
298
243
  hits["rsa-1024-anywhere"].push({ file: f.rel });
299
244
  }
300
- // Attach a 1-based `line` (from the match offset) so the evidence
301
- // location carries a SARIF startLine region rather than pointing at
302
- // the file. Does not change hit/miss — the same matches still fire.
245
+ // The 1-based `line` gives the evidence location a SARIF startLine region.
303
246
  const mrHits = scanMathRandom(content);
304
247
  for (const h of mrHits) hits["math-random-in-security-path"].push({ file: f.rel, offset: h.offset, line: lineFromOffset(content, h.offset) });
305
248
 
@@ -317,11 +260,8 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
317
260
  }
318
261
  }
319
262
 
320
- // Cross-file evidence for the conditional indicators (ecdsa-
321
- // without-pqc-roadmap, no-ml-kem-implementation, fips-claim-
322
- // without-runtime-activation). Production-context only — a
323
- // PQC / FIPS reference inside `tests/` / `fixtures/` / `examples/`
324
- // doesn't count as evidence the library SHIPS that capability.
263
+ // Cross-file evidence for the conditional indicators, production context only:
264
+ // a PQC / FIPS reference in tests is not evidence the library SHIPS it.
325
265
  if (!isTest) {
326
266
  if (CLASSICAL_SIG_RE.test(content)) sawClassicalSig = true;
327
267
  if (PQC_SIG_IMPL_RE.test(content)) sawPqcSigImpl = true;
@@ -348,19 +288,13 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
348
288
  const vendoredPqcFiles = files.filter(f => isVendored(f.rel) && VENDORED_PQC_NAMES_RE.test(f.rel));
349
289
  let vendoredPqcNoProvenance = "miss";
350
290
  if (vendoredPqcFiles.length > 0) {
351
- // `MANIFEST.json` / `vendor/MANIFEST.json` is the common provenance
352
- // record for a vendored dependency tree (records upstream version,
353
- // source URL, license, copied-at commit). This walk only runs for
354
- // files already classified as vendored, so a MANIFEST.json found while
355
- // climbing the vendor tree is a genuine provenance marker, not a stray
356
- // app/package manifest at the repo root.
291
+ // MANIFEST.json is a common provenance record for a vendored tree. The walk
292
+ // runs only over already-vendored files, so a hit is a marker, not the root
293
+ // app manifest.
357
294
  const provenanceMarkers = new Set(["_PROVENANCE.json", "UPSTREAM", "ORIGIN", ".upstream-commit", "PROVENANCE.md", "MANIFEST.json"]);
358
- // Walk from the file's directory up to the repo root, checking
359
- // each ancestor for a provenance marker. The marker can live at
360
- // the immediate sibling (`vendor/kyber/_PROVENANCE.json`), at
361
- // the vendor root (`vendor/_PROVENANCE.json`), or anywhere in
362
- // between for deeply-nested vendor trees. Stop at the repo root
363
- // (cwd) so we don't escape into the parent filesystem.
295
+ // Walk from the file's directory up to the repo root: the marker may sit
296
+ // beside the file, at the vendor root, or between. Stopping at the root
297
+ // keeps the search inside the repo.
364
298
  let anyMissing = false;
365
299
  for (const f of vendoredPqcFiles) {
366
300
  let dir = path.dirname(f.full);
@@ -405,28 +339,22 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
405
339
  if (noMlKemImpl !== undefined) signal_overrides["no-ml-kem-implementation"] = noMlKemImpl;
406
340
  if (fipsTheater !== undefined) signal_overrides["fips-claim-without-runtime-activation"] = fipsTheater;
407
341
 
408
- // Per-indicator __fp_checks attestation for the FP-gated call-site
409
- // indicators. Every surviving hit is in a non-test source file (isTest is
410
- // excluded before the scan) and, for weak-hash, flows into a security sink
411
- // (scanWeakHash). The remaining false_positive_checks_required entries are
412
- // legacy-protocol-shim / feature-flag / later-override judgements the
413
- // collector does not make, so they stay unattested and the runner keeps
414
- // those indicators inconclusive. Without attesting what it DID check, the
415
- // collector's real call-site hits are downgraded to inconclusive after run.
342
+ // __fp_checks attest what the collector actually checked: every surviving hit is
343
+ // in a non-test source file and, for weak-hash, flows into a security sink. The
344
+ // unattested entries are judgements it does not make, and stay inconclusive.
416
345
  if (signal_overrides["weak-hash-import"] === "hit") {
417
- // [0] not under test + non-security-file demotion; [2] hash flows to an
418
- // authn/integrity sink. [1] legacy-protocol shim is operator judgement.
346
+ // [0] not under test, plus non-security-file demotion; [2] the hash flows to
347
+ // an authn/integrity sink. [1] legacy-protocol shim is operator judgement.
419
348
  signal_overrides["weak-hash-import__fp_checks"] = { "0": true, "2": true };
420
349
  }
421
350
  if (signal_overrides["weak-cipher-mode"] === "hit") {
422
- // [0] not under test/KAT-vector path; [2] construction is in a scanned
423
- // (production) source file. [1] legacy-protocol-parser scope is operator.
351
+ // [0] not under a test / KAT-vector path; [2] the construction is in a
352
+ // production source file. [1] legacy-protocol-parser scope is operator.
424
353
  signal_overrides["weak-cipher-mode__fp_checks"] = { "0": true, "2": true };
425
354
  }
426
355
  if (signal_overrides["tls-old-protocol"] === "hit") {
427
- // [0] the hit is in non-test production code, not a test asserting the
428
- // library REJECTS the legacy protocol. [1] feature-flag-default-off and
429
- // [2] later-override are not inspected by the collector.
356
+ // [0] the hit is production code, not a test asserting the library REJECTS the
357
+ // legacy protocol. [1] feature-flag default and [2] later override are not.
430
358
  signal_overrides["tls-old-protocol__fp_checks"] = { "0": true };
431
359
  }
432
360
 
@@ -488,15 +416,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
488
416
  },
489
417
  };
490
418
 
491
- // Per-indicator file locations for the call-site indicators flipped to
492
- // "hit". The cross-file derived indicators (ecdsa-without-pqc-roadmap,
493
- // no-ml-kem-implementation, fips-claim-without-runtime-activation,
494
- // vendored-pqc-no-provenance) describe a whole-repo state rather than a
495
- // single offending file, so they carry no file-level location. The
496
- // offset-bearing call-site scans (math-random / pbkdf2 / bcrypt) now record
497
- // a 1-based `line`, so their locations include a startLine region; the
498
- // remaining whole-file scans (weak-hash / weak-cipher / rsa-1024 /
499
- // hardcoded-key / tls) stay file-level (no startLine).
419
+ // File locations for the call-site indicators flipped to "hit". The cross-file
420
+ // derived indicators describe a whole-repo state, so they carry no location; the
421
+ // offset-bearing scans record a line, the whole-file scans stay file-level.
500
422
  const evidence_locations = {};
501
423
  for (const id of Object.keys(hits)) {
502
424
  if (signal_overrides[id] === "hit") {
@@ -507,15 +429,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
507
429
 
508
430
  return {
509
431
  precondition_checks: {
510
- // Auto-attest the crypto-codebase playbook's own `repo-has-source-tree`
511
- // gate by mirroring the gate's own exists_any(SOURCE_TREE_MARKERS)
512
- // predicate against the scanned cwd. The runner's autoDetectPreconditions
513
- // probes the run process's cwd (not the collected --cwd) and has no
514
- // exists_any() branch, so a repo with a recognizable source tree would
515
- // otherwise surface a spurious precondition_unverified warning. Keying to
516
- // the gate's exact id + predicate mirrors the sbom / library-author
517
- // collectors; `repo-context` is not a precondition this playbook
518
- // references.
432
+ // Mirrors the playbook's own exists_any(SOURCE_TREE_MARKERS) predicate
433
+ // against the scanned cwd. The runner's autoDetectPreconditions probes the
434
+ // run process's cwd and has no exists_any branch, so without this a real
435
+ // source tree surfaces a spurious precondition_unverified warning.
519
436
  "repo-has-source-tree": SOURCE_TREE_MARKERS.some((m) => fs.existsSync(path.join(cwd, m))),
520
437
  },
521
438
  artifacts,