@blamejs/exceptd-skills 0.12.8 → 0.12.10

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 (40) hide show
  1. package/AGENTS.md +2 -2
  2. package/ARCHITECTURE.md +21 -5
  3. package/CHANGELOG.md +120 -0
  4. package/README.md +1 -1
  5. package/bin/exceptd.js +227 -17
  6. package/data/_indexes/_meta.json +20 -20
  7. package/data/_indexes/activity-feed.json +17 -17
  8. package/data/_indexes/catalog-summaries.json +5 -5
  9. package/data/_indexes/chains.json +90 -11
  10. package/data/_indexes/frequency.json +2 -0
  11. package/data/_indexes/section-offsets.json +463 -355
  12. package/data/_indexes/token-budget.json +113 -53
  13. package/data/cve-catalog.json +385 -23
  14. package/data/cwe-catalog.json +34 -0
  15. package/data/playbooks/library-author.json +14 -0
  16. package/data/playbooks/mcp.json +1 -0
  17. package/data/zeroday-lessons.json +223 -1
  18. package/lib/playbook-runner.js +119 -35
  19. package/lib/prefetch.js +27 -6
  20. package/lib/refresh-external.js +81 -18
  21. package/lib/source-osv.js +493 -0
  22. package/manifest-snapshot.json +1 -1
  23. package/manifest.json +51 -51
  24. package/orchestrator/index.js +1 -1
  25. package/package.json +1 -1
  26. package/sbom.cdx.json +6 -6
  27. package/scripts/check-test-coverage.js +27 -6
  28. package/scripts/predeploy.js +7 -9
  29. package/skills/ai-attack-surface/skill.md +25 -0
  30. package/skills/ai-c2-detection/skill.md +24 -0
  31. package/skills/compliance-theater/skill.md +6 -0
  32. package/skills/exploit-scoring/skill.md +6 -0
  33. package/skills/mcp-agent-trust/skill.md +24 -0
  34. package/skills/policy-exception-gen/skill.md +6 -0
  35. package/skills/rag-pipeline-security/skill.md +28 -2
  36. package/skills/researcher/skill.md +6 -0
  37. package/skills/security-maturity-tiers/skill.md +6 -0
  38. package/skills/skill-update-loop/skill.md +6 -0
  39. package/skills/threat-model-currency/skill.md +4 -0
  40. package/skills/zeroday-gap-learn/skill.md +6 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "_meta": {
3
3
  "schema_version": "1.0.0",
4
- "last_updated": "2026-05-01",
4
+ "last_updated": "2026-05-13",
5
5
  "purpose": "Zero-day learning loop output. Each entry maps a CVE to: attack vector, defense chain analysis, framework coverage, new control requirements generated, and exposure scoring.",
6
6
  "note": "Never delete entries. Closed gaps are marked status: closed. History is data.",
7
7
  "tlp": "CLEAR",
@@ -466,5 +466,227 @@
466
466
  "basis": "SLSA L3 + provenance + signing all pass on the malicious package. Standard supply-chain audits (SBOM check, provenance verify, signature verify) all give green. The architectural pre-condition (pull_request_target + id-token:write + shared actions/cache) is not in any compliance framework's control catalog. Combined ~150M+ weekly downloads across 42 packages = extremely broad exposure.",
467
467
  "theater_pattern": "provenance_signed_therefore_safe"
468
468
  }
