@blamejs/exceptd-skills 0.12.15 → 0.12.18

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.
@@ -43,7 +43,10 @@
43
43
  "on_fail": "warn"
44
44
  }
45
45
  ],
46
- "mutex": ["secrets"],
46
+ "mutex": [
47
+ "secrets",
48
+ "containers"
49
+ ],
47
50
  "feeds_into": [
48
51
  {
49
52
  "playbook_id": "sbom",
@@ -563,14 +566,14 @@
563
566
  },
564
567
  {
565
568
  "id": "signing-key-material",
566
- "type": "file_path",
569
+ "type": "file",
567
570
  "source": "Locate: cosign.pub, *.pem (public-key signing artifacts), keys/public.pem, .well-known/openpgpkey/, sigstore-cosign metadata in workflow",
568
571
  "description": "Public signing material committed to the repo or referenced from the release workflow.",
569
572
  "required": false
570
573
  },
571
574
  {
572
575
  "id": "intoto-attestations",
573
- "type": "file_path",
576
+ "type": "file",
574
577
  "source": "Glob: *.intoto.jsonl, *.sigstore, *.sbom.json.sig, *.cdx.json.sig, attestations directory; AND for npm packages: GET https://registry.npmjs.org/${pkg} and inspect dist.attestations",
575
578
  "description": "in-toto / Sigstore / SLSA provenance attestations attached to releases.",
576
579
  "required": false,
@@ -592,7 +595,7 @@
592
595
  },
593
596
  {
594
597
  "id": "vendored-code",
595
- "type": "file_path",
598
+ "type": "file",
596
599
  "source": "List directories: vendor/, third_party/, external/, deps/, internal/vendor/; AND for each: locate provenance manifest (vendor.json, THIRD_PARTY_PROVENANCE.md, modules.txt for Go vendored)",
597
600
  "description": "Vendored / bundled third-party code carried in the publisher's own release.",
598
601
  "required": false
@@ -644,7 +647,7 @@
644
647
  },
645
648
  {
646
649
  "id": "skill-signing-infrastructure",
647
- "type": "file_path",
650
+ "type": "file",
648
651
  "source": "If repo ships skills / plugins / extensions: locate lib/sign.js, lib/verify.js, scripts/sign-skills.*, signatures/, keys/, AND read package.json scripts for sign/verify entries",
649
652
  "description": "Specific to AI-tool / plugin / skill publishers: Ed25519 / cosign signing scaffolding.",
650
653
  "required": false
@@ -786,7 +789,11 @@
786
789
  "description": "Mutable action reference — upstream owner can substitute action code under the same tag.",
787
790
  "confidence": "deterministic",
788
791
  "deterministic": true,
789
- "attack_ref": "T1195.001"
792
+ "attack_ref": "T1195.001",
793
+ "false_positive_checks_required": [
794
+ "If Dependabot is configured (.github/dependabot.yml with package-ecosystem: github-actions, schedule >= weekly) AND the repo has a recent (within last 60 days) Dependabot PR updating action SHAs — the mutable-ref window is bounded; demote to lower-confidence finding (not miss; tag-pinning is still strictly safer than SHA-pinning-with-Dependabot).",
795
+ "If every mutable ref points to a github-owned action (actions/*, github/*) the supply-chain risk is materially lower than third-party action refs; tag-with-rationale-in-comment is acceptable for github-owned. Demote third-party-only mutable refs above github-owned ones in reporting."
796
+ ]
790
797
  },
791
798
  {
792
799
  "id": "package-json-provenance-missing",
@@ -839,11 +846,15 @@
839
846
  },
840
847
  {
841
848
  "id": "tag-protection-absent",
842
- "type": "api_response",
849
+ "type": "api_call_sequence",
843
850
  "value": "Within the branch-tag-protection artifact: gh api repos/${owner}/${repo}/tags/protection returns empty array OR 404 AND the release-workflows artifact contains a publish workflow that triggers on push tags: ['v*']",
844
851
  "description": "Release tags unprotected (theater #6) — any branch can push v* and trigger publish.",
845
852
  "confidence": "deterministic",
846
- "deterministic": true
853
+ "deterministic": true,
854
+ "false_positive_checks_required": [
855
+ "If repository rulesets (newer model) protect the tag pattern v*.*.* with delete + non-fast-forward + update blocked AND zero bypass actors — the legacy tag-protection-rules API may return empty while protection is in place. Cross-check the rulesets API before flagging.",
856
+ "If the repository is read-only / archived (archived: true via the repo API) — tag protection is moot; demote to miss."
857
+ ]
847
858
  },
848
859
  {
849
860
  "id": "release-tag-not-signed",
@@ -871,7 +882,7 @@
871
882
  },
872
883
  {
873
884
  "id": "no-security-txt",
874
- "type": "api_response",
885
+ "type": "api_call_sequence",
875
886
  "value": "If product has a primary domain: HTTP 404 / connection failure on /.well-known/security.txt",
876
887
  "description": "No RFC 9116 machine-discoverable disclosure path (theater #10).",
877
888
  "confidence": "deterministic",
@@ -879,7 +890,7 @@
879
890
  },
880
891
  {
881
892
  "id": "private-vuln-reporting-disabled",
882
- "type": "api_response",
893
+ "type": "api_call_sequence",
883
894
  "value": "gh api repos/${owner}/${repo} --jq '.security_and_analysis.private_vulnerability_reporting.status' returns 'disabled'",
884
895
  "description": "GitHub private vulnerability reporting off — no OIDC-authenticated disclosure intake.",
885
896
  "confidence": "deterministic",
@@ -440,7 +440,7 @@
440
440
  },
441
441
  {
442
442
  "id": "mcp-tool-response-log",
443
- "type": "log_pattern",
443
+ "type": "log",
444
444
  "source": "AI client MCP-protocol logs: ~/.claude/logs/mcp/*.jsonl (Claude Code), ~/.cursor/logs/mcp-*.log (Cursor), ~/.codeium/windsurf/logs/mcp_*.log (Windsurf)",
445
445
  "description": "v0.12.6: Verbatim tools/list and tools/call response capture. The only artifact that lets ANSI-escape, Unicode-Tag-smuggling, instruction-coercion-grammar, and sensitive-path-reference indicators fire. If client doesn't log MCP responses, mark inconclusive and recommend enabling MCP request/response verbose logging in client settings.",
446
446
  "required": false
@@ -550,7 +550,11 @@
550
550
  "value": "ps shows an MCP server process running with effective UID 0 or with elevated capabilities (CAP_SYS_ADMIN, CAP_NET_ADMIN)",
551
551
  "description": "MCP server with elevated privileges turns a tool-response RCE into immediate root.",
552
552
  "confidence": "deterministic",
553
- "deterministic": true
553
+ "deterministic": true,
554
+ "false_positive_checks_required": [
555
+ "If the MCP server's documented purpose requires elevated capability (kernel-introspection / host-firewall / docker-control MCP) AND it is on a vendor-published privileged-allowlist with a signed manifest, accept with explicit scope.",
556
+ "Verify root-on-host vs root-in-user-namespace — inspect /proc/<pid>/status Cap* fields and userns mapping. A rootless-container MCP running as in-namespace UID 0 has materially lower blast radius; demote to lower confidence."
557
+ ]
554
558
  },
555
559
  {
556
560
  "id": "copilot-yolo-mode-flag",
@@ -579,7 +583,11 @@
579
583
  "confidence": "deterministic",
580
584
  "deterministic": true,
581
585
  "atlas_ref": "AML.T0051",
582
- "attack_ref": "T1059"
586
+ "attack_ref": "T1059",
587
+ "false_positive_checks_required": [
588
+ "If the MCP tool is a documented terminal-emulation / shell-output renderer (the tool's purpose is to relay terminal output and the host AI assistant renders it inline) AND the escape bytes are SGR-only with no cursor-movement / screen-clear / OSC-8 codes, the escape carries no instruction-coercion payload; demote.",
589
+ "Confirm the escape appears in a tool-output payload AND not in a tools/list metadata field — metadata is the high-leverage smuggling site; output may be expected. If only in output for a documented terminal tool, demote."
590
+ ]
583
591
  },
584
592
  {
585
593
  "id": "mcp-response-unicode-tag-smuggling",
@@ -588,7 +596,11 @@
588
596
  "description": "Unicode Tag-range smuggling — zero-width to humans, tokenized by the model. Source: Embrace the Red (Rehberger, 2025).",
589
597
  "confidence": "deterministic",
590
598
  "deterministic": true,
591
- "atlas_ref": "AML.T0051"
599
+ "atlas_ref": "AML.T0051",
600
+ "false_positive_checks_required": [
601
+ "Tag-range codepoints have no documented legitimate use in MCP transport. Confirm the file content is actually a JSON payload (not, e.g., a captured raw email body that contains a U+E0000-tagged language hint per RFC 4646 / RFC 5646 BCP-47 legacy). For genuine MCP payloads, presence is unambiguous; demote only if the payload is from a non-MCP capture mislabeled in the artifact.",
602
+ "Cross-check against the MCP server's package signature / publisher field — if the server is signed by a first-party allowlisted publisher AND the codepoint appears only in a documented `language-tag` schema field, treat as legacy-language-tag (RFC 6082 marked Tag deprecated 2011); still flag for review."
603
+ ]
592
604
  },
593
605
  {
594
606
  "id": "mcp-response-instruction-coercion",
@@ -68,7 +68,9 @@
68
68
  "T1068",
69
69
  "T1548.003"
70
70
  ],
71
- "cve_refs": [],
71
+ "cve_refs": [
72
+ "CVE-2026-31431"
73
+ ],
72
74
  "cwe_refs": [
73
75
  "CWE-269",
74
76
  "CWE-732",
@@ -428,7 +430,12 @@
428
430
  "description": "Multiple UID=0 accounts. T1136.001 persistence pattern. Outside legitimate installs.",
429
431
  "confidence": "deterministic",
430
432
  "deterministic": true,
431
- "attack_ref": "T1136.001"
433
+ "attack_ref": "T1136.001",
434
+ "false_positive_checks_required": [
435
+ "If the second UID-0 account is `toor` on FreeBSD / `tor` on certain BSDs (documented alternate-shell root) or is a vendor-documented sudo-replacement account (e.g. AlmaLinux's `almauser`, Amazon Linux's `ec2-user` aliased), check the OS-vendor documentation; demote if intentional.",
436
+ "Cross-check `/etc/shadow` for the second account — if the password field is `!` / `*` (locked) AND no SSH authorized_keys / kerberos principal exists, the account is non-authentication-bearing; report at high but not deterministic.",
437
+ "If the host is a documented break-glass jumpbox with a renamed-root account replacing `root` (and `root` itself is removed), this is intentional; accept with the runbook reference."
438
+ ]
432
439
  },
433
440
  {
434
441
  "id": "listening-socket-unknown-bind",
@@ -455,7 +462,11 @@
455
462
  "description": "Hijack-execution-flow primitive. Any non-root process can replace the file.",
456
463
  "confidence": "deterministic",
457
464
  "deterministic": true,
458
- "attack_ref": "T1574.005"
465
+ "attack_ref": "T1574.005",
466
+ "false_positive_checks_required": [
467
+ "Verify the file has the sticky bit set (1777-style mode on /opt subdirs that intentionally permit per-user write). Sticky-bit mode 1777 + ownership root:root limits cross-user replacement and is documented for some package-managers; demote to lower confidence.",
468
+ "If the file is a 0-byte stamp / unix-socket / FIFO under /opt that's documented for the application (e.g. mode 0666 socket for /opt/vendor/sock), the indicator is misclassified by the gatherer; demote."
469
+ ]
459
470
  },
460
471
  {
461
472
  "id": "orphan-privileged-process",
@@ -464,7 +475,12 @@
464
475
  "description": "Privileged orphan in a writable temp path. Common implant shape.",
465
476
  "confidence": "deterministic",
466
477
  "deterministic": true,
467
- "attack_ref": "T1055"
478
+ "attack_ref": "T1055",
479
+ "false_positive_checks_required": [
480
+ "If the process executable matches a known anti-malware / VPN-client / EDR-agent that documentedly drops a binary under /var/tmp for sandbox-extraction reasons (e.g. CrowdStrike Falcon, SentinelOne, MS Defender Linux), check the org's endpoint-agent allowlist; demote if matched.",
481
+ "Verify the binary checksum (sha256) against the org's allowlisted-deploys inventory. A known-hash match means the binary is approved; demote. A mismatch means the path is unsanctioned and the indicator stands.",
482
+ "If the process was launched by `nohup` / `setsid` / `at` from an interactive session and is documented in the system's `at` queue or operator scratch list, accept as ephemeral with a TTL."
483
+ ]
468
484
  }
469
485
  ],
