@blamejs/exceptd-skills 0.12.7 → 0.12.9

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 (77) hide show
  1. package/AGENTS.md +15 -1
  2. package/ARCHITECTURE.md +21 -5
  3. package/CHANGELOG.md +150 -0
  4. package/README.md +1 -1
  5. package/bin/exceptd.js +416 -69
  6. package/data/_indexes/_meta.json +44 -44
  7. package/data/_indexes/activity-feed.json +34 -34
  8. package/data/_indexes/catalog-summaries.json +9 -9
  9. package/data/_indexes/chains.json +249 -11
  10. package/data/_indexes/frequency.json +63 -5
  11. package/data/_indexes/jurisdiction-map.json +13 -3
  12. package/data/_indexes/section-offsets.json +1171 -1027
  13. package/data/_indexes/summary-cards.json +2 -2
  14. package/data/_indexes/token-budget.json +232 -152
  15. package/data/atlas-ttps.json +189 -1
  16. package/data/cve-catalog.json +34 -22
  17. package/data/cwe-catalog.json +290 -1
  18. package/data/d3fend-catalog.json +163 -1
  19. package/data/framework-control-gaps.json +243 -0
  20. package/data/playbooks/containers.json +23 -5
  21. package/data/playbooks/cred-stores.json +9 -9
  22. package/data/playbooks/crypto.json +8 -8
  23. package/data/playbooks/hardening.json +46 -10
  24. package/data/playbooks/library-author.json +16 -20
  25. package/data/playbooks/mcp.json +1 -0
  26. package/data/playbooks/runtime.json +7 -7
  27. package/data/playbooks/sbom.json +11 -11
  28. package/data/playbooks/secrets.json +4 -4
  29. package/data/rfc-references.json +144 -0
  30. package/lib/playbook-runner.js +119 -35
  31. package/lib/prefetch.js +27 -6
  32. package/lib/refresh-external.js +32 -9
  33. package/lib/schemas/skill-frontmatter.schema.json +2 -2
  34. package/manifest-snapshot.json +1 -1
  35. package/manifest.json +73 -73
  36. package/orchestrator/index.js +1 -1
  37. package/package.json +2 -1
  38. package/sbom.cdx.json +6 -6
  39. package/scripts/check-sbom-currency.js +87 -0
  40. package/scripts/check-test-coverage.README.md +148 -0
  41. package/scripts/check-test-coverage.js +476 -0
  42. package/scripts/hooks/pre-commit.sh +19 -0
  43. package/scripts/predeploy.js +14 -30
  44. package/skills/age-gates-child-safety/skill.md +3 -0
  45. package/skills/ai-attack-surface/skill.md +29 -1
  46. package/skills/ai-c2-detection/skill.md +30 -1
  47. package/skills/ai-risk-management/skill.md +3 -0
  48. package/skills/api-security/skill.md +3 -0
  49. package/skills/attack-surface-pentest/skill.md +3 -0
  50. package/skills/cloud-security/skill.md +3 -0
  51. package/skills/compliance-theater/skill.md +6 -0
  52. package/skills/container-runtime-security/skill.md +3 -0
  53. package/skills/coordinated-vuln-disclosure/skill.md +8 -1
  54. package/skills/defensive-countermeasure-mapping/skill.md +1 -1
  55. package/skills/dlp-gap-analysis/skill.md +3 -0
  56. package/skills/email-security-anti-phishing/skill.md +9 -1
  57. package/skills/exploit-scoring/skill.md +6 -0
  58. package/skills/identity-assurance/skill.md +6 -1
  59. package/skills/incident-response-playbook/skill.md +8 -2
  60. package/skills/kernel-lpe-triage/skill.md +24 -4
  61. package/skills/mcp-agent-trust/skill.md +28 -1
  62. package/skills/mlops-security/skill.md +3 -0
  63. package/skills/ot-ics-security/skill.md +3 -0
  64. package/skills/policy-exception-gen/skill.md +6 -0
  65. package/skills/rag-pipeline-security/skill.md +30 -1
  66. package/skills/researcher/skill.md +6 -0
  67. package/skills/sector-energy/skill.md +3 -0
  68. package/skills/sector-federal-government/skill.md +3 -0
  69. package/skills/sector-financial/skill.md +3 -0
  70. package/skills/sector-healthcare/skill.md +3 -0
  71. package/skills/security-maturity-tiers/skill.md +25 -1
  72. package/skills/skill-update-loop/skill.md +38 -0
  73. package/skills/supply-chain-integrity/skill.md +3 -0
  74. package/skills/threat-model-currency/skill.md +4 -0
  75. package/skills/threat-modeling-methodology/skill.md +3 -0
  76. package/skills/webapp-security/skill.md +3 -0
  77. package/skills/zeroday-gap-learn/skill.md +6 -0