469
+ },
470
+ "MAL-2026-3083": {
471
+ "name": "Elementary-Data PyPI Worm (Forged Release via GitHub Actions Script Injection)",
472
+ "lesson_date": "2026-05-13",
473
+ "attack_vector": {
474
+ "description": "GitHub Actions script-injection sink in `.github/workflows/update_pylon_issue.yml`. The workflow interpolated `${{ github.event.comment.body }}` directly into a `run:` block — any commenter could execute attacker-controlled shell with the workflow's elevated GITHUB_TOKEN. The attacker forged an orphan commit (b1e4b1f3...) and tagged v0.23.3, causing the project's legitimate publishing pipeline to emit a properly-signed PyPI release of code the maintainers never saw. The wheel differed from 0.23.2 by exactly one file: an `elementary.pth` Python startup hook that auto-executed on every interpreter invocation and harvested cloud + dbt + git credentials, exfiltrating to a single subdomain on skyhanni.cloud during an 8-hour in-the-wild window (2026-04-24 22:20Z → 2026-04-25 ~06:30Z).",
475
+ "privileges_required": "Any GitHub account that can comment on a public PR or issue in the target repo.",
476
+ "complexity": "low — comment-driven; no maintainer access required",
477
+ "ai_factor": "None observed. Conventional GitHub Actions script-injection tradecraft. The compounding factor is workflow-shaped: `${{ github.event.* }}` interpolated directly into `run:` is a documented anti-pattern, but it remains widespread."
478
+ },
479
+ "defense_chain": {
480
+ "prevention": {
481
+ "what_would_have_worked": "Treat `${{ github.event.* }}` as untrusted; pass it via env: into the script body rather than interpolating directly. Forbid workflows triggered by issue_comment / pull_request_target from holding `contents: write` permissions. Block release tags whose target is not an ancestor of the default branch (orphan-commit-driven release detection).",
482
+ "was_this_required": false,
483
+ "framework_requiring_it": null,
484
+ "adequacy": "Architectural — eliminates the primitive entirely. Auditing every workflow file for the anti-pattern is the hard part; this is what the library-author playbook's `gha-workflow-script-injection-sink` indicator looks for."
485
+ },
486
+ "detection": {
487
+ "what_would_have_worked": "Consumer-side fresh-publish cooldown (PyPI's pip --require-hashes against a known-good lockfile, or registry-mirror cooldown windows). Comparison-by-content: any pip install of a major-version-pinned package returning a wheel whose extracted contents differ from the previous patch version by an added .pth file should fail loud.",
488
+ "was_this_required": false,
489
+ "framework_requiring_it": null,
490
+ "adequacy": "Defense in depth — the malicious 0.23.3 was caught within hours. A 24-72h cooldown would have shielded most consumers."
491
+ },
492
+ "response": {
493
+ "what_would_have_worked": "Rotate every credential under the credential_paths_scanned list for any host that pip-installed elementary-data during the 8h window — dbt warehouse creds especially. The package was yanked, but extracted .pth files persist on disk until the affected venv is wiped.",
494
+ "was_this_required": false,
495
+ "framework_requiring_it": null,
496
+ "adequacy": "Reduces blast radius post-exploitation. Upgrading to 0.23.4 does NOT remove the planted elementary.pth from the existing site-packages — a venv recreate is required."
497
+ }
498
+ },
499
+ "framework_coverage": {
500
+ "SLSA-L3": {
501
+ "covered": true,
502
+ "adequate": false,
503
+ "gap": "Provenance valid, payload malicious — same shape as CVE-2026-45321. SLSA-L3 attests WHICH pipeline built the artifact, not that the pipeline was driven by trusted inputs."
504
+ },
505
+ "NIST-800-53-SA-12": {
506
+ "covered": true,
507
+ "adequate": false,
508
+ "gap": "Supply chain protection treats signed release as the trust anchor. The signature was valid; the input to the signing pipeline was attacker-controlled."
509
+ },
510
+ "NIST-800-218-PO.4": {
511
+ "covered": true,
512
+ "adequate": false,
513
+ "gap": "Define and use secure development security checks. Direct interpolation of github.event.* into run: scripts is a documented anti-pattern but is not framework-enforced."
514
+ },
515
+ "EU-CRA-Art13": {
516
+ "covered": true,
517
+ "adequate": false,
518
+ "gap": "Vulnerability handling provisions don't address the case where the maintainer was an unwitting publisher."
519
+ },
520
+ "NIS2-Art21-2d": {
521
+ "covered": true,
522
+ "adequate": false,
523
+ "gap": "Supply chain risk management presumes detectable signal at consumption. Valid signature neutralizes consumer-side checks."
524
+ }
525
+ },
526
+ "new_control_requirements": [
527
+ {
528
+ "id": "NEW-CTRL-011",
529
+ "name": "GHA-WORKFLOW-SCRIPT-INJECTION-SINK-BAN",
530
+ "description": "Forbid direct interpolation of `${{ github.event.* }}` (comment.body, issue.body, review.body, pull_request.title, head_ref, etc.) into any `run:` block. Pass via `env:` so the shell sees a quoted variable, not an interpolated string. Enforced via repository linter / required CI check.",
531
+ "evidence": "MAL-2026-3083 — the entire compromise hinges on this single primitive; no other infrastructure was breached.",
532
+ "gap_closes": [
533
+ "NIST-800-218-PO.4",
534
+ "SLSA-L3"
535
+ ]
536
+ },
537
+ {
538
+ "id": "NEW-CTRL-012",
539
+ "name": "ORPHAN-COMMIT-RELEASE-DETECTION",
540
+ "description": "Reject release tags whose target commit is not reachable from the default branch (`git merge-base --is-ancestor`). Forged orphan-commit releases are a signature of the maintainer-impersonation supply chain pattern.",
541
+ "evidence": "MAL-2026-3083 — the malicious release pointed at orphan commit b1e4b1f3aad0d489ab0e9208031c67402bbb8480, never on main.",
542
+ "gap_closes": [
543
+ "NIST-800-53-SA-12",
544
+ "EU-CRA-Art13"
545
+ ]
546
+ }
547
+ ],
548
+ "compliance_exposure_score": {
549
+ "percent_audit_passing_orgs_still_exposed": 92,
550
+ "basis": "PyPI signature + maintainer trust + provenance all pass on the malicious package. Audit programs measure SBOM presence, package-signing posture, and dependency-pin discipline — none of which catch a maintainer's own pipeline being weaponized via a comment. ~1.1M monthly downloads broaden the consumer footprint.",
551
+ "theater_pattern": "signed_release_therefore_safe"
552
+ }
553
+ },
554
+ "CVE-2026-42208": {
555
+ "name": "BerriAI LiteLLM Proxy Auth SQL Injection",
556
+ "lesson_date": "2026-05-13",
557
+ "attack_vector": {
558
+ "description": "Authorization header value passed directly into a SQL query in the LiteLLM proxy's auth path. Crafted bearer-token-shape strings reach an error-logging pathway that executes SQL with the attacker-controlled value as a string-concatenated parameter — full pre-auth read/modify of the managed-credentials database. CISA KEV-listed 2026-05-08; in-wild exploitation evidence is the listing criterion.",
559
+ "privileges_required": "Network reachability to the LiteLLM proxy endpoint. No prior authentication.",
560
+ "complexity": "low — curl-able. POST /chat/completions with a SQLi payload in Authorization.",
561
+ "ai_factor": "Conventional human-security-research SQLi tradecraft. AI-stack relevance is downstream: LiteLLM IS the gateway in front of the model-provider keys that operators DO NOT want exfiltrated. The vulnerability is conventional; the impact class is AI-infrastructure."
562
+ },
563
+ "defense_chain": {
564
+ "prevention": {
565
+ "what_would_have_worked": "Parameterised queries throughout the auth path — no caller-supplied string ever string-concatenated into SQL. The 1.83.7 patch is exactly this: caller-supplied value becomes a SQL parameter, not part of the statement.",
566
+ "was_this_required": false,
567
+ "framework_requiring_it": "NIST-800-53-SI-10",
568
+ "adequacy": "Eliminates the class. SI-10's text requirement is satisfied by 'we validate inputs' regardless of whether the validation runs before the parameter binding — the framework gap is operational, not conceptual."
569
+ },
570
+ "detection": {
571
+ "what_would_have_worked": "WAF rule on Authorization headers containing SQL metacharacters or exceeding 100 bytes of non-base64-shape characters. LiteLLM error logs surface the injection string verbatim pre-1.83.7 — a log-pattern alert would have fired on the first probe.",
572
+ "was_this_required": false,
573
+ "framework_requiring_it": null,
574
+ "adequacy": "Detection layer. Operators running LiteLLM behind a default-deny WAF would not have been compromised."
575
+ },
576
+ "response": {
577
+ "what_would_have_worked": "Rotate every virtual key minted on the proxy since the patch ship date. Rotate every model-provider key the proxy held (openai, anthropic, etc.). Rotate LITELLM_MASTER_KEY and DATABASE_URL credentials. Audit LiteLLM_VerificationToken / LiteLLM_UserTable for admin-event-less inserts.",
578
+ "was_this_required": false,
579
+ "framework_requiring_it": null,
580
+ "adequacy": "Reduces blast radius post-exploitation. The DB primitive is read+write — assume tampering, not just disclosure."
581
+ }
582
+ },
583
+ "framework_coverage": {
584
+ "NIST-800-53-SI-10": {
585
+ "covered": true,
586
+ "adequate": false,
587
+ "gap": "Input validation control doesn't address argument-vs-statement distinction in SQL libraries."
588
+ },
589
+ "OWASP-LLM01": {
590
+ "covered": false,
591
+ "adequate": false,
592
+ "gap": "Prompt-injection control set doesn't address the AI-PROXY backend SQL surface — LiteLLM is the substrate that gates LLM API access, not the LLM itself."
593
+ },
594
+ "EU-AI-Act-Art-15": {
595
+ "covered": true,
596
+ "adequate": false,
597
+ "gap": "Robustness + cybersecurity requirement is undefined operationally for AI gateway infrastructure."
598
+ }
599
+ },
600
+ "new_control_requirements": [
601
+ {
602
+ "id": "NEW-CTRL-013",
603
+ "name": "AI-GATEWAY-CREDENTIAL-STORE-ISOLATION",
604
+ "description": "AI-API gateway substrates (LiteLLM, Portkey, Helicone, similar) must isolate the managed-credentials DB on a network segment unreachable from the API plane. The auth path may read but the API plane MUST NOT have raw-SQL connectivity to the credential store.",
605
+ "evidence": "CVE-2026-42208 — a single SQLi reaches the entire model-provider credential vault because the API plane and credential store share a process.",
606
+ "gap_closes": [
607
+ "OWASP-LLM01",
608
+ "EU-AI-Act-Art-15"
609
+ ]
610
+ }
611
+ ],
612
+ "compliance_exposure_score": {
613
+ "percent_audit_passing_orgs_still_exposed": 75,
614
+ "basis": "SI-10 audits accept 'we validate inputs' as compliance. Most operators run LiteLLM internet-reachable behind a thin proxy without a SQL-injection-aware WAF. KEV listing imposes a 21-day patch SLA on federal orgs; private-sector adoption lags.",
615
+ "theater_pattern": "input_validation_checkbox_without_parameterised_queries"
616
+ }
617
+ },
618
+ "CVE-2026-39884": {
619
+ "name": "Flux159 mcp-server-kubernetes Argument Injection via port_forward",
620
+ "lesson_date": "2026-05-13",
621
+ "attack_vector": {
622
+ "description": "AI assistant invokes the mcp-server-kubernetes `port_forward` MCP tool with a tainted resourceName (e.g. 'pod-name --address=0.0.0.0'). The server builds a string-form kubectl command and uses `.split(' ')` instead of an argv array, so the attacker-controlled flag lands as a distinct argv entry to kubectl. `--address=0.0.0.0` binds the port-forward to all interfaces; `-n kube-system` redirects to attacker-chosen namespaces. Exploitation is mediated by the AI assistant — adversarial input via prompt injection in retrieved docs / commit messages / upstream MCP tool responses is the upstream gate.",
623
+ "privileges_required": "AI assistant with mcp-server-kubernetes installed and port_forward enabled. Attacker needs only to influence the AI's input (PR comment, doc, retrieved RAG chunk).",
624
+ "complexity": "low — once the tainted string is in the AI's context, the tool call propagates it unchanged.",
625
+ "ai_factor": "AI-assistant-mediated argument injection. The vuln is conventional argv-injection; the AI is the channel that converts adversarial document content into infrastructure-tool flags."
626
+ },
627
+ "defense_chain": {
628
+ "prevention": {
629
+ "what_would_have_worked": "argv-array spawn — `execFile('kubectl', ['port-forward', resourceName, ...])` with no `.split(' ')` and no shell interpretation. The 3.5.0 patch does exactly this.",
630
+ "was_this_required": false,
631
+ "framework_requiring_it": "NIST-800-53-SI-10",
632
+ "adequacy": "Architectural fix — the class disappears."
633
+ },
634
+ "detection": {
635
+ "what_would_have_worked": "MCP audit log alerting on port_forward tool calls where resourceName contains whitespace or kubectl flag prefixes (`--`, `-n`). Process-level alerting on kubectl port-forward processes with --address=0.0.0.0 on hosts that should only port-forward to localhost.",
636
+ "was_this_required": false,
637
+ "framework_requiring_it": null,
638
+ "adequacy": "Detection layer — catches the exploit attempt before the listener binds externally."
639
+ },
640
+ "response": {
641
+ "what_would_have_worked": "Disable the port_forward tool in the MCP allowlist until upgraded to 3.5.0+. Most operator deployments don't rely on port_forward for routine work.",
642
+ "was_this_required": false,
643
+ "framework_requiring_it": null,
644
+ "adequacy": "Effective tool-disable mitigation; low operator cost."
645
+ }
646
+ },
647
+ "framework_coverage": {
648
+ "NIST-800-53-SI-10": {
649
+ "covered": true,
650
+ "adequate": false,
651
+ "gap": "Input validation control doesn't address the argv-vs-string boundary that argument injection exploits."
652
+ },
653
+ "OWASP-LLM01": {
654
+ "covered": false,
655
+ "adequate": false,
656
+ "gap": "Prompt-injection control set doesn't model the AI-assistant-as-channel pattern — the attacker doesn't compromise the MCP server, they feed adversarial input that the AI dutifully passes through."
657
+ },
658
+ "NIS2-Art21-2g": {
659
+ "covered": true,
660
+ "adequate": false,
661
+ "gap": "Patch management presumes traditional CVE timelines; MCP plugin ecosystem patch awareness lags."
662
+ }
663
+ },
664
+ "new_control_requirements": [
665
+ {
666
+ "id": "NEW-CTRL-014",
667
+ "name": "MCP-SERVER-ARGV-NOT-SHELLSTRING",
668
+ "description": "MCP servers spawning subprocesses MUST use argv-array spawn primitives (execFile / spawn with array args / posix_spawn) — never .split(' ') or shell concatenation of caller-supplied input. Treats every MCP tool argument as untrusted by default.",
669
+ "evidence": "CVE-2026-39884 — the entire vulnerability is .split(' ') on a caller-supplied string. The 3.5.0 patch is the argv-array refactor.",
670
+ "gap_closes": [
671
+ "NIST-800-53-SI-10",
672
+ "OWASP-LLM01"
673
+ ]
674
+ },
675
+ {
676
+ "id": "NEW-CTRL-015",
677
+ "name": "MCP-TOOL-ALLOWLIST-ENFORCEMENT",
678
+ "description": "AI agent stacks must enforce an explicit allowlist of MCP tools — tools default to denied. High-risk tools (port_forward, exec, write_file, shell, kubectl) require operator consent per session.",
679
+ "evidence": "CVE-2026-39884 — temporary mitigation is exactly 'disable port_forward in the allowlist'. The control closes the class across future MCP plugins.",
680
+ "gap_closes": [
681
+ "OWASP-LLM01",
682
+ "NIS2-Art21-2g"
683
+ ]
684
+ }
685
+ ],
686
+ "compliance_exposure_score": {
687
+ "percent_audit_passing_orgs_still_exposed": 88,
688
+ "basis": "MCP ecosystem patch hygiene lags traditional CVE timelines. Most AI-agent operators do not maintain an explicit MCP tool allowlist; SI-10 audits accept the MCP plugin as a vendored dependency without auditing its argv handling.",
689
+ "theater_pattern": "vendored_mcp_plugin_inherits_vendor_trust"
690
+ }
469
691
  }