470
486
  "false_positive_profile": [
@@ -499,7 +499,7 @@
499
499
  },
500
500
  {
501
501
  "id": "model-weight-files",
502
- "type": "file_path",
502
+ "type": "file",
503
503
  "source": "find $HOME ~/.cache/huggingface ~/.cache/torch /var/lib/model-store -type f \\( -name '*.pt' -o -name '*.ckpt' -o -name '*.bin' -o -name '*.safetensors' -o -name '*.gguf' \\) 2>/dev/null AND for each: file <path> AND attempt to identify Sigstore signature / OpenSSF model-signing attestation",
504
504
  "description": "Model weight artifacts — needed for AI-supply-chain analysis (ATLAS AML.T0018).",
505
505
  "required": false
@@ -528,7 +528,7 @@
528
528
  },
529
529
  {
530
530
  "id": "tanstack-payload-sweep",
531
- "type": "file_path",
531
+ "type": "file",
532
532
  "source": "find node_modules -path '*/@tanstack/*' \\( -name 'router_init.js' -o -name 'router_runtime.js' \\) 2>/dev/null",
533
533
  "description": "CVE-2026-45321 IoC sweep — payload markers inside any installed @tanstack/* package. Captures both flat npm and pnpm-style nested layouts.",
534
534
  "required": false
@@ -639,7 +639,11 @@
639
639
  "value": "Within the repo-lockfiles artifact: any pinned dependency lacks an integrity field (sha512/sha384/sha256, sri-integrity, go.sum-style hash)",
640
640
  "description": "Theater fingerprint #2 detection — name-pinned without integrity.",
641
641
  "confidence": "deterministic",
642
- "deterministic": true
642
+ "deterministic": true,
643
+ "false_positive_checks_required": [
644
+ "If the missing-integrity entries are local-path / workspace / git+ssh refs (`file:`, `link:`, `workspace:`, `git+ssh:`) that the package manager intentionally omits integrity for, demote — workspace refs are integrity-checked at the monorepo root.",
645
+ "Confirm the lockfile is the one the build actually consumes — repos can have stale lockfiles in `archive/` or `pre-migration/` subdirs. Check the build script's referenced lockfile path."
646
+ ]
643
647
  },