@@ -320,5 +320,149 @@
320
320
  "pqc-first"
321
321
  ],
322
322
  "last_verified": "2026-05-11"
323
+ },
324
+ "RFC-7489": {
325
+ "number": 7489,
326
+ "title": "Domain-based Message Authentication, Reporting, and Conformance (DMARC)",
327
+ "status": "Informational",
328
+ "published": "2015-03",
329
+ "tracker": "https://www.rfc-editor.org/info/rfc7489",
330
+ "relevance": "Defines DMARC — the email-authentication policy framework that binds SPF + DKIM results to a published domain-owner policy. Operator-facing email security depends on a published DMARC record with `p=reject` or `p=quarantine`; relaxed policies (`p=none`) are equivalent to no DMARC for spoofing-defense purposes. Cited alongside DKIM (RFC 6376) and SPF (RFC 7208) as the authoritative tri-RFC for anti-spoofing posture.",
331
+ "skills_referencing": [
332
+ "email-security-anti-phishing"
333
+ ],
334
+ "last_verified": "2026-05-13"
335
+ },
336
+ "RFC-6376": {
337
+ "number": 6376,
338
+ "title": "DomainKeys Identified Mail (DKIM) Signatures",
339
+ "status": "Internet Standard",
340
+ "published": "2011-09",
341
+ "tracker": "https://www.rfc-editor.org/info/rfc6376",
342
+ "relevance": "Cryptographic signing of email by the originating domain. DMARC verification relies on either SPF or DKIM aligning with the From-header domain. Operationally, DKIM is the load-bearing half of DMARC — SPF breaks on legitimate forwarding paths; DKIM survives them. Key-length lag is the recurring framework gap: many deployments still use 1024-bit RSA keys despite IETF guidance to rotate to ≥2048-bit.",
343
+ "skills_referencing": [
344
+ "email-security-anti-phishing"
345
+ ],
346
+ "last_verified": "2026-05-13"
347
+ },
348
+ "RFC-7208": {
349
+ "number": 7208,
350
+ "title": "Sender Policy Framework (SPF) for Authorizing Use of Domains in Email",
351
+ "status": "Proposed Standard",
352
+ "published": "2014-04",
353
+ "tracker": "https://www.rfc-editor.org/info/rfc7208",
354
+ "relevance": "Authorizes which IPs may send mail on behalf of a domain. DMARC's path-based authentication half. Limits: a strict 10-DNS-lookup ceiling that operators routinely blow past through nested includes, leading to permerror DMARC outcomes; SPF breaks across legitimate forwarders, which is why DKIM (RFC 6376) is the load-bearing half operationally.",
355
+ "skills_referencing": [
356
+ "email-security-anti-phishing"
357
+ ],
358
+ "last_verified": "2026-05-13"
359
+ },
360
+ "RFC-8616": {
361
+ "number": 8616,
362
+ "title": "Email Authentication for Internationalized Mail",
363
+ "status": "Proposed Standard",
364
+ "published": "2019-06",
365
+ "tracker": "https://www.rfc-editor.org/info/rfc8616",
366
+ "relevance": "Updates DKIM / SPF / DMARC handling for internationalized domain names (IDN) and internationalized email-address local parts. Operator-facing: phishing campaigns increasingly leverage IDN homoglyphs (e.g. Cyrillic `а` for Latin `a`), and a DMARC implementation that doesn't honor RFC 8616 normalization rules can fail to align IDN-bearing From-headers correctly. Treat as a compatibility prerequisite for any anti-spoofing posture covering global mail flows.",
367
+ "skills_referencing": [
368
+ "email-security-anti-phishing"
369
+ ],
370
+ "last_verified": "2026-05-13"
371
+ },
372
+ "RFC-8461": {
373
+ "number": 8461,
374
+ "title": "SMTP MTA Strict Transport Security (MTA-STS)",
375
+ "status": "Proposed Standard",
376
+ "published": "2018-09",
377
+ "tracker": "https://www.rfc-editor.org/info/rfc8461",
378
+ "relevance": "Forces TLS on inbound SMTP between MTAs that publish a `_mta-sts` policy. Closes the historical 'opportunistic-TLS-downgrade' attack window where a network-positioned attacker stripped the STARTTLS handshake. Operator-facing: an MTA-STS policy in `enforce` mode plus monitoring via TLSRPT (RFC 8460) is the baseline for inbound email confidentiality. Phishing-defense relevance is indirect — STARTTLS stripping enables session-layer manipulation that downstream defenses (DMARC, content classifiers) cannot recover from.",
379
+ "skills_referencing": [
380
+ "email-security-anti-phishing"
381
+ ],
382
+ "last_verified": "2026-05-13"
383
+ },
384
+ "ISO-29147": {
385
+ "number": null,
386
+ "title": "ISO/IEC 29147:2018 Information technology — Security techniques — Vulnerability disclosure",
387
+ "status": "International Standard",
388
+ "published": "2018-10",
389
+ "tracker": "https://www.iso.org/standard/72311.html",
390
+ "relevance": "Internationally-recognized vulnerability-disclosure procedure standard. Operator-facing: an org claiming a CVD program must demonstrate the documented procedures cover receipt, triage, advisory drafting, notification, and post-disclosure review. Twin to ISO 30111 (handling); together they form the disclosure ↔ handling pair. The RFC 9116 (security.txt) artifact is the operator-facing surface that operationalizes ISO 29147 receipt requirements.",
391
+ "skills_referencing": [
392
+ "coordinated-vuln-disclosure"
393
+ ],
394
+ "last_verified": "2026-05-13"
395
+ },
396
+ "ISO-30111": {
397
+ "number": null,
398
+ "title": "ISO/IEC 30111:2019 Information technology — Security techniques — Vulnerability handling processes",
399
+ "status": "International Standard",
400
+ "published": "2019-10",
401
+ "tracker": "https://www.iso.org/standard/69725.html",
402
+ "relevance": "Internal handling counterpart to ISO 29147. Defines the internal processes (investigation, resolution, release) that follow a disclosed vulnerability through to fix. Operator-facing: paired with ISO 29147 it supplies the end-to-end CVD lifecycle. Many compliance frameworks (NIS2, EU CRA) reference 'a documented vulnerability handling process'; ISO 30111 is the canonical artifact that satisfies the wording.",
403
+ "skills_referencing": [
404
+ "coordinated-vuln-disclosure"
405
+ ],
406
+ "last_verified": "2026-05-13"
407
+ },
408
+ "RFC-9116": {
409
+ "number": 9116,
410
+ "title": "A File Format to Aid in Security Vulnerability Disclosure",
411
+ "status": "Proposed Standard",
412
+ "published": "2022-04",
413
+ "tracker": "https://www.rfc-editor.org/info/rfc9116",
414
+ "relevance": "Defines the `/.well-known/security.txt` file format. Operator-facing CVD entry point: researchers reaching a domain consult `/.well-known/security.txt` for the disclosure contact + policy URL + preferred encryption + acknowledgments page. Absence is a recurring CVD-friction finding; presence with a stale `Expires` header is equivalent to absence. Pair with ISO 29147 receipt requirements.",
415
+ "skills_referencing": [
416
+ "coordinated-vuln-disclosure"
417
+ ],
418
+ "last_verified": "2026-05-13"
419
+ },
420
+ "CSAF-2.0": {
421
+ "number": null,
422
+ "title": "OASIS Common Security Advisory Framework Version 2.0",
423
+ "status": "OASIS Standard",
424
+ "published": "2022-11",
425
+ "tracker": "https://docs.oasis-open.org/csaf/csaf/v2.0/csaf-v2.0.html",
426
+ "relevance": "Machine-readable security advisory format adopted by EU CRA, CISA, and major vendor PSIRTs. Replaces the CVRF-1.2 format. Operator-facing: CSAF 2.0 documents carry the same advisory content traditionally found in vendor PDFs but in a parseable JSON form that consumers can ingest for fleet-wide impact assessment. exceptd emits CSAF-2.0 close.evidence_package bundles from `exceptd run --format csaf-2.0` for downstream auditor consumption.",
427
+ "skills_referencing": [
428
+ "coordinated-vuln-disclosure"
429
+ ],
430
+ "last_verified": "2026-05-13"
431
+ },
432
+ "RFC-6545": {
433
+ "number": 6545,
434
+ "title": "Real-time Inter-network Defense (RID)",
435
+ "status": "Proposed Standard",
436
+ "published": "2012-04",
437
+ "tracker": "https://www.rfc-editor.org/info/rfc6545",
438
+ "relevance": "Defines the RID schema for exchanging incident-response data between organizations and CSIRTs. Operator-facing relevance is via the IODEF (RFC 7970) payload that RID transports — the pair is the IETF-standardized cross-organizational incident-coordination protocol. Adoption lags; in practice many SOCs use ad-hoc CSV/JSON exchanges instead, leaving compliance with 'documented coordination channel' requirements weakly evidenced.",
439
+ "skills_referencing": [
440
+ "incident-response-playbook"
441
+ ],
442
+ "last_verified": "2026-05-13"
443
+ },
444
+ "RFC-6546": {
445
+ "number": 6546,
446
+ "title": "Transport of Real-time Inter-network Defense (RID) Messages over HTTP/TLS",
447
+ "status": "Proposed Standard",
448
+ "published": "2012-04",
449
+ "tracker": "https://www.rfc-editor.org/info/rfc6546",
450
+ "relevance": "Specifies the HTTP-over-TLS transport for RID (RFC 6545) messages. Operator-facing: provides the wire-level protocol for IODEF/RID message exchange when an organization commits to standardized incident-data coordination. Pair this with RFC 6545 (schema) and RFC 7970 (IODEF v2 payload).",
451
+ "skills_referencing": [
452
+ "incident-response-playbook"
453
+ ],
454
+ "last_verified": "2026-05-13"
455
+ },
456
+ "RFC-7970": {
457
+ "number": 7970,
458
+ "title": "The Incident Object Description Exchange Format Version 2",
459
+ "status": "Proposed Standard",
460
+ "published": "2016-11",
461
+ "tracker": "https://www.rfc-editor.org/info/rfc7970",
462
+ "relevance": "IODEF v2 — the structured-incident payload format carried by RID. Operator-facing: IODEF v2 is the canonical machine-readable incident record. Use cases include cross-CSIRT coordination, regulator submissions where structured data is requested, and SIEM-to-SIEM federation. Adoption is sector-uneven; healthcare and financial sectors have stronger uptake than general industry.",
463
+ "skills_referencing": [
464
+ "incident-response-playbook"
465
+ ],
466
+ "last_verified": "2026-05-13"
323
467
  }