470
692
  }
@@ -418,26 +418,118 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}) {
418
418
  const an = resolvedPhase(playbook, directiveId, 'analyze');
419
419
  const directive = findDirective(playbook, directiveId);
420
420
 
421
- // Match catalogued CVEs from the domain.cve_refs list. The agent submits
422
- // signal values; engine joins to the catalog for RWEP context.
423
- // VEX filter (agentSignals.vex_filter): a set of CVE IDs the operator
424
- // has formally declared not_affected via a CycloneDX/OpenVEX statement.
425
- // We drop those from matched_cves before scoring, and surface them
426
- // separately so the analyze response still records the disposition.
421
+ // Resolve catalogued CVEs from the domain.cve_refs list. This list is the
422
+ // playbook's CVE scan-coverage enumeration every CVE this playbook can
423
+ // detect. By itself it is NOT a statement that the operator is affected by
424
+ // any of these CVEs; affected-ness requires evidence correlation in detect.
425
+ //
426
+ // Two distinct sets are computed below:
427
+ //
428
+ // catalogBaselineCves — every CVE the playbook scans for, with full
429
+ // per-CVE catalog context (RWEP / KEV / CVSS / AI-discovery /
430
+ // active-exploitation / patch state). Always populated when the
431
+ // playbook has domain.cve_refs. Each entry carries correlated_via=null
432
+ // and a `note` flagging it as catalog-only.
433
+ //
434
+ // matchedCves — CVEs the operator's submitted evidence actually
435
+ // correlates to. Correlation paths:
436
+ // (a) An indicator fired (verdict === 'hit') whose attack_ref or
437
+ // atlas_ref intersects the CVE's attack_refs / atlas_refs in
438
+ // the catalog.
439
+ // (b) An agentSignal explicitly references the CVE id with a
440
+ // truthy value (`agentSignals[cveId] === true`) or with a
441
+ // string value 'hit' / 'detected' / 'affected'.
442
+ // Each entry carries correlated_via=<reason string> so downstream
443
+ // consumers (CSAF / SARIF / OpenVEX / human renderer) can show the
444
+ // provenance, and so an empty matchedCves means "no evidence
445
+ // correlated to operator's submission" rather than "playbook has
446
+ // no CVEs of interest."
447
+ //
448
+ // VEX filter (agentSignals.vex_filter): a set of CVE IDs the operator has
449
+ // formally declared not_affected via CycloneDX/OpenVEX. VEX-dropped CVEs
450
+ // are removed from BOTH arrays (they're not affected — neither correlated
451
+ // nor part of effective scan coverage for this run).
427
452
  const cveRefs = playbook.domain.cve_refs || [];
428
453
  const vexFilter = agentSignals.vex_filter instanceof Set ? agentSignals.vex_filter
429
454
  : (Array.isArray(agentSignals.vex_filter) ? new Set(agentSignals.vex_filter) : null);
430
- const allMatches = cveRefs.map(id => xref.byCve(id)).filter(r => r.found);
431
- const matchedCves = vexFilter
432
- ? allMatches.filter(c => !vexFilter.has(c.cve_id))
433
- : allMatches;
455
+ const allCves = cveRefs.map(id => xref.byCve(id)).filter(r => r.found);
456
+ const catalogBaselineCves = vexFilter
457
+ ? allCves.filter(c => !vexFilter.has(c.cve_id))
458
+ : allCves;
434
459
  const vexDropped = vexFilter
435
- ? allMatches.filter(c => vexFilter.has(c.cve_id)).map(c => c.cve_id)
460
+ ? allCves.filter(c => vexFilter.has(c.cve_id)).map(c => c.cve_id)
436
461
  : [];
437
462
 
438
- // RWEP composition: start from the catalogue's per-CVE rwep_score (already
439
- // baked from KEV + PoC + AI-disc + active-exploitation + blast-radius), then
440
- // adjust by playbook's rwep_inputs based on detect hits + agent signals.
463
+ // Build correlation map: cve_id -> array of "indicator_hit:<id>" / "signal:<id>" reasons.
464
+ const correlationsByCve = new Map();
465
+ const addCorrelation = (cveId, reason) => {
466
+ if (!correlationsByCve.has(cveId)) correlationsByCve.set(cveId, []);
467
+ const arr = correlationsByCve.get(cveId);
468
+ if (!arr.includes(reason)) arr.push(reason);
469
+ };
470
+ // (a) indicator-hit → CVE via shared attack_ref / atlas_ref.
471
+ const playbookDetect = resolvedPhase(playbook, directiveId, 'detect');
472
+ const indicatorRefs = new Map(); // indicator.id -> { attack_ref, atlas_ref }
473
+ for (const ind of (playbookDetect.indicators || [])) {
474
+ indicatorRefs.set(ind.id, { attack_ref: ind.attack_ref || null, atlas_ref: ind.atlas_ref || null });
475
+ }
476
+ const firedIndicators = (detectResult.indicators || []).filter(i => i.verdict === 'hit');
477
+ for (const fired of firedIndicators) {
478
+ const refs = indicatorRefs.get(fired.id) || { attack_ref: fired.attack_ref || null, atlas_ref: fired.atlas_ref || null };
479
+ if (!refs.attack_ref && !refs.atlas_ref) continue;
480
+ for (const c of catalogBaselineCves) {
481
+ const attackHit = refs.attack_ref && Array.isArray(c.attack_refs) && c.attack_refs.includes(refs.attack_ref);
482
+ const atlasHit = refs.atlas_ref && Array.isArray(c.atlas_refs) && c.atlas_refs.includes(refs.atlas_ref);
483
+ if (attackHit || atlasHit) addCorrelation(c.cve_id, `indicator_hit:${fired.id}`);
484
+ }
485
+ }
486
+ // (b) agentSignals explicitly referencing a CVE id.
487
+ for (const c of catalogBaselineCves) {
488
+ const sig = agentSignals[c.cve_id];
489
+ if (sig === true || sig === 'hit' || sig === 'detected' || sig === 'affected') {
490
+ addCorrelation(c.cve_id, `signal:${c.cve_id}`);
491
+ }
492
+ }
493
+
494
+ const matchedCves = catalogBaselineCves.filter(c => correlationsByCve.has(c.cve_id));
495
+
496
+ // Per-CVE shape — identical between matched_cves and catalog_baseline_cves
497
+ // so consumers can iterate either without branching. matched_cves entries
498
+ // carry a non-null correlated_via array; catalog_baseline_cves entries
499
+ // carry correlated_via:null and a `note` clarifying the field's intent.
500
+ const cveShape = (c, correlatedVia) => ({
501
+ cve_id: c.cve_id,
502
+ rwep: c.rwep_score,
503
+ cvss_score: c.entry?.cvss_score ?? null,
504
+ cvss_vector: c.entry?.cvss_vector ?? null,
505
+ cisa_kev: c.cisa_kev,
506
+ cisa_kev_date: c.entry?.cisa_kev_date ?? null,
507
+ cisa_kev_due_date: c.entry?.cisa_kev_due_date ?? null,
508
+ poc_available: c.entry?.poc_available ?? null,
509
+ ai_discovered: c.ai_discovered,
510
+ ai_assisted_weaponization: c.entry?.ai_assisted_weaponization ?? null,
511
+ active_exploitation: c.active_exploitation,
512
+ patch_available: c.entry?.patch_available ?? null,
513
+ patch_required_reboot: c.entry?.patch_required_reboot ?? null,
514
+ live_patch_available: c.entry?.live_patch_available ?? null,
515
+ epss_score: c.entry?.epss_score ?? null,
516
+ epss_date: c.entry?.epss_date ?? null,
517
+ atlas_refs: c.atlas_refs,
518
+ attack_refs: c.attack_refs,
519
+ affected_versions: c.entry?.affected_versions ?? null,
520
+ correlated_via: correlatedVia,
521
+ });
522
+
523
+ const matchedCveEntries = matchedCves.map(c => cveShape(c, correlationsByCve.get(c.cve_id)));
524
+ const catalogBaselineEntries = catalogBaselineCves.map(c => ({
525
+ ...cveShape(c, null),
526
+ note: 'Catalog-baseline entry — this CVE is in the playbook\'s scan coverage but no submitted evidence correlated to it. Not a statement that the operator is affected.',
527
+ }));
528
+
529
+ // RWEP composition: start from the per-CVE rwep_score of evidence-correlated
530
+ // matches (NOT catalog baseline) so RWEP base reflects what the operator's
531
+ // evidence actually surfaced. Adjust by playbook's rwep_inputs based on
532
+ // detect hits + agent signals.
441
533
  const baseRwep = matchedCves.length ? Math.max(...matchedCves.map(c => c.rwep_score)) : 0;
442
534
  let adjustedRwep = baseRwep;
443
535
  const rwepBreakdown = [];
@@ -495,27 +587,19 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}) {
495
587
  // Pull every required field from the catalog entry; null is only emitted
496
588
  // when the catalog itself lacks the value, never when we just forgot to
497
589
  // forward it. EPSS is included because validate-cves --live populates it.
498
- matched_cves: matchedCves.map(c => ({
499
- cve_id: c.cve_id,
500
- rwep: c.rwep_score,
501
- cvss_score: c.entry?.cvss_score ?? null,
502
- cvss_vector: c.entry?.cvss_vector ?? null,
503
- cisa_kev: c.cisa_kev,
504
- cisa_kev_date: c.entry?.cisa_kev_date ?? null,
505
- cisa_kev_due_date: c.entry?.cisa_kev_due_date ?? null,
506
- poc_available: c.entry?.poc_available ?? null,
507
- ai_discovered: c.ai_discovered,
508
- ai_assisted_weaponization: c.entry?.ai_assisted_weaponization ?? null,
509
- active_exploitation: c.active_exploitation,
510
- patch_available: c.entry?.patch_available ?? null,
511
- patch_required_reboot: c.entry?.patch_required_reboot ?? null,
512
- live_patch_available: c.entry?.live_patch_available ?? null,
513
- epss_score: c.entry?.epss_score ?? null,
514
- epss_date: c.entry?.epss_date ?? null,
515
- atlas_refs: c.atlas_refs,
516
- attack_refs: c.attack_refs,
517
- affected_versions: c.entry?.affected_versions ?? null,
518
- })),
590
+ //
591
+ // matched_cves — evidence-correlated only. Each entry has a non-null
592
+ // correlated_via[] array naming the indicator hits or agent signals that
593
+ // tied the operator's submission to this CVE. Empty array means the
594
+ // playbook's scan coverage saw no matching evidence in this run.
595
+ matched_cves: matchedCveEntries,
596
+ // catalog_baseline_cves every CVE the playbook scans for, with the
597
+ // same per-CVE shape but correlated_via=null and a note explaining the
598
+ // field is scan-coverage metadata, NOT an operator-affected list. Use
599
+ // this when surfacing "what CVEs does this playbook check for?" Use
600
+ // matched_cves when surfacing "what CVEs is the operator actually
601
+ // affected by based on submitted evidence?"
602
+ catalog_baseline_cves: catalogBaselineEntries,
519
603
  rwep: { base: baseRwep, adjusted: adjustedRwep, breakdown: rwepBreakdown, threshold: directive ? resolvedPhase(playbook, directiveId, 'direct').rwep_threshold : null },
520
604
  blast_radius_score: blastRadiusScore,
521
605
  blast_radius_basis: blastRubric.find(r => r.blast_radius_score === blastRadiusScore) || null,
package/lib/prefetch.js CHANGED
@@ -80,15 +80,24 @@ const SOURCES = {
80
80
  .filter(Boolean),
81
81
  },
82
82
  pins: {
83
- description: "MITRE GitHub releases for ATLAS / ATT&CK / D3FEND / CWE pin checks",
83
+ description: "MITRE GitHub releases for ATLAS / ATT&CK pin checks",
84
84
  rate: { tokens: 30, windowMs: 60 * 60_000 }, // anon: 60/h, leave headroom
85
85
  rate_with_key: { tokens: 500, windowMs: 60 * 60_000 },
86
86
  concurrency: 2,
87
+ // D3FEND and CWE were previously listed here but neither project
88
+ // publishes via GitHub Releases — D3FEND distributes the ontology
89
+ // from d3fend/d3fend-ontology without tagged releases, and CWE
90
+ // ships its catalog as XML/JSON downloads from cwe.mitre.org rather
91
+ // than a GitHub repo. The old api.github.com URLs (mitre/cwe and
92
+ // d3fend/d3fend-data) returned HTTP 404 on every refresh, surfacing
93
+ // as "2 error(s)" in the prefetch summary. Pin currency for those
94
+ // two frameworks is tracked via lib/upstream-check.js against
95
+ // cwe.mitre.org and d3fend.mitre.org respectively; the prefetch
96
+ // registry only contains sources that actually have a GitHub
97
+ // Releases feed to poll.
87
98
  expand: () => [
88
99
  { id: "mitre-atlas__atlas-data__releases", url: "https://api.github.com/repos/mitre-atlas/atlas-data/releases?per_page=5" },
89
100
  { id: "mitre-attack__attack-stix-data__releases", url: "https://api.github.com/repos/mitre-attack/attack-stix-data/releases?per_page=5" },
90
- { id: "d3fend__d3fend-data__releases", url: "https://api.github.com/repos/d3fend/d3fend-data/releases?per_page=5" },
91
- { id: "mitre__cwe__releases", url: "https://api.github.com/repos/mitre/cwe/releases?per_page=5" },
92
101
  ],
93
102
  },
94
103
  };
