@blamejs/exceptd-skills 0.16.22 → 0.16.24

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 (62) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/CHANGELOG.md +42 -0
  3. package/CONTEXT.md +9 -9
  4. package/README.md +3 -3
  5. package/agents/report-generator.md +2 -2
  6. package/agents/skill-updater.md +1 -1
  7. package/agents/source-validator.md +3 -4
  8. package/agents/threat-researcher.md +1 -1
  9. package/bin/exceptd.js +91 -32
  10. package/data/_indexes/_meta.json +10 -10
  11. package/data/_indexes/activity-feed.json +12 -12
  12. package/data/_indexes/chains.json +70435 -4026
  13. package/data/_indexes/frequency.json +492 -163
  14. package/data/_indexes/section-offsets.json +51 -51
  15. package/data/_indexes/summary-cards.json +272 -106
  16. package/data/_indexes/token-budget.json +10 -10
  17. package/data/_indexes/trigger-table.json +15 -6
  18. package/data/_indexes/xref.json +218 -26
  19. package/data/cve-catalog.json +10 -10
  20. package/data/cwe-catalog.json +1 -0
  21. package/lib/auto-discovery.js +39 -1
  22. package/lib/collectors/ai-api.js +112 -7
  23. package/lib/collectors/citation-hygiene.js +27 -0
  24. package/lib/collectors/crypto-codebase.js +25 -0
  25. package/lib/collectors/kernel.js +32 -2
  26. package/lib/collectors/library-author.js +30 -0
  27. package/lib/collectors/runtime.js +38 -3
  28. package/lib/collectors/sbom.js +21 -2
  29. package/lib/collectors/scan-excludes.js +4 -1
  30. package/lib/collectors/secrets.js +125 -0
  31. package/lib/cve-cli.js +9 -1
  32. package/lib/cve-curation.js +8 -1
  33. package/lib/cve-regression-watcher.js +5 -2
  34. package/lib/exit-codes.js +2 -0
  35. package/lib/flag-suggest.js +1 -1
  36. package/lib/lint-skills.js +70 -0
  37. package/lib/playbook-runner.js +75 -14
  38. package/lib/prefetch.js +24 -1
  39. package/lib/refresh-external.js +32 -3
  40. package/lib/rfc-cli.js +8 -1
  41. package/lib/scoring.js +36 -8
  42. package/lib/validate-cve-catalog.js +36 -14
  43. package/lib/validate-package.js +8 -0
  44. package/lib/validate-playbooks.js +42 -0
  45. package/lib/verify.js +4 -3
  46. package/manifest-snapshot.json +4 -2
  47. package/manifest-snapshot.sha256 +1 -1
  48. package/manifest.json +57 -54
  49. package/orchestrator/README.md +1 -1
  50. package/orchestrator/index.js +65 -7
  51. package/orchestrator/scanner.js +53 -5
  52. package/package.json +1 -1
  53. package/sbom.cdx.json +110 -110
  54. package/scripts/build-indexes.js +42 -8
  55. package/scripts/builders/cwe-chains.js +1 -0
  56. package/scripts/builders/section-offsets.js +10 -2
  57. package/scripts/builders/token-budget.js +3 -3
  58. package/scripts/check-changelog-extract.js +38 -1
  59. package/scripts/check-sbom-currency.js +72 -0
  60. package/scripts/check-version-tags.js +5 -0
  61. package/scripts/release.js +22 -15
  62. package/skills/exploit-scoring/skill.md +8 -8