324
468
  }
@@ -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
 
@@ -43,6 +43,21 @@ const ROOT = path.join(__dirname, "..");
43
43
  const ABS = (p) => path.join(ROOT, p);
44
44
  const TODAY = new Date().toISOString().slice(0, 10);
45
45
 
46
+ // v0.12.8: the CVE catalog path used by refresh-external is overridable so
47
+ // tests can redirect to a tempdir instead of mutating the real shipped
48
+ // data/cve-catalog.json. Resolution order:
49
+ // 1. opts.catalog (--catalog CLI arg)
50
+ // 2. process.env.EXCEPTD_CVE_CATALOG (env var)
51
+ // 3. ROOT/data/cve-catalog.json (default)
52
+ // All four write-sites in this file route through resolveCatalogPath() so
53
+ // that the redirect is consistent across the advisory-import, GHSA-import,
54
+ // and per-source merge code paths.
55
+ function resolveCatalogPath(opts) {
56
+ if (opts && opts.catalog) return path.resolve(opts.catalog);
57
+ if (process.env.EXCEPTD_CVE_CATALOG) return path.resolve(process.env.EXCEPTD_CVE_CATALOG);
58
+ return ABS("data/cve-catalog.json");
59
+ }
60
+
46
61
  function parseArgs(argv) {
47
62
  const out = {
48
63
  apply: false,
@@ -64,6 +79,8 @@ function parseArgs(argv) {
64
79
  else if (a === "--help" || a === "-h") out.help = true;
65
80
  else if (a === "--advisory") { out.advisory = argv[++i]; }
66
81
  else if (a.startsWith("--advisory=")) { out.advisory = a.slice("--advisory=".length); }
82
+ else if (a === "--catalog") { out.catalog = argv[++i]; }
83
+ else if (a.startsWith("--catalog=")) { out.catalog = a.slice("--catalog=".length); }
67
84
  else if (a === "--from-cache") {
68
85
  // accept either --from-cache <path> or --from-cache (default path)
69
86
  const next = argv[i + 1];
@@ -204,7 +221,7 @@ const KEV_SOURCE = {
204
221
  }
205
222
  ctx.cveCatalog._meta = ctx.cveCatalog._meta || {};
206
223
  ctx.cveCatalog._meta.last_updated = TODAY;
207
- writeJson(ABS("data/cve-catalog.json"), ctx.cveCatalog);
224
+ writeJson(ctx.cvePath || ABS("data/cve-catalog.json"), ctx.cveCatalog);
208
225
  return { updated: updated + added, added, drift_updated: updated, errors };
209
226
  },
210
227
  };
@@ -279,7 +296,7 @@ const EPSS_SOURCE = {
279
296
  }
280
297
  ctx.cveCatalog._meta = ctx.cveCatalog._meta || {};
281
298
  ctx.cveCatalog._meta.last_updated = TODAY;
282
- writeJson(ABS("data/cve-catalog.json"), ctx.cveCatalog);
299
+ writeJson(ctx.cvePath || ABS("data/cve-catalog.json"), ctx.cveCatalog);
283
300
  return { updated, errors };
284
301
  },
285
302
  };