@@ -364,14 +373,26 @@ async function main() {
364
373
  const opts = parseArgs(process.argv);
365
374
  if (opts.help) {
366
375
  printHelp();
367
- process.exit(0);
376
+ return;
368
377
  }
378
+ // Why process.exitCode and not process.exit():
379
+ // On Windows + Node 25 (libuv), calling process.exit() synchronously
380
+ // while in-flight fetch / AbortController teardown is still mid-close
381
+ // produced `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING),
382
+ // file src\win\async.c, line 76` followed by exit 3221226505
383
+ // (STATUS_STACK_BUFFER_OVERRUN). The summary line had already
384
+ // flushed, so operators saw the crash *after* their summary —
385
+ // contractually correct but visibly noisy. Letting the event loop
386
+ // drain naturally — via exitCode + return — lets undici's connection
387
+ // pool and the AbortController signal listeners finish teardown
388
+ // before the process exits, eliminating the assertion. Same pattern
389
+ // documented in CLAUDE.md for v0.11.11's `ci` #100 regression.
369
390
  try {
370
391
  const result = await prefetch(opts);
371
- process.exit(result.errors > 0 ? 1 : 0);
392
+ process.exitCode = result.errors > 0 ? 1 : 0;
372
393
  } catch (err) {
373
394
  console.error(`prefetch: fatal: ${err.message}`);
374
- process.exit(2);
395
+ process.exitCode = 2;
375
396
  }
376
397
  }
377
398