@@ -49,6 +49,16 @@ const AI_KEY_PATTERNS = [
49
49
  { id: "cohere", re: /(?:^|\n)\s*(?:export\s+|set\s+-gx\s+)?COHERE_API_KEY\s*[= ]\s*['"]?[A-Za-z0-9-]{30,}/m },
50
50
  ];
51
51
 
52
+ // Capture the exported value so the false_positive_checks_required entries
53
+ // (placeholder demotion, entropy floor) can be evaluated. The export
54
+ // patterns above end at the prefix; widen to grab the trailing token.
55
+ const AI_KEY_VALUE_RE = {
56
+ openai: /OPENAI_API_KEY\s*[= ]\s*['"]?(sk-[A-Za-z0-9_-]+)/,
57
+ anthropic: /ANTHROPIC_API_KEY\s*[= ]\s*['"]?(sk-ant-[A-Za-z0-9_-]+)/,
58
+ huggingface: /(?:HUGGINGFACE_TOKEN|HF_TOKEN)\s*[= ]\s*['"]?(hf_[A-Za-z0-9]+)/,
59
+ };
60
+ const PLACEHOLDER_RE = /placeholder|example|redacted|dummy|x{4,}|0{6,}|test-/i;
61
+
52
62
  function scanShellRc(content) {
53
63
  if (!content) return [];
54
64
  const hits = [];
@@ -58,6 +68,31 @@ function scanShellRc(content) {
58
68
  return hits;
59
69
  }
60
70
 
71
+ // Deterministic false_positive_checks_required evaluation for
72
+ // cleartext-api-key-in-dotfile. Returns the satisfiable indices for the
73
+ // exports found across the canonical dotfiles (intersection — an index is
74
+ // only attested if every export satisfies it). Canonical home rc / dotfile
75
+ // paths are never under examples/tests/fixtures, so the path check [1] is
76
+ // always satisfied here.
77
+ function cleartextFpIndices(content) {
78
+ const sat = new Set(["0", "1", "2"]);
79
+ let sawAny = false;
80
+ for (const [vendor, re] of Object.entries(AI_KEY_VALUE_RE)) {
81
+ const m = content.match(re);
82
+ if (!m) continue;
83
+ sawAny = true;
84
+ const value = m[1];
85
+ // [0] not a documented placeholder / sk-test- fixture
86
+ if (PLACEHOLDER_RE.test(value)) sat.delete("0");
87
+ // [2] entropy floor: OpenAI sk-* >= 48 post-prefix, Anthropic sk-ant-* >= 40,
88
+ // HuggingFace hf_* >= 30.
89
+ const floor = vendor === "openai" ? 48 : vendor === "anthropic" ? 40 : 30;
90
+ const body = value.replace(/^sk-ant-(?:api03|admin01)-|^sk-(?:proj-|svcacct-|admin-)?|^hf_/, "");
91
+ if (body.length < floor) sat.delete("2");
92
+ }
93
+ return sawAny ? sat : new Set();
94
+ }
95
+
61
96
  function parseAwsCredentials(content) {
62
97
  if (!content) return { staticProfiles: [] };
63
98
  const lines = content.split(/\r?\n/);
@@ -74,30 +109,43 @@ function parseAwsCredentials(content) {
74
109
  profiles[current][kv[1].trim().toLowerCase()] = kv[2].trim();
75
110
  }
76
111
  const staticProfiles = [];
112
+ const accessKeyIds = [];
77
113
  for (const [name, kv] of Object.entries(profiles)) {
78
114
  // long-lived-aws-keys: aws_access_key_id present AND no
79
115
  // aws_session_token sibling (STS temporary creds carry the
80
116
  // session token; IAM-user long-lived keys do not).
81
117
  if (kv["aws_access_key_id"] && !kv["aws_session_token"]) {
82
118
  staticProfiles.push(name);
119
+ accessKeyIds.push(kv["aws_access_key_id"]);
83
120
  }
84
121
  }
85
- return { staticProfiles };
122
+ return { staticProfiles, accessKeyIds };
86
123
  }
87
124
 
125
+ // AWS-published sample credential pair — long-lived-aws-keys FP[0] demotes it.
126
+ const AWS_EXAMPLE_KEY_PARTS = new Set([
127
+ "AKIAIOSFODNN7EXAMPLE",
128
+ "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
129
+ ]);
130
+
88
131
  function parseGcloudAdc(content) {
89
132
  if (!content) return { hasServiceAccount: false };
90
133
  try {
91
134
  const j = JSON.parse(content);
92
- return { hasServiceAccount: j?.type === "service_account" };
135
+ const hasServiceAccount = j?.type === "service_account";
136
+ return {
137
+ hasServiceAccount,
138
+ privateKey: typeof j?.private_key === "string" ? j.private_key : "",
139
+ clientEmail: typeof j?.client_email === "string" ? j.client_email : "",
140
+ };
93
141
  } catch { return { hasServiceAccount: false }; }
94
142
  }
95
143
 
96
144
  function parseKubeStaticToken(content) {
97
- if (!content) return false;
145
+ if (!content) return { found: false };
98
146
  // Same shape as cred-stores: token under user:, not auth-provider.
99
147
  const userKvRe = /^(\s+)(token|token-data)\s*:\s*(\S[^\n]*)/gm;
100
- let staticFound = false;
148
+ let tokenValue = null;
101
149
  for (const m of content.matchAll(userKvRe)) {
102
150
  const upto = content.slice(0, m.index);
103
151
  const lastUserAt = upto.lastIndexOf("\n user:");
@@ -105,12 +153,17 @@ function parseKubeStaticToken(content) {
105
153
  if (lastAuthProviderAt > lastUserAt) continue;
106
154
  const value = m[3];
107
155
  if (!value || value.startsWith("null")) continue;
108
- staticFound = true;
156
+ tokenValue = value.trim();
109
157
  break;
110
158
  }
111
- return staticFound;
159
+ // Cluster server URL — FP[0] demotes local-only clusters.
160
+ const serverM = content.match(/^\s*server:\s*(\S+)/m);
161
+ return { found: tokenValue !== null, tokenValue, serverUrl: serverM ? serverM[1] : "" };
112
162
  }
113
163
 
164
+ const LOCAL_CLUSTER_RE = /https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])[:/]|\.kind\b|minikube|k3d|docker-for-desktop|docker-desktop/i;
165
+ const CI_RUNNER_PATH_RE = /(?:^|[\\/])(?:home[\\/]runner|github[\\/]workspace|builds|workspace)[\\/]/i;
166
+
114
167
  function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
115
168
  const errors = [];
116
169
  const startTime = Date.now();
@@ -140,6 +193,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
140
193
 
141
194
  const allKeyCarriers = [...shellRcs, ...dotfileKeys];
142
195
  const cleartextHitsByFile = {};
196
+ let cleartextFp = null;
143
197
  for (const p of allKeyCarriers) {
144
198
  if (!fileExists(p)) continue;
145
199
  const c = readSafe(p);
@@ -147,6 +201,11 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
147
201
  const hits = scanShellRc(c);
148
202
  if (hits.length > 0) {
149
203
  cleartextHitsByFile[path.relative(home, p)] = hits;
204
+ const fp = cleartextFpIndices(c);
205
+ if (fp.size) {
206
+ if (cleartextFp === null) cleartextFp = new Set(fp);
207
+ else for (const idx of [...cleartextFp]) if (!fp.has(idx)) cleartextFp.delete(idx);
208
+ }
150
209
  }
151
210
  }
152
211
  const cleartextAnyHit = Object.keys(cleartextHitsByFile).length > 0;
@@ -163,7 +222,8 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
163
222
 
164
223
  const kubeCfgPath = (env && env.KUBECONFIG) || path.join(home, ".kube", "config");
165
224
  const kubeContent = fileExists(kubeCfgPath) ? readSafe(kubeCfgPath) : null;
166
- const kubeStaticToken = parseKubeStaticToken(kubeContent);
225
+ const kubeParsed = parseKubeStaticToken(kubeContent);
226
+ const kubeStaticToken = kubeParsed.found;
167
227
 
168
228
  const signal_overrides = {
169
229
  "cleartext-api-key-in-dotfile": cleartextAnyHit ? "hit" : "miss",
@@ -172,6 +232,51 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
172
232
  "kubeconfig-with-static-token": kubeStaticToken ? "hit" : "miss",
173
233
  };
174
234
 
235
+ // Per-indicator __fp_checks attestation. Each canonical-path credential
236
+ // store the collector reads is never under an examples/tests/fixtures path,
237
+ // so the path-based FP checks are satisfied; value-based checks (placeholder,
238
+ // entropy, sample-credential, cluster-locality) are evaluated deterministically.
239
+ // Network / sts-validity checks are left unattested so the runner still
240
+ // downgrades those. Without this, a real cleartext key or static token
241
+ // surfaced by `collect` is downgraded to inconclusive after `run`.
242
+ if (cleartextAnyHit && cleartextFp && cleartextFp.size) {
243
+ const att = {};
244
+ for (const idx of cleartextFp) att[idx] = true;
245
+ signal_overrides["cleartext-api-key-in-dotfile__fp_checks"] = att;
246
+ }
247
+ if (longLivedAws) {
248
+ const att = {};
249
+ // [0] none of the access-key ids are the AWS-published sample pair
250
+ if (!(awsParsed.accessKeyIds || []).some((k) => AWS_EXAMPLE_KEY_PARTS.has(k))) att["0"] = true;
251
+ // [1] ~/.aws/credentials is a canonical home path, not an examples/test path
252
+ att["1"] = true;
253
+ // [2] sts get-caller-identity needs network — left unattested.
254
+ if (Object.keys(att).length) signal_overrides["long-lived-aws-keys__fp_checks"] = att;
255
+ }
256
+ if (gcloudParsed.hasServiceAccount) {
257
+ const att = {};
258
+ // [0] private_key is a real PEM body (>= 1000 chars), not PLACEHOLDER/REDACTED
259
+ const pk = gcloudParsed.privateKey || "";
260
+ if (pk.length >= 1000 && !/PLACEHOLDER|REDACTED/i.test(pk)) att["0"] = true;
261
+ // [1] client_email is a real *@*.gserviceaccount.com (not example/test)
262
+ const ce = gcloudParsed.clientEmail || "";
263
+ if (/@[^@\s]+\.gserviceaccount\.com$/i.test(ce) && !/@example\.com$|@test\./i.test(ce)) att["1"] = true;
264
+ // [2] canonical ADC path (not under examples/) AND no GOOGLE_APPLICATION_CREDENTIALS
265
+ // redirecting away from it
266
+ if (!(env && env.GOOGLE_APPLICATION_CREDENTIALS)) att["2"] = true;
267
+ if (Object.keys(att).length) signal_overrides["gcp-service-account-json__fp_checks"] = att;
268
+ }
269
+ if (kubeStaticToken) {
270
+ const att = {};
271
+ // [0] cluster server URL is not a local-only dev cluster
272
+ if (kubeParsed.serverUrl && !LOCAL_CLUSTER_RE.test(kubeParsed.serverUrl)) att["0"] = true;
273
+ // [1] token is not a short kind/minikube bootstrap-token shape
274
+ if (kubeParsed.tokenValue && kubeParsed.tokenValue.length >= 40 && !/^[a-z0-9]{6}\.[a-z0-9]{16}$/.test(kubeParsed.tokenValue)) att["1"] = true;
275
+ // [2] kubeconfig is not inside a CI runner workspace
276
+ if (!CI_RUNNER_PATH_RE.test(kubeCfgPath)) att["2"] = true;
277
+ if (Object.keys(att).length) signal_overrides["kubeconfig-with-static-token__fp_checks"] = att;
278
+ }
279
+
175
280
  const artifacts = {
176
281
  "shell-rc-files": {
177
282
  value: cleartextAnyHit
@@ -421,6 +421,33 @@ function collect({ cwd = process.cwd() } = {}) {
421
421
  signal_overrides["cve-citation-needs-external-verification"] = "inconclusive";
422
422
  }
423
423
 
424
+ // __fp_checks attestation for the FP-gated indicators the collector decides
425
+ // deterministically. Each hit already excludes illustrative (template /
426
+ // fixture / doc-snippet) paths and is keyed off the shipped catalogs, so the
427
+ // path / catalog-cross-reference / same-citation checks the collector ran
428
+ // are attested; surrounding-text-acknowledgement remains operator judgement.
429
+ // Without this the runner downgrades a real bad citation to inconclusive.
430
+ if (signal_overrides["fabricated-cve-id"] === "hit") {
431
+ // [0] not under a fixture / regex-example / doc-snippet path (illustrative
432
+ // paths are excluded before the hit). [1] placeholder forms (CVE-TBD /
433
+ // pending) never match the numeric citation regex, so a fired hit is
434
+ // not a placeholder.
435
+ signal_overrides["fabricated-cve-id__fp_checks"] = { "0": true, "1": true };
436
+ }
437
+ if (signal_overrides["rejected-or-disputed-cve"] === "hit") {
438
+ // [1] the catalog note marks THIS exact identifier rejected/disputed.
439
+ // [2] the identifier is present in the catalog (absence does not fire).
440
+ // [0] inline dispute-acknowledgement in surrounding prose is operator
441
+ // judgement — left unattested.
442
+ signal_overrides["rejected-or-disputed-cve__fp_checks"] = { "1": true, "2": true };
443
+ }
444
+ if (signal_overrides["rfc-number-title-mismatch"] === "hit") {
445
+ // [0] a paraphrase / nickname (no title claim) does not fire. [1] numbers
446
+ // absent from the shipped RFC index do not fire. [2] the stated title is
447
+ // extracted from the SAME citation line.
448
+ signal_overrides["rfc-number-title-mismatch__fp_checks"] = { "0": true, "1": true, "2": true };
449
+ }
450
+
424
451
  const summarize = (list) => {
425
452
  if (list.length === 0) return "0 hits";
426
453
  const head = list.slice(0, 5).map((h) => {
@@ -405,6 +405,31 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
405
405
  if (noMlKemImpl !== undefined) signal_overrides["no-ml-kem-implementation"] = noMlKemImpl;
406
406
  if (fipsTheater !== undefined) signal_overrides["fips-claim-without-runtime-activation"] = fipsTheater;
407
407
 
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.
416
+ 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.
419
+ signal_overrides["weak-hash-import__fp_checks"] = { "0": true, "2": true };
420
+ }
421
+ 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.
424
+ signal_overrides["weak-cipher-mode__fp_checks"] = { "0": true, "2": true };
425
+ }
426
+ 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.
430
+ signal_overrides["tls-old-protocol__fp_checks"] = { "0": true };
431
+ }
432
+
408
433
  const summarize = (id) => {
409
434
  const list = hits[id];
410
435
  if (list.length === 0) return "0 hits";
@@ -109,6 +109,18 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
109
109
  }
110
110
  }
111
111
 
112
+ // Running-kernel build config (/boot/config-$(uname -r)) backs the
113
+ // CONFIG_* false_positive_checks_required entries: a sysctl is moot if the
114
+ // feature is compiled out of the kernel. Best-effort — unreadable on many
115
+ // hardened hosts.
116
+ let kernelConfig = null;
117
+ if (linuxPlatform && unameR.ok) {
118
+ try {
119
+ const fs = require("node:fs");
120
+ kernelConfig = fs.readFileSync(`/boot/config-${unameR.value}`, "utf8");
121
+ } catch { /* config not readable — CONFIG_* checks stay unattested */ }
122
+ }
123
+
112
124
  // Signal overrides: we can't decide kver-in-affected-range without
113
125
  // the CVE-affected-version catalog (the runner does that
114
126
  // correlation). But we CAN flip the deterministic indicators that
@@ -127,13 +139,31 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
127
139
  // unpriv-userns-enabled: clone == 1 means enabled (risky).
128
140
  if (parsed.unprivileged_userns_clone != null) {
129
141
  const v = parseInt(parsed.unprivileged_userns_clone, 10);
130
- signal_overrides["unpriv-userns-enabled"] = (v === 1) ? "hit" : "miss";
142
+ const hit = v === 1;
143
+ signal_overrides["unpriv-userns-enabled"] = hit ? "hit" : "miss";
144
+ // FP[1]: CONFIG_USER_NS=y — the sysctl is live only when userns is
145
+ // compiled in. Attested when /boot/config confirms it. FP[0]
146
+ // (rootless-runtime exception + LSM enforcement) is operator
147
+ // judgement and stays unattested.
148
+ if (hit && kernelConfig && /^CONFIG_USER_NS=y$/m.test(kernelConfig)) {
149
+ signal_overrides["unpriv-userns-enabled__fp_checks"] = { "1": true };
150
+ }
131
151
  }
132
152
  // unpriv-bpf-allowed: bpf_disabled == 0 means unprivileged BPF
133
153
  // is allowed (risky).
134
154
  if (parsed.unprivileged_bpf_disabled != null) {
135
155
  const v = parseInt(parsed.unprivileged_bpf_disabled, 10);
136
- signal_overrides["unpriv-bpf-allowed"] = (v === 0) ? "hit" : "miss";
156
+ const hit = v === 0;
157
+ signal_overrides["unpriv-bpf-allowed"] = hit ? "hit" : "miss";
158
+ // FP[0]: CONFIG_BPF_SYSCALL=y AND CONFIG_BPF_JIT=y — the sysctl is
159
+ // moot if BPF is compiled out. Attested when /boot/config confirms
160
+ // both. FP[1] (enforcing LSM bpf() restriction) is operator
161
+ // judgement and stays unattested.
162
+ if (hit && kernelConfig &&
163
+ /^CONFIG_BPF_SYSCALL=y$/m.test(kernelConfig) &&
164
+ /^CONFIG_BPF_JIT=y$/m.test(kernelConfig)) {
165
+ signal_overrides["unpriv-bpf-allowed__fp_checks"] = { "0": true };
166
+ }
137
167
  }
138
168
  }
139
169
  }
@@ -496,6 +496,36 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
496
496
  signal_overrides["lockfile-missing-integrity"] = lockfileMissingIntegrity;
497
497
  }
498
498
 
499
+ // __fp_checks attestation for publish-workflow-action-refs-mutable. Both
500
+ // false_positive_checks_required entries are deterministic from the repo:
501
+ // [0] Dependabot configured for github-actions on a weekly+ schedule
502
+ // demotes the finding — attest survival when no such config exists.
503
+ // [1] every mutable ref pointing to a github-owned action is lower risk —
504
+ // attest survival when at least one mutable ref is third-party.
505
+ if (signal_overrides["publish-workflow-action-refs-mutable"] === "hit") {
506
+ let dependabotActions = false;
507
+ try {
508
+ const dbContent =
509
+ readSafe(path.join(root, ".github", "dependabot.yml")) ||
510
+ readSafe(path.join(root, ".github", "dependabot.yaml")) || "";
511
+ dependabotActions = /package-ecosystem:\s*['"]?github-actions/i.test(dbContent) &&
512
+ /\binterval:\s*['"]?(?:daily|weekly)/i.test(dbContent);
513
+ } catch { /* no dependabot config */ }
514
+ const mutableRefSnippets = (workflowHits["publish-workflow-action-refs-mutable"] || []).map(h => h.snippet || "");
515
+ const refOf = (s) => {
516
+ const m = s.match(/uses:\s*['"]?([^'"\s]+)/);
517
+ return m ? m[1] : "";
518
+ };
519
+ const anyThirdParty = mutableRefSnippets.some(s => {
520
+ const r = refOf(s);
521
+ return r && !/^(?:actions|github)\//i.test(r);
522
+ });
523
+ const att = {};
524
+ if (!dependabotActions) att["0"] = true;
525
+ if (anyThirdParty) att["1"] = true;
526
+ if (Object.keys(att).length) signal_overrides["publish-workflow-action-refs-mutable__fp_checks"] = att;
527
+ }
528
+
499
529
  // Per-indicator file locations for the publish-workflow indicators
500
530
  // flipped to "hit", so a SARIF result points at the workflow file (and,
501
531
  // for mutable action refs, the offending `uses:` line). The other
@@ -106,6 +106,21 @@ function isWorldWritable(p) {
106
106
  } catch { return false; }
107
107
  }
108
108
 
109
+ // Classify a world-writable hit against the two deterministic
110
+ // false_positive_checks_required entries for world-writable-in-trusted-path:
111
+ // [0] sticky-bit (1777-style) dirs/files intentionally permit per-user write
112
+ // [1] 0-byte stamp / unix-socket / FIFO documented for the application
113
+ // Returns { stickyBit, special } so the caller can both keep only genuine
114
+ // hits and attest exactly the checks it ran.
115
+ function classifyWorldWritable(p) {
116
+ try {
117
+ const s = fs.lstatSync(p);
118
+ const stickyBit = (s.mode & 0o1000) !== 0;
119
+ const special = s.isSocket() || s.isFIFO() || (s.isFile() && s.size === 0);
120
+ return { stickyBit, special };
121
+ } catch { return { stickyBit: false, special: false }; }
122
+ }
123
+
109
124
  function readProcPid(pid, procRoot) {
110
125
  // Returns { pid, ppid, uid, exe } or null.
111
126
  try {
@@ -240,11 +255,22 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
240
255
  const passwdContent = readFileSafe(P.passwd);
241
256
  const uid0 = passwdContent ? parsePasswdUidZero(passwdContent) : null;
242
257
 
243
- // World-writable files under trusted paths.
258
+ // World-writable files under trusted paths. Split into genuine hits
259
+ // (regular non-empty files without the sticky bit) and benign carriers
260
+ // the two false_positive_checks_required entries demote (sticky-bit
261
+ // per-user-write dirs; 0-byte stamps / sockets / FIFOs). Only the
262
+ // genuine hits flip the indicator; the split records which FP checks
263
+ // the collector deterministically ran.
244
264
  const worldWritableFiles = [];
265
+ let sawStickyBitCarrier = false;
266
+ let sawSpecialCarrier = false;
245
267
  for (const tp of P.trustedPaths) {
246
268
  for (const f of walkShallow(tp, TRUSTED_PATH_MAX_DEPTH)) {
247
- if (isWorldWritable(f)) worldWritableFiles.push(f);
269
+ if (!isWorldWritable(f)) continue;
270
+ const { stickyBit, special } = classifyWorldWritable(f);
271
+ if (stickyBit) { sawStickyBitCarrier = true; continue; }
272
+ if (special) { sawSpecialCarrier = true; continue; }
273
+ worldWritableFiles.push(f);
248
274
  }
249
275
  }
250
276
 
@@ -266,7 +292,16 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
266
292
  try { fs.readdirSync(tp); return true; } catch { return false; }
267
293
  });
268
294
  if (anyTpReadable) {
269
- signal_overrides["world-writable-in-trusted-path"] = worldWritableFiles.length > 0 ? "hit" : "miss";
295
+ const wwHit = worldWritableFiles.length > 0;
296
+ signal_overrides["world-writable-in-trusted-path"] = wwHit ? "hit" : "miss";
297
+ // Attest the false_positive_checks_required entries the collector
298
+ // ran against every flagged file: [0] sticky-bit carriers and [1]
299
+ // 0-byte/socket/FIFO carriers were both stat-inspected and excluded,
300
+ // so a surviving hit satisfies both. Without this attestation the
301
+ // runner downgrades a real world-writable hit to inconclusive.
302
+ if (wwHit) {
303
+ signal_overrides["world-writable-in-trusted-path__fp_checks"] = { "0": true, "1": true };
304
+ }
270
305
  }
271
306
  // orphan-privileged-process: only emit when /proc was walkable
272
307
  // AND the scan had enough exe-link visibility to reach a verdict.
@@ -354,6 +354,12 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
354
354
  const j = JSON.parse(fs.readFileSync(npmLockfile.path, "utf8"));
355
355
  let withIntegrity = 0;
356
356
  let withoutIntegrity = 0;
357
+ // Track whether any integrity-less entry is a local-path / workspace /
358
+ // git ref. lockfile-no-integrity FP[0] demotes those — they legitimately
359
+ // have no registry integrity hash. A remote-registry tarball without
360
+ // integrity is the genuine finding.
361
+ let withoutIntegrityLocalOnly = true;
362
+ const LOCAL_REF_RE = /^(?:file:|link:|workspace:|git\+ssh:|git\+https:|git:|github:|portal:)/i;
357
363
  const walk = (obj) => {
358
364
  if (!obj || typeof obj !== "object") return;
359
365
  // Only remote-tarball entries (those with a `resolved` URL) are
@@ -362,8 +368,12 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
362
368
  // `integrity`, so keying off `version` would false-positive on
363
369
  // every clean lockfile. Mirror library-author.js's guard.
364
370
  if (obj.resolved != null) {
365
- if (obj.integrity != null) withIntegrity++;
366
- else withoutIntegrity++;
371
+ if (obj.integrity != null) {
372
+ withIntegrity++;
373
+ } else {
374
+ withoutIntegrity++;
375
+ if (!LOCAL_REF_RE.test(String(obj.resolved))) withoutIntegrityLocalOnly = false;
376
+ }
367
377
  }
368
378
  for (const v of Object.values(obj)) if (v && typeof v === "object") walk(v);
369
379
  };
@@ -373,6 +383,15 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
373
383
  // class, not full coverage.
374
384
  if (withoutIntegrity > 0) {
375
385
  signal_overrides["lockfile-no-integrity"] = "hit";
386
+ // __fp_checks attestation. [0]: at least one integrity-less entry is a
387
+ // remote-registry tarball (not exclusively local-path/workspace/git
388
+ // refs). [1]: the lockfile is the canonical root package-lock.json the
389
+ // build consumes, not a stale copy under archive/ pre-migration/.
390
+ const att = {};
391
+ if (!withoutIntegrityLocalOnly) att["0"] = true;
392
+ const rel = (npmLockfile.path || "").replace(/\\/g, "/");
393
+ if (!/\/(?:archive|pre-migration|old|backup|legacy)\//i.test(rel)) att["1"] = true;
394
+ if (Object.keys(att).length) signal_overrides["lockfile-no-integrity__fp_checks"] = att;
376
395
  } else if (withIntegrity > 0) {
377
396
  signal_overrides["lockfile-no-integrity"] = "miss";
378
397
  }
@@ -148,7 +148,10 @@ function walkTree(root, opts = {}) {
148
148
  // `seen` check is needed. This is the hot path — it runs once per
149
149
  // file in the tree and is now syscall-free beyond the parent
150
150
  // readdir.
151
- out.push({ full, rel: path.relative(root, full), name: entry.name });
151
+ // Emit forward-slash rel paths on every platform so artifact
152
+ // summaries match the SARIF evidence_locations (which normalize the
153
+ // same way) — on Windows path.relative returns backslash separators.
154
+ out.push({ full, rel: path.relative(root, full).split(path.sep).join("/"), name: entry.name });
152
155
  }
153
156
  }
154
157
  }
@@ -112,6 +112,98 @@ const AWS_EXAMPLE_ACCESS_KEY_IDS = new Set([
112
112
  "AKIAIOSFODNN7EXAMPLE",
113
113
  ]);
114
114
 
115
+ // AWS-published sample secret-access-key (paired with AKIAIOSFODNN7EXAMPLE
116
+ // throughout the AWS docs). aws-secret-access-key false_positive_checks_required[1]
117
+ // demotes this exact value.
118
+ const AWS_EXAMPLE_SECRET_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
119
+
120
+ // Placeholder/fixture substrings the API-key indicators' FP[0] checks demote.
121
+ // A literal `PLACEHOLDER` / `EXAMPLE` / `XXXX` run / a `dummy`/`test` infix
122
+ // marks documentation material rather than a live credential.
123
+ const PLACEHOLDER_RE = /placeholder|example|redacted|dummy|x{4,}|0{6,}|1234567890/i;
124
+
125
+ // Path segments the per-indicator FP path checks treat as documentation /
126
+ // fixture material in addition to TEST_PATH_SEGMENTS (which already covers
127
+ // /examples/, /fixtures/, /test/ etc.). The secret indicators' FP prose adds
128
+ // /docs/ and quickstart/snippet paths.
129
+ const DOC_PATH_SEGMENTS = [
130
+ "/docs/", "/doc/", "/sdk-quickstart/", "/quickstart/", "/docs-snippet/",
131
+ ];
132
+
133
+ function isDocOrTestPath(rel) {
134
+ if (isTestPath(rel)) return true;
135
+ const norm = "/" + rel.replace(/\\/g, "/").toLowerCase() + "/";
136
+ return DOC_PATH_SEGMENTS.some((seg) => norm.includes(seg));
137
+ }
138
+
139
+ // Per-indicator deterministic false_positive_checks_required evaluation.
140
+ // Returns the set of FP-check indices (as strings) the collector can attest
141
+ // for a single hit — i.e. the checks it actually ran and that the hit
142
+ // survives. Indices requiring network reachability or operator judgement are
143
+ // deliberately omitted so the runner honestly downgrades to inconclusive.
144
+ // `value` is the matched credential, `file` the relative path, `window` a
145
+ // few-line context slice around the match.
146
+ function fpIndicesSatisfied(indicatorId, value, file, window) {
147
+ const sat = new Set();
148
+ const notDocPath = !isDocOrTestPath(file);
149
+ switch (indicatorId) {
150
+ case "aws-secret-access-key": {
151
+ // [0] co-occurrence with an AKIA*/ASIA*/AGPA*/AIDA* id in a 10-line window
152
+ if (/\b(?:AKIA|ASIA|AGPA|AIDA)[0-9A-Z]{12,}\b/.test(window)) sat.add("0");
153
+ // [1] not the AWS-published sample secret
154
+ if (value !== AWS_EXAMPLE_SECRET_KEY) sat.add("1");
155
+ // [2] not under examples/ docs/ fixtures/ / a test snapshot
156
+ if (notDocPath) sat.add("2");
157
+ break;
158
+ }
159
+ case "slack-bot-or-user-token": {
160
+ // [0] not a placeholder / published doc fixture
161
+ if (!PLACEHOLDER_RE.test(value)) sat.add("0");
162
+ // [1] conforms to a current Slack token shape: at least three
163
+ // dash-separated segments after the xox? prefix
164
+ if (value.split("-").length >= 4) sat.add("1");
165
+ // [2] not under examples/ docs/ fixtures/
166
+ if (notDocPath) sat.add("2");
167
+ break;
168
+ }
169
+ case "stripe-secret-key": {
170
+ // [0] not a sk_test_ published sample (deterministic only for the
171
+ // test prefix; live keys are handled by [2])
172
+ if (!(value.startsWith("sk_test_") && PLACEHOLDER_RE.test(value))) sat.add("0");
173
+ // [1] not under examples/ fixtures/ docs/ / a quickstart template
174
+ if (notDocPath) sat.add("1");
175
+ // [2] the live-validity probe applies only to sk_live_*; a sk_test_
176
+ // key carries no live financial exposure, so the check is moot and
177
+ // the collector can attest it deterministically. sk_live_* still
178
+ // needs operator-authorised network validation — left unattested.
179
+ if (value.startsWith("sk_test_") || value.startsWith("rk_test_")) sat.add("2");
180
+ break;
181
+ }
182
+ case "openai-api-key": {
183
+ // [0] not a placeholder / sk-test- / sk-dummy- fixture
184
+ if (!PLACEHOLDER_RE.test(value) && !/^sk-(?:test|dummy)-/i.test(value)) sat.add("0");
185
+ // [1] post-prefix length meets the entropy floor (>= 48 chars)
186
+ if (value.replace(/^sk-(?:proj-|svcacct-|admin-)?/, "").length >= 48) sat.add("1");
187
+ // [2] vendor disambiguation — sk-ant-* is Anthropic, not OpenAI
188
+ if (!/^sk-ant-/i.test(value)) sat.add("2");
189
+ break;
190
+ }
191
+ case "anthropic-api-key": {
192
+ // [0] not a placeholder / sk-ant-test- fixture
193
+ if (!PLACEHOLDER_RE.test(value) && !/^sk-ant-test-/i.test(value)) sat.add("0");
194
+ // [1] not under examples/ fixtures/ sdk-quickstart/ docs-snippet/
195
+ if (notDocPath) sat.add("1");
196
+ // [2] post-prefix length meets the entropy floor (>= 80 chars after
197
+ // sk-ant-(api03|admin01)-)
198
+ if (value.replace(/^sk-ant-(?:api03|admin01)-/, "").length >= 80) sat.add("2");
199
+ break;
200
+ }
201
+ default:
202
+ break;
203
+ }
204
+ return sat;
205
+ }
206
+
115
207
  const INDICATOR_PATTERNS = [
116
208
  { id: "aws-access-key-id", re: /\bAKIA[0-9A-Z]{16}\b/g },
117
209
  { id: "aws-secret-access-key", re: /\baws_secret_access_key\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/gi },
@@ -209,6 +301,13 @@ function scanContent(full, rel) {
209
301
  // Demote AWS-published example access-key IDs (e.g. the docs' canonical
210
302
  // AKIAIOSFODNN7EXAMPLE). A README quoting the AWS docs must not hit.
211
303
  if (p.id === "aws-access-key-id" && AWS_EXAMPLE_ACCESS_KEY_IDS.has(m[0])) continue;
304
+ // The captured credential is group 1 when the pattern brackets it
305
+ // (aws_secret_access_key=<value>); otherwise the whole match.
306
+ const value = m[1] != null ? m[1] : m[0];
307
+ // ±600-byte context window (a deterministic proxy for "nearby lines")
308
+ // used by the co-occurrence FP check.
309
+ const winStart = Math.max(0, m.index - 600);
310
+ const window = buf.slice(winStart, m.index + 600);
212
311
  hits.push({
213
312
  indicator_id: p.id,
214
313
  file: rel,
@@ -217,6 +316,9 @@ function scanContent(full, rel) {
217
316
  // (SARIF startLine) instead of a bare file-level location.
218
317
  line: lineFromOffset(buf, m.index),
219
318
  redacted_match: redactMatch(m[0]),
319
+ // The false_positive_checks_required indices this hit deterministically
320
+ // survives — attested so the runner doesn't downgrade hit → inconclusive.
321
+ fp_satisfied: fpIndicesSatisfied(p.id, value, rel, window),
220
322
  });
221
323
  if (++count >= 5) break; // cap per-indicator-per-file
222
324
  }
@@ -310,6 +412,29 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
310
412
  for (const p of INDICATOR_PATTERNS) {
311
413
  signal_overrides[p.id] = prodHitsByIndicator[p.id] && prodHitsByIndicator[p.id].length > 0 ? "hit" : "miss";
312
414
  }
415
+ // Per-indicator __fp_checks attestation. For each FP-gated indicator that
416
+ // fired, attest the false_positive_checks_required indices the collector
417
+ // deterministically ran AND that EVERY surviving hit satisfies (the
418
+ // intersection — an index is only universally true if no hit fails it).
419
+ // Network / operator-judgement indices are never in the set, so the runner
420
+ // still downgrades indicators that carry one. Without this, a real secret
421
+ // surfaced by `collect` is downgraded to inconclusive after `run`.
422
+ for (const p of INDICATOR_PATTERNS) {
423
+ if (signal_overrides[p.id] !== "hit") continue;
424
+ const hits = prodHitsByIndicator[p.id] || [];
425
+ if (!hits.length || !hits[0].fp_satisfied) continue;
426
+ let common = null;
427
+ for (const h of hits) {
428
+ const s = h.fp_satisfied || new Set();
429
+ if (common === null) { common = new Set(s); continue; }
430
+ for (const idx of [...common]) if (!s.has(idx)) common.delete(idx);
431
+ }
432
+ if (common && common.size) {
433
+ const att = {};
434
+ for (const idx of common) att[idx] = true;
435
+ signal_overrides[`${p.id}__fp_checks`] = att;
436
+ }
437
+ }
313
438
  // ssh-private-key-block is also flipped by file presence (a private
314
439
  // key file with the matching magic bytes counts even without a
315
440
  // content scan match — e.g. binary-only key formats). Re-flip when
package/lib/cve-cli.js CHANGED
@@ -69,4 +69,12 @@ const { resolveCve } = require("./citation-resolve.js");
69
69
  if (fails) {
70
70
  process.exitCode = 2;
71
71
  }
72
- })();
72
+ })().catch((err) => {
73
+ // A corrupt/unreadable catalog (or any unexpected throw inside the async
74
+ // body) becomes a rejected promise. Emit the same single-line
75
+ // {ok:false,error} envelope the verb promises rather than crashing with a
76
+ // raw stack trace, and signal failure via exitCode so the event loop drains
77
+ // stderr before exit.
78
+ process.stderr.write(JSON.stringify({ ok: false, verb: "cve", error: String((err && err.message) || err) }) + "\n");
79
+ process.exitCode = 1;
80
+ });