644
648
  {
645
649
  "id": "transitive-deps-incomplete-sbom",
@@ -681,7 +685,11 @@
681
685
  "description": "Direct match for CVE-2026-30615.",
682
686
  "confidence": "deterministic",
683
687
  "deterministic": true,
684
- "attack_ref": "T1190"
688
+ "attack_ref": "T1190",
689
+ "false_positive_checks_required": [
690
+ "If the Windsurf binary is the vendor-published patched build identified by sha256 hash (cross-reference Codeium's signed release manifest), the recorded version string may not advance until the next major release; demote when the binary hash matches the patched-build allowlist.",
691
+ "Confirm the installation is on a network-isolated jumpbox / air-gapped dev workstation with documented offline procedure — exploit primitive requires network egress to attacker C2. Demote when the host is provably air-gapped."
692
+ ]
685
693
  },
686
694
  {
687
695
  "id": "kev-listed-match",
@@ -689,7 +697,12 @@
689
697
  "value": "Any package-matches-catalogued-cve match resolves to a CVE with cisa_kev=true in the catalog",
690
698
  "description": "KEV-listed match — fast-path escalation required.",
691
699
  "confidence": "deterministic",
692
- "deterministic": true
700
+ "deterministic": true,
701
+ "false_positive_checks_required": [
702
+ "Pull the vex-statements artifact for the matched CVE. If a VEX entry exists with status `not_affected` (justification: `component_not_present` / `vulnerable_code_not_in_execute_path` / `vulnerable_code_cannot_be_controlled_by_adversary`) AND `status_notes` references a maintainer review, demote — but keep on the regression schedule.",
703
+ "Verify the matched package version is actually present in the live deploy (not just in a build-time/dev-time dependency) — runtime presence elevates; build-time-only presence demotes to high.",
704
+ "If the matched VEX entry has `status: fixed` AND the lockfile resolution is newer-than-fix-version, the match is stale and should be demoted; re-run the dependency walker to refresh."
705
+ ]
693
706
  },