@@ -324,7 +341,7 @@ const NVD_SOURCE = {
324
341
  }
325
342
  ctx.cveCatalog._meta = ctx.cveCatalog._meta || {};
326
343
  ctx.cveCatalog._meta.last_updated = TODAY;
327
- writeJson(ABS("data/cve-catalog.json"), ctx.cveCatalog);
344
+ writeJson(ctx.cvePath || ABS("data/cve-catalog.json"), ctx.cveCatalog);
328
345
  return { updated, errors };
329
346
  },
330
347
  };
@@ -652,17 +669,20 @@ function rfcDiffFromCache(ctx) {
652
669
 
653
670
  function pinsDiffFromCache(ctx) {
654
671
  // Cache layout under pins/: <owner>__<repo>__releases.json arrays.
672
+ // Only repos that publish via GitHub Releases live here — D3FEND and CWE
673
+ // were removed in the same pass that pruned them from lib/prefetch.js's
674
+ // SOURCES.pins (neither project tags releases on GitHub; D3FEND ships
675
+ // the ontology from d3fend/d3fend-ontology without tagged releases,
676
+ // and CWE distributes XML from cwe.mitre.org). Pin currency for those
677
+ // two frameworks is monitored via lib/upstream-check.js against their
678
+ // canonical mitre.org endpoints, not through the prefetch cache.
655
679
  const PIN_REPOS = {
656
680
  atlas_version: "mitre-atlas__atlas-data__releases",
657
681
  attack_version: "mitre-attack__attack-stix-data__releases",
658
- d3fend_version: "d3fend__d3fend-data__releases",
659
- cwe_version: "mitre__cwe__releases",
660
682
  };
661
683
  const localOf = {
662
684
  atlas_version: ctx.manifest.atlas_version,
663
685
  attack_version: ctx.manifest.attack_version,
664
- d3fend_version: ctx.d3fendCatalog?._meta?.version || ctx.d3fendCatalog?._meta?.d3fend_version || null,
665
- cwe_version: ctx.cweCatalog?._meta?.version || ctx.cweCatalog?._meta?.cwe_version || null,
666
686
  };
667
687
  const diffs = [];
668
688
  let errors = 0;
@@ -714,9 +734,11 @@ function synthesizeFromFixture(ctx, sourceName) {
714
734
  // --- IO helpers --------------------------------------------------------
715
735
 
716
736
  function loadCtx(opts) {
737
+ const cvePath = resolveCatalogPath(opts);
717
738
  const ctx = {
718
739
  manifest: JSON.parse(fs.readFileSync(ABS("manifest.json"), "utf8")),
719
- cveCatalog: JSON.parse(fs.readFileSync(ABS("data/cve-catalog.json"), "utf8")),
740
+ cvePath, // remember the resolved path; applyDiff callbacks write through it
741
+ cveCatalog: JSON.parse(fs.readFileSync(cvePath, "utf8")),
720
742
  rfcCatalog: JSON.parse(fs.readFileSync(ABS("data/rfc-references.json"), "utf8")),
721
743
  cweCatalog: JSON.parse(fs.readFileSync(ABS("data/cwe-catalog.json"), "utf8")),
722
744
  d3fendCatalog: JSON.parse(fs.readFileSync(ABS("data/d3fend-catalog.json"), "utf8")),
@@ -828,7 +850,8 @@ async function seedSingleAdvisory(opts) {
828
850
  }
829
851
 
830
852
  // Apply: write to cve-catalog.json with the _auto_imported flag.
831
- const catalogPath = ABS("data/cve-catalog.json");
853
+ // v0.12.8: honor --catalog / EXCEPTD_CVE_CATALOG so tests can redirect.
854
+ const catalogPath = resolveCatalogPath(opts);
832
855
  const catalog = JSON.parse(fs.readFileSync(catalogPath, "utf8"));
833
856
  if (catalog[cveId] && !catalog[cveId]._auto_imported && !catalog[cveId]._draft) {
834
857
  // Refuse to overwrite a human-curated entry.
@@ -71,9 +71,9 @@
71
71
  "type": "array",
72
72
  "items": {
73
73
  "type": "string",
74
- "pattern": "^(RFC-[0-9]+|DRAFT-[A-Z0-9-]+)$"
74
+ "pattern": "^(RFC-[0-9]+|DRAFT-[A-Z0-9-]+|ISO-[0-9]+|CSAF-[0-9]+\\.[0-9]+)$"
75
75
  },
76
- "description": "Optional. IETF RFC numbers (e.g. RFC-8446) or Internet-Draft slugs (e.g. DRAFT-IETF-TLS-ECDHE-MLKEM) the skill depends on. Each must resolve in data/rfc-references.json."
76
+ "description": "Optional. IETF RFC numbers (e.g. RFC-8446), Internet-Draft slugs (e.g. DRAFT-IETF-TLS-ECDHE-MLKEM), ISO standards (e.g. ISO-29147), or OASIS CSAF advisory format (CSAF-2.0). Each must resolve in data/rfc-references.json."
77
77
  },
78
78
  "cwe_refs": {
79
79
  "type": "array",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_comment": "Auto-generated by scripts/refresh-manifest-snapshot.js — do not hand-edit. Public skill surface used by check-manifest-snapshot.js to detect breaking removals.",
3
- "_generated_at": "2026-05-13T04:36:44.853Z",
3
+ "_generated_at": "2026-05-13T15:30:27.029Z",
4
4
  "atlas_version": "5.1.0",
5
5
  "skill_count": 38,
6
6
  "skills": [