694
707
  {
695
708
  "id": "tanstack-worm-payload-files",
@@ -35,7 +35,9 @@
35
35
  "on_fail": "warn"
36
36
  }
37
37
  ],
38
- "mutex": [],
38
+ "mutex": [
39
+ "library-author"
40
+ ],
39
41
  "feeds_into": [
40
42
  {
41
43
  "playbook_id": "cred-stores",
@@ -389,7 +391,12 @@
389
391
  "description": "AWS Secret Access Key. Always findable in tandem with AKIA*. Independent finding because both halves are needed.",
390
392
  "confidence": "deterministic",
391
393
  "deterministic": true,
392
- "attack_ref": "T1552.001"
394
+ "attack_ref": "T1552.001",
395
+ "false_positive_checks_required": [
396
+ "Check for co-occurrence with an AKIA*/ASIA*/AGPA*/AIDA* access-key-id in a 10-line window. Without corroboration, the 40-char base64-ish string is plausibly a Base64-encoded JWT signature / random data / DB password — demote to medium when corroboration is absent.",
397
+ "If the value matches the AWS-published sample wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY, demote to miss (doc fixture).",
398
+ "Verify the file is not under examples/ / docs/ / fixtures/ AND not a known-test snapshot; demote when in a documented placeholder path."
399
+ ]
393
400
  },
394
401
  {
395
402
  "id": "gcp-service-account-json",
@@ -425,7 +432,12 @@
425
432
  "description": "Slack bot / user / refresh / app token.",
426
433
  "confidence": "deterministic",
427
434
  "deterministic": true,
428
- "attack_ref": "T1552.001"
435
+ "attack_ref": "T1552.001",
436
+ "false_positive_checks_required": [
437
+ "If the value is `xoxb-PLACEHOLDER` / `xoxb-EXAMPLE` / a Slack-published doc fixture (e.g. xoxb-12345-67890-AbCdEf), demote.",
438
+ "Verify the token format conforms to a current Slack token shape (xoxb- usually has at least three dash-separated numeric segments before the alpha suffix). Loose 10-char tail matches without segmentation can match unrelated strings; demote when segmentation is wrong.",
439
+ "If under examples/ / docs/ / fixtures/ AND no surrounding env var (SLACK_BOT_TOKEN / SLACK_USER_TOKEN) suggests an active config, demote."
440
+ ]
429
441
  },
430
442
  {
431
443
  "id": "stripe-secret-key",
@@ -434,7 +446,12 @@
434
446
  "description": "Stripe live or test secret key. Live keys are direct financial exposure.",
435
447
  "confidence": "deterministic",
436
448
  "deterministic": true,
437
- "attack_ref": "T1552.001"
449
+ "attack_ref": "T1552.001",
450
+ "false_positive_checks_required": [
451
+ "If the prefix is `sk_test_` AND the value matches a published Stripe sample test key (see Stripe API docs — they publish a small set explicitly for documentation use), demote — test keys cannot move funds.",
452
+ "Verify the file path is not under examples/, fixtures/, docs/, or a stripe-quickstart template — demote in documented placeholder paths.",
453
+ "For sk_live_*, cross-check against the Stripe API (`POST /v1/accounts/retrieve` or similar live-validity probe) only if the operator has authorised an external validation; otherwise treat the live prefix as deterministic."
454
+ ]
438
455
  },
439
456
  {
440
457
  "id": "jwt-token-with-secret-context",
@@ -461,7 +478,12 @@
461
478
  "description": "OpenAI API key (incl. project-scoped sk-proj-* form). Active key spend exposure.",
462
479
  "confidence": "deterministic",
463
480
  "deterministic": true,
464
- "attack_ref": "T1552.001"
481
+ "attack_ref": "T1552.001",
482
+ "false_positive_checks_required": [
483
+ "If the key prefix is `sk-test-*` / `sk-dummy-*` / `sk-XXXX` / contains only placeholder runs (XXXXX, 1234, abcd) it is a documentation fixture; demote.",
484
+ "Verify the key length meets the OpenAI minimum entropy floor (post-prefix length >= 48 chars). Below the floor is a placeholder; demote.",
485
+ "The regex `sk-[A-Za-z0-9_-]{40,}` also matches Anthropic sk-ant-* and other vendor-prefixed keys — confirm the vendor by surrounding context (env var name, comment) before classifying as OpenAI."
486
+ ]
465
487
  },
466
488
  {
467
489
  "id": "anthropic-api-key",
@@ -470,7 +492,12 @@
470
492
  "description": "Anthropic API key. Active key spend exposure.",
471
493
  "confidence": "deterministic",
472
494
  "deterministic": true,
473
- "attack_ref": "T1552.001"
495
+ "attack_ref": "T1552.001",
496
+ "false_positive_checks_required": [
497
+ "If the key is `sk-ant-api03-PLACEHOLDER...` / `sk-ant-test-*` / contains documented placeholder substrings (XXXX runs, all-zero runs), demote.",
498
+ "Verify the file is not under examples/, fixtures/, sdk-quickstart/, or a docs-snippet path — demote when in documented placeholder paths.",
499
+ "Confirm post-prefix length meets the Anthropic minimum entropy floor (>= 80 chars after sk-ant-(api03|admin01)-); below the floor is a fixture, demote."
500
+ ]
474
501
  },
475
502
  {
476
503
  "id": "world-writable-env-file",
@@ -31,6 +31,63 @@ const fs = require("fs");
31
31
  const path = require("path");
32
32
  const { scoreCustom } = require("./scoring");
33
33
 
34
+ // audit M P1-C: stored rwep_factors must reproduce the stored rwep_score.
35
+ // `buildScoringInputs` is the single source of truth for both — it captures
36
+ // the conservative defaults applied to a freshly-imported KEV draft (CISA
37
+ // only lists vulnerabilities with documented exploitation, so we assume a
38
+ // public PoC exists; reboot defaults to true because most KEV-listed CVEs
39
+ // land in the kernel / hypervisor / vendor firmware where reboot is the
40
+ // norm). The same input object is then handed to scoreCustom for the score
41
+ // AND mapped into the `rwep_factors` shape stored on the draft. Calling
42
+ // scoring.validate() on the post-import catalog will no longer flag every
43
+ // auto-imported draft for divergence > 5.
44
+ function buildScoringInputs(kevEntry /*, nvdPayload */) {
45
+ void kevEntry;
46
+ return {
47
+ cisa_kev: true,
48
+ poc_available: true,
49
+ ai_assisted_weapon: false,
50
+ ai_discovered: false,
51
+ active_exploitation: "suspected",
52
+ blast_radius: 15,
53
+ patch_available: false,
54
+ live_patch_available: false,
55
+ reboot_required: true,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Audit M P3-O — diff severity nuance for KEV-discovered drafts.
61
+ *
62
+ * Pre-fix every KEV-derived diff carried `severity: "high"`. Operators
63
+ * scanning the diff stream had no way to distinguish "patch in 21 days"
64
+ * from "active ransomware campaign, patch yesterday." Now:
65
+ *
66
+ * - ransomware_use === "Known" → "critical" (campaigns observed in the wild)
67
+ * - dueDate within 7 days of now → "critical" (CISA escalation window)
68
+ * - otherwise → "high" (still actively exploited per KEV listing)
69
+ *
70
+ * A KEV listing inherently means active exploitation; "low" / "medium"
71
+ * never apply here. The split is between "act today" and "act this sprint."
72
+ *
73
+ * @param {object} kevEntry
74
+ * @returns {"critical" | "high"}
75
+ */
76
+ function deriveKevSeverity(kevEntry) {
77
+ const ransomware = String(kevEntry?.knownRansomwareCampaignUse || "").toLowerCase() === "known";
78
+ if (ransomware) return "critical";
79
+ const due = kevEntry?.dueDate;
80
+ if (typeof due === "string" && /^\d{4}-\d{2}-\d{2}/.test(due)) {
81
+ const dueMs = Date.parse(due);
82
+ if (Number.isFinite(dueMs)) {
83
+ const deltaMs = dueMs - Date.now();
84
+ // Within the next 7 days OR already past due → critical.
85
+ if (deltaMs <= 7 * 86_400_000) return "critical";
86
+ }
87
+ }
88
+ return "high";
89
+ }
90
+
34
91
  const TODAY = new Date().toISOString().slice(0, 10);
35
92
  const TIMEOUT_MS = 10_000;
36
93
  const USER_AGENT = "exceptd-security/auto-discovery (+https://exceptd.com)";
@@ -110,37 +167,17 @@ function buildKevDraftEntry(kevEntry, nvdPayload, epssPayload) {
110
167
  const knownRansomware =
111
168
  String(kevEntry.knownRansomwareCampaignUse || "").toLowerCase() === "known";
112
169
 
113
- // Compute initial RWEP. KEV +25, suspected exploitation +10.
114
- // Unknown PoC/AI flags default to false (conservative we don't
115
- // claim more than we know). Blast radius defaults to 15 (mid-range)
116
- // since we can't infer it from KEV metadata alone.
117
- const rwep_factors = {
118
- cisa_kev: true,
119
- poc_available: null, // unknown curation needed
120
- ai_assisted_weapon: null,
121
- ai_discovered: null,
122
- active_exploitation: "suspected", // KEV listing implies exploitation
123
- blast_radius: 15,
124
- patch_available: null,
125
- live_patch_available: null,
126
- reboot_required: null,
127
- };
128
- // scoreCustom() treats null fields as false, which under-counts the
129
- // score. Pass concrete defaults for unknowns: poc_available=true is
130
- // the conservative assumption for KEV entries (CISA generally only
131
- // adds entries with documented exploitation), and reboot_required=
132
- // true biases toward urgency.
133
- const rwep_score = scoreCustom({
134
- cisa_kev: true,
135
- poc_available: true,
136
- ai_assisted_weapon: false,
137
- ai_discovered: false,
138
- active_exploitation: "suspected",
139
- blast_radius: 15,
140
- patch_available: false,
141
- live_patch_available: false,
142
- reboot_required: true,
143
- });
170
+ // audit M P1-C: stored rwep_factors and computed rwep_score MUST agree.
171
+ // Previously rwep_factors held nulls (for unknown poc/ai/reboot) but
172
+ // rwep_score was computed from concrete defaults (poc=true, reboot=true).
173
+ // `scoring.validate()` then flagged every auto-imported draft for
174
+ // divergence > 5. Now: one canonical input object → both surfaces.
175
+ // The curation flow rewrites these once an operator answers the editorial
176
+ // questions; until then, the boolean shape on rwep_factors is the
177
+ // conservative-default snapshot and reproduces the score exactly.
178
+ const scoringInputs = buildScoringInputs(kevEntry, nvdPayload);
179
+ const rwep_factors = { ...scoringInputs };
180
+ const rwep_score = scoreCustom(scoringInputs);
144
181
 
145
182
  const product = [kevEntry.vendorProject, kevEntry.product]
146
183
  .filter(Boolean)
@@ -254,7 +291,7 @@ function discoverNewKev(ctx, cap = DEFAULT_CAP) {
254
291
  op: "add",
255
292
  target: "cveCatalog",
256
293
  entry,
257
- severity: "high",
294
+ severity: deriveKevSeverity(kev),
258
295
  meta: {
259
296
  date_added: kev.dateAdded || null,
260
297
  vendor: kev.vendorProject || null,
@@ -529,6 +566,7 @@ module.exports = {
529
566
  discoverNewRfcs,
530
567
  buildKevDraftEntry,
531
568
  getProjectRfcGroups,
569
+ deriveKevSeverity,
532
570
  SEED_RFC_GROUPS,
533
571
  DEFAULT_CAP,
534
572
  };
@@ -43,6 +43,14 @@ const path = require("path");
43
43
  // before deciding promotion.
44
44
  const { withCatalogLock } = require("./refresh-external");
45
45
  const { validate: validateAgainstSchema } = require("./validate-cve-catalog");
46
+ // audit J F3: derive rwep_score via the canonical scoring helper rather
47
+ // than a blind `Object.values(...).reduce(sum)`. The helper detects shape
48
+ // (boolean inputs → scoreCustom; post-weight numeric inputs → sum + clamp)
49
+ // so the curation apply-path produces a score that matches whatever the
50
+ // catalog scorer or playbook-runner would have produced for the same
51
+ // factors. Direct dependency on scoring.js is intentional — scoring.js is
52
+ // the authoritative formula.
53
+ const { deriveRwepFromFactors } = require("./scoring");
46
54
 
47
55
  const ROOT = path.resolve(__dirname, "..");
48
56
  const CVE_SCHEMA_PATH = path.join(ROOT, "lib", "schemas", "cve-catalog.schema.json");
@@ -562,17 +570,15 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
562
570
  appliedFields.push(field);
563
571
  }
564
572
 
565
- // Derive rwep_score from rwep_factors when factors supplied without an
566
- // explicit score. lib/scoring.js owns the canonical formula; we sum the
567
- // numeric values here as a fallback.
573
+ // audit J F3: derive rwep_score via the canonical scoring helper rather
574
+ // than a blind sum. deriveRwepFromFactors detects shape (boolean inputs
575
+ // → scoreCustom; post-weight numeric inputs sum + clamp) and routes
576
+ // accordingly, so the apply-path produces a score that agrees with
577
+ // scoring.validate() instead of diverging from it.
568
578
  if ("rwep_factors" in answers && !("rwep_score" in answers)
569
579
  && entry.rwep_factors && typeof entry.rwep_factors === "object") {
570
- let sum = 0;
571
- for (const v of Object.values(entry.rwep_factors)) {
572
- if (typeof v === "number") sum += v;
573
- }
574
- entry.rwep_score = Math.max(0, Math.min(100, sum));
575
- appliedFields.push("rwep_score (derived from rwep_factors)");
580
+ entry.rwep_score = deriveRwepFromFactors(entry.rwep_factors);
581
+ appliedFields.push("rwep_score (derived from rwep_factors via scoring.deriveRwepFromFactors)");
576
582
  }
577
583
 
578
584
  // last_updated reflects the apply moment.
package/lib/prefetch.js CHANGED
@@ -15,8 +15,14 @@
15
15
  * kev/known_exploited_vulnerabilities.json — full KEV feed
16
16
  * nvd/<cve-id>.json — NVD 2.0 per-CVE response
17
17
  * epss/<cve-id>.json — EPSS per-CVE response
18
- * ietf/<doc-name>.json — IETF Datatracker doc record
19
- * github/<owner>__<repo>__releases.json — releases listing
18
+ * rfc/<doc-name>.json — IETF Datatracker doc record
19
+ * pins/<owner>__<repo>__releases.json MITRE GitHub releases listing
20
+ *
21
+ * audit M P2-K: the registered source names in SOURCES below are `rfc` and
22
+ * `pins`. Earlier comments + --help text said `ietf` and `github`; an
23
+ * operator running `--source ietf` or `--source github` would hit "unknown
24
+ * source" because no such key exists. The names below are the canonical
25
+ * ones consumed by --source filtering.
20
26
  *
21
27
  * Usage:
22
28
  * node lib/prefetch.js # fetch everything not fresh
@@ -137,8 +143,8 @@ Sources:
137
143
  kev CISA Known Exploited Vulnerabilities
138
144
  nvd NIST NVD 2.0 per-CVE
139
145
  epss FIRST EPSS per-CVE
140
- ietf IETF Datatracker per-RFC
141
- github MITRE GitHub releases (ATLAS / ATT&CK / D3FEND / CWE)
146
+ rfc IETF Datatracker per-RFC
147
+ pins MITRE GitHub releases (ATLAS / ATT&CK)
142
148
 
143
149
  Options:
144
150
  --max-age <dur> skip entries fresher than this (e.g. 12h, 1d). Default: 24h.
@@ -296,7 +302,15 @@ function isFresh(idx, source, id, maxAgeMs) {
296
302
 
297
303
  function authHeadersForSource(source) {
298
304
  if (source === "nvd" && process.env.NVD_API_KEY) return { apiKey: process.env.NVD_API_KEY };
299
- if (source === "github" && process.env.GITHUB_TOKEN) return { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` };
305
+ // audit M P2-J: the registered source name for MITRE GitHub releases is
306
+ // `pins` (see SOURCES above). The prior check looked for `github`, so
307
+ // GITHUB_TOKEN never reached the per-request Authorization header and
308
+ // anonymous-rate-limited fetches were always used even when an operator
309
+ // had supplied a token. Accept both spellings so this is forgiving of
310
+ // the historical naming and the registered name.
311
+ if ((source === "pins" || source === "github") && process.env.GITHUB_TOKEN) {
312
+ return { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` };
313
+ }
300
314
  return {};
301
315
  }
302
316
 
@@ -459,13 +473,21 @@ function readCached(cacheDir, source, id, opts = {}) {
459
473
  const idx = loadIndex(cacheDir);
460
474
  const meta = idx.entries[entryKey(source, id)];
461
475
  if (!meta) return null;
462
- const ageMs = Date.now() - new Date(meta.fetched_at).getTime();
463
- if (!opts.allowStale && ageMs > maxAgeMs) return null;
476
+ // audit M P2-L: when `fetched_at` is missing / non-string / unparseable,
477
+ // `new Date(undefined).getTime()` is NaN and `NaN > maxAgeMs` is false —
478
+ // so the cached entry would have been returned as if fresh. Treat any
479
+ // non-finite age as "no provenance, refuse" unless the caller explicitly
480
+ // opted into allowStale.
481
+ const ageMs = meta.fetched_at ? Date.now() - new Date(meta.fetched_at).getTime() : NaN;
482
+ if (!opts.allowStale) {
483
+ if (!meta.fetched_at || !Number.isFinite(ageMs)) return null;
484
+ if (ageMs > maxAgeMs) return null;
485
+ }
464
486
  const p = entryPath(cacheDir, source, id);
465
487
  if (!fs.existsSync(p)) return null;
466
488
  try {
467
489
  const data = JSON.parse(fs.readFileSync(p, "utf8"));
468
- return { data, age_ms: ageMs, meta };
490
+ return { data, age_ms: Number.isFinite(ageMs) ? ageMs : null, meta };
469
491
  } catch {
470
492
  return null;
471
493
  }
@@ -357,6 +357,46 @@ async function main() {
357
357
  process.exitCode = 5; return;
358
358
  }
359
359
 
360
+ // v0.12.16 (audit I P1-5): cross-check the local public key against
361
+ // keys/EXPECTED_FINGERPRINT (the CI-pinned signing key). The prior
362
+ // refresh-network code only compared LOCAL ↔ TARBALL fingerprints, so a
363
+ // coordinated attacker who swapped both `keys/public.pem` on the operator's
364
+ // host AND the registry tarball passed every check — fingerprints match
365
+ // each other but match the attacker's key. The pin in EXPECTED_FINGERPRINT
366
+ // is the external trust anchor that closes this gap.
367
+ //
368
+ // Honors `KEYS_ROTATED=1` env to allow legitimate key rotation without
369
+ // re-bootstrap. Missing EXPECTED_FINGERPRINT file → warn-and-continue
370
+ // (don't break existing installs whose tree predates the pin file).
371
+ const expectedFingerprintPath = path.join(ROOT, "keys", "EXPECTED_FINGERPRINT");
372
+ if (fs.existsSync(expectedFingerprintPath) && !process.env.KEYS_ROTATED) {
373
+ try {
374
+ const expectedFp = fs.readFileSync(expectedFingerprintPath, "utf8")
375
+ .split(/\r?\n/).map(l => l.trim()).find(l => l.length > 0);
376
+ // v0.12.16 (codex P1 PR #11): `expectedFp` is read verbatim from
377
+ // keys/EXPECTED_FINGERPRINT (formatted as `SHA256:<base64>`), but
378
+ // `fingerprintPublicKey()` returns the raw base64 without the
379
+ // `SHA256:` prefix. Comparing the two raw strings would refuse every
380
+ // legitimate run unless KEYS_ROTATED=1 was set. Normalize by stripping
381
+ // the prefix from the pin file before compare. lib/verify.js's
382
+ // checkExpectedFingerprint() does the symmetric thing (adds the
383
+ // prefix to localFp); either side works as long as one is canonical.
384
+ const expectedFpBase64 = expectedFp && expectedFp.startsWith("SHA256:")
385
+ ? expectedFp.slice("SHA256:".length)
386
+ : expectedFp;
387
+ if (expectedFpBase64 && expectedFpBase64 !== localFp) {
388
+ emit({
389
+ ok: false,
390
+ error: `local keys/public.pem fingerprint diverges from keys/EXPECTED_FINGERPRINT pin`,
391
+ local_fingerprint: "SHA256:" + localFp,
392
+ pinned_fingerprint: expectedFp,
393
+ hint: "Either keys/public.pem was rotated since the pin was set (rerun `npm run bootstrap` to re-pin), or the local public.pem was tampered with. Set KEYS_ROTATED=1 to bypass once. Refusing to swap on --network.",
394
+ }, opts.json);
395
+ process.exitCode = 5; return;
396
+ }
397
+ } catch { /* unreadable pin file = warn-and-continue */ }
398
+ }
399
+
360
400
  // Verify every signed entry in the tarball manifest using the local key.
361
401
  let tarballManifest;
362
402
  try { tarballManifest = JSON.parse(tarballManifestEntry.body.toString("utf8")); }
@@ -323,7 +323,13 @@
323
323
  "confidence": { "type": "string", "enum": ["low", "medium", "high", "deterministic"] },
324
324
  "deterministic": { "type": "boolean", "description": "True if presence is definitive proof, not probabilistic." },
325
325
  "atlas_ref": { "type": "string" },
326
- "attack_ref": { "type": "string" }
326
+ "attack_ref": { "type": "string" },
327
+ "cve_ref": { "type": "string", "description": "Optional CVE / MAL-* / SNYK-* identifier this indicator binds to. When the indicator fires, the named entry is pulled into analyze.matched_cves[] (v0.12.14 F3)." },
328
+ "false_positive_checks_required": {
329
+ "type": "array",
330
+ "items": { "type": "string" },
331
+ "description": "v0.12.12+ contract: each entry is a check an AI assistant or operator must satisfy before this indicator's `hit` verdict can drive classification:detected. The runner downgrades a hit to inconclusive when this array is non-empty and no fp_checks attestation is supplied via signal_overrides[`<id>__fp_checks`]."
332
+ }
327
333
  }
328
334
  }
329
335
  },