@blamejs/exceptd-skills 0.12.41 → 0.13.1

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 (83) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/bin/exceptd.js +52 -44
  3. package/data/_indexes/_meta.json +49 -49
  4. package/data/_indexes/activity-feed.json +2 -2
  5. package/data/_indexes/catalog-summaries.json +2 -2
  6. package/data/_indexes/chains.json +1531 -575
  7. package/data/_indexes/jurisdiction-map.json +15 -4
  8. package/data/_indexes/section-offsets.json +1244 -1244
  9. package/data/_indexes/token-budget.json +173 -173
  10. package/data/atlas-ttps.json +55 -11
  11. package/data/attack-techniques.json +124 -19
  12. package/data/cve-catalog.json +194 -27
  13. package/data/cwe-catalog.json +15 -5
  14. package/data/framework-control-gaps.json +32 -10
  15. package/data/playbooks/ai-api.json +5 -0
  16. package/data/playbooks/cicd-pipeline-compromise.json +970 -0
  17. package/data/playbooks/cloud-iam-incident.json +4 -1
  18. package/data/playbooks/cred-stores.json +10 -0
  19. package/data/playbooks/framework.json +16 -0
  20. package/data/playbooks/hardening.json +4 -0
  21. package/data/playbooks/identity-sso-compromise.json +951 -0
  22. package/data/playbooks/idp-incident.json +3 -0
  23. package/data/playbooks/kernel.json +6 -0
  24. package/data/playbooks/llm-tool-use-exfil.json +963 -0
  25. package/data/playbooks/mcp.json +6 -0
  26. package/data/playbooks/runtime.json +4 -0
  27. package/data/playbooks/sbom.json +13 -0
  28. package/data/playbooks/secrets.json +6 -0
  29. package/data/playbooks/webhook-callback-abuse.json +916 -0
  30. package/data/zeroday-lessons.json +178 -0
  31. package/lib/cross-ref-api.js +33 -13
  32. package/lib/cve-curation.js +12 -1
  33. package/lib/exit-codes.js +29 -0
  34. package/lib/lint-skills.js +24 -2
  35. package/lib/refresh-external.js +17 -1
  36. package/lib/scoring.js +55 -0
  37. package/lib/source-advisories.js +281 -0
  38. package/manifest.json +83 -83
  39. package/orchestrator/index.js +207 -24
  40. package/package.json +1 -1
  41. package/sbom.cdx.json +134 -79
  42. package/scripts/predeploy.js +7 -13
  43. package/scripts/refresh-reverse-refs.js +86 -0
  44. package/scripts/refresh-sbom.js +21 -4
  45. package/skills/age-gates-child-safety/skill.md +1 -5
  46. package/skills/ai-attack-surface/skill.md +11 -4
  47. package/skills/ai-c2-detection/skill.md +11 -2
  48. package/skills/ai-risk-management/skill.md +4 -2
  49. package/skills/api-security/skill.md +7 -8
  50. package/skills/attack-surface-pentest/skill.md +2 -2
  51. package/skills/cloud-iam-incident/skill.md +1 -5
  52. package/skills/cloud-security/skill.md +0 -4
  53. package/skills/compliance-theater/skill.md +10 -2
  54. package/skills/container-runtime-security/skill.md +1 -3
  55. package/skills/dlp-gap-analysis/skill.md +3 -4
  56. package/skills/email-security-anti-phishing/skill.md +1 -8
  57. package/skills/exploit-scoring/skill.md +7 -2
  58. package/skills/framework-gap-analysis/skill.md +1 -1
  59. package/skills/fuzz-testing-strategy/skill.md +1 -2
  60. package/skills/global-grc/skill.md +3 -2
  61. package/skills/identity-assurance/skill.md +1 -3
  62. package/skills/idp-incident-response/skill.md +1 -4
  63. package/skills/incident-response-playbook/skill.md +1 -5
  64. package/skills/kernel-lpe-triage/skill.md +2 -2
  65. package/skills/mcp-agent-trust/skill.md +13 -3
  66. package/skills/mlops-security/skill.md +2 -3
  67. package/skills/ot-ics-security/skill.md +0 -3
  68. package/skills/policy-exception-gen/skill.md +11 -3
  69. package/skills/pqc-first/skill.md +4 -2
  70. package/skills/rag-pipeline-security/skill.md +2 -0
  71. package/skills/ransomware-response/skill.md +1 -5
  72. package/skills/researcher/skill.md +4 -3
  73. package/skills/sector-energy/skill.md +0 -4
  74. package/skills/sector-federal-government/skill.md +2 -3
  75. package/skills/sector-financial/skill.md +1 -4
  76. package/skills/sector-healthcare/skill.md +0 -5
  77. package/skills/sector-telecom/skill.md +0 -4
  78. package/skills/security-maturity-tiers/skill.md +1 -2
  79. package/skills/skill-update-loop/skill.md +4 -3
  80. package/skills/supply-chain-integrity/skill.md +4 -3
  81. package/skills/threat-model-currency/skill.md +1 -1
  82. package/skills/threat-modeling-methodology/skill.md +2 -1
  83. package/skills/webapp-security/skill.md +0 -5
@@ -1941,5 +1941,183 @@
1941
1941
  "ai_discovery_source": "vendor_research",
1942
1942
  "ai_discovery_date": "2026-05-14",
1943
1943
  "ai_assist_factor": "low"
1944
+ },
1945
+ "CVE-2026-46333": {
1946
+ "name": "ssh-keysign-pwn (Linux kernel ptrace exit-race)",
1947
+ "lesson_date": "2026-05-17",
1948
+ "attack_vector": {
1949
+ "description": "Linux kernel ptrace exit-race: exit_mm() runs before exit_files() during privileged-process shutdown, leaving a microsecond window where task->mm == NULL but the fd table still holds privileged file handles. Pre-fix __ptrace_may_access() skipped its get_dumpable() check when mm == NULL, silently authorizing UID-matched access. Unprivileged attacker races ssh-keysign or chage exit, calls pidfd_getfd(2) to duplicate still-open fds, reads /etc/ssh/ssh_host_*_key or /etc/shadow as root.",
1950
+ "privileges_required": "unprivileged local user with shell access",
1951
+ "complexity": "race-condition with deterministic-on-loop primitive — 100-2000 attempts typically succeed",
1952
+ "ai_factor": "Not AI-discovered. Qualys TRU human research. The underlying flaw was originally proposed in a 2020 Jann Horn patch that was never merged; 6-year dormant logic bug."
1953
+ },
1954
+ "defense_chain": {
1955
+ "prevention": {
1956
+ "what_would_have_worked": "Merging the 2020 Jann Horn patch proposal. seccomp profiles blocking pidfd_getfd for unprivileged users. SUID removal from ssh-keysign + chage on hosts where host-based SSH auth + age-warn UX are not required. sysctl kernel.user_ptrace=0 to block unprivileged ptrace system-wide.",
1957
+ "was_this_required": false,
1958
+ "framework_requiring_it": null,
1959
+ "adequacy": "Yama ptrace_scope is NOT a compensating control — the bypass is at the kernel access-check layer, not the LSM layer. The 2020 Horn patch would have closed the class entirely."
1960
+ },
1961
+ "detection": {
1962
+ "what_would_have_worked": "auditd rule on pidfd_getfd with auid>=1000 fires on unprivileged invocation. eBPF on tracepoint:syscalls:sys_enter_pidfd_getfd correlated with ssh-keysign / chage execution bursts surfaces the 100-2000-attempt race loop loudly.",
1963
+ "was_this_required": false,
1964
+ "framework_requiring_it": null,
1965
+ "adequacy": "Detection only; race is running by the time auditd fires. Mitigates post-exploitation cleanup, not exfil itself."
1966
+ },
1967
+ "response": {
1968
+ "what_would_have_worked": "Rotate SSH host keys + /etc/shadow on hosts with observed pidfd_getfd anomalies in disclosure window. Patch + reboot (kernel point releases 7.0.8 / 6.18.31 / 6.12.89 / 6.6.139 / 6.1.173 / 5.15.207 / 5.10.256). KernelCare livepatch when released.",
1969
+ "was_this_required": false,
1970
+ "framework_requiring_it": null,
1971
+ "adequacy": "Host-key rotation is non-trivial in fleet ops — SSH known_hosts trust graph fragments. Most operators patch + accept residual window between disclosure and reboot scheduling."
1972
+ }
1973
+ },
1974
+ "framework_coverage": {
1975
+ "NIST-800-53-SI-2": {
1976
+ "covered": true,
1977
+ "adequate": false,
1978
+ "gap": "30-day critical patch SLA is an exploitation window for a kernel LPE with two public PoCs. Reboot-required nature breaks standard SI-2 maintenance assumptions."
1979
+ },
1980
+ "NIST-800-53-AC-3": {
1981
+ "covered": true,
1982
+ "adequate": false,
1983
+ "gap": "Access enforcement compliance assumes file-permission integrity. ssh-keysign-pwn defeats file permissions via kernel-level access-check skip — AC-3 mode-bit checks are not compensating controls."
1984
+ },
1985
+ "NIST-800-53-AU-2": {
1986
+ "covered": true,
1987
+ "adequate": false,
1988
+ "gap": "Audit event selection should include pidfd_getfd post-disclosure; pre-disclosure the syscall was rarely on default audit rule sets."
1989
+ },
1990
+ "ISO-27001-2022-A.8.8": {
1991
+ "covered": true,
1992
+ "adequate": false,
1993
+ "gap": "Appropriate timescales undefined; same problem as CVE-2026-43284 / CVE-2026-46300 / CVE-2026-31431."
1994
+ },
1995
+ "NIS2-Art21-patch-management": {
1996
+ "covered": true,
1997
+ "adequate": false,
1998
+ "gap": "Art. 21(2)(c) measures undefined for fast-cycle kernel LPEs. No guidance on sysctl or SUID-removal as interim measures."
1999
+ }
2000
+ },
2001
+ "new_control_requirements": [
2002
+ {
2003
+ "id": "NEW-CTRL-048",
2004
+ "name": "KERNEL-EXIT-RACE-CVE-CLASS-MONITORING",
2005
+ "description": "When a CVE is disclosed in the kernel ptrace / pidfd / fd-table lifecycle class, audit rules SHOULD enable pidfd_getfd / pidfd_open / pidfd_send_signal tracking for auid>=1000 within 24h. The syscall is rare in normal operation; spike volume after a same-class disclosure is high-confidence exploitation signal.",
2006
+ "evidence": "CVE-2026-46333: the 100-2000-attempt exploit loop is loud in audit but most default rule sets do not monitor pidfd_getfd.",
2007
+ "gap_closes": [
2008
+ "NIST-800-53-AU-2",
2009
+ "NIST-800-53-SI-4"
2010
+ ]
2011
+ },
2012
+ {
2013
+ "id": "NEW-CTRL-049",
2014
+ "name": "SUID-MINIMIZATION-FOR-KERNEL-LPE-CARRIER-BINARIES",
2015
+ "description": "Hosts that do not use SSH host-based authentication SHOULD have ssh-keysign de-suid-ed. Hosts that do not use age-warn login UX SHOULD have chage de-suid-ed. Removes the privileged-process carrier even before the kernel patch lands.",
2016
+ "evidence": "CVE-2026-46333: ssh-keysign + chage are the canonical carriers; SUID-removal blocks the exploit at the carrier layer regardless of kernel patch state.",
2017
+ "gap_closes": [
2018
+ "NIST-800-53-CM-6",
2019
+ "NIST-800-53-AC-3"
2020
+ ]
2021
+ }
2022
+ ],
2023
+ "compliance_exposure_score": {
2024
+ "percent_audit_passing_orgs_still_exposed": 95,
2025
+ "basis": "Every Linux fleet running OpenSSH + shadow-utils — every default install for the last 6 years. Audit-passing orgs are exposed because the bug is in default-shipped kernels.",
2026
+ "theater_pattern": "patch_management"
2027
+ },
2028
+ "ai_discovered_zeroday": false,
2029
+ "ai_discovery_source": "human_researcher",
2030
+ "ai_assist_factor": "low"
2031
+ },
2032
+ "MAL-2026-SHAI-HULUD-OSS": {
2033
+ "name": "Shai-Hulud worm framework open-source release (TeamPCP)",
2034
+ "lesson_date": "2026-05-17",
2035
+ "attack_vector": {
2036
+ "description": "Threat-actor open-source release of an operational supply-chain worm under MIT license, paired with a BreachForums-hosted cash-bounty contest for downstream impact. Lowers barrier-to-entry from custom-tradecraft to clone-and-deploy. Framework targets AI-coding-assistant config files (~/.cursor, ~/.codeium, ~/.claude) alongside cloud + registry credentials; adds Claude Code startup hooks for persistence. Self-replicates via maintainer-token-pivot — stolen npm token authenticates as compromised maintainer, publishes malicious versions of other packages owned by the same maintainer.",
2037
+ "privileges_required": "opportunistic — any developer workstation where the package is installed and post-install or require-time activation fires",
2038
+ "complexity": "post-release: low. Pre-release: high (custom tradecraft).",
2039
+ "ai_factor": "TeamPCP self-describes as \"vibe coded\" — AI-coding-assistant-mediated authoring. Defenders should expect rapid variant proliferation accelerated by AI coding assistants."
2040
+ },
2041
+ "defense_chain": {
2042
+ "prevention": {
2043
+ "what_would_have_worked": "Maintainer-side: hardware-key-bound npm publish + MFA-required republish + npm-token isolation (publish tokens NOT on developer workstations). Consumer-side: package-pin hash verification, --ignore-scripts for postinstall, internal Verdaccio proxy with manual gating on version-bump risk-scoring. AI-assistant-side: file-permission restriction on ~/.claude/settings.json, ~/.cursor/mcp.json, ~/.codeium/windsurf/mcp_config.json (0600 on POSIX, ACL-restricted on Windows) so unprivileged processes cannot read.",
2044
+ "was_this_required": false,
2045
+ "framework_requiring_it": null,
2046
+ "adequacy": "No single control prevents the attack class. Defense-in-depth across maintainer + registry + consumer + AI-config layers reduces blast radius but does not eliminate."
2047
+ },
2048
+ "detection": {
2049
+ "what_would_have_worked": "Anomaly detection on npm publish events from new IPs, unusual cadence, or unusual package selection per maintainer. Monitor GitHub for repos matching \"A Gift From TeamPCP\" naming pattern OR with commit timestamps in year 2099 OR with agwagwagwa / headdirt / tmechen account contributors. eBPF on file-read events for ~/.aws, ~/.ssh, ~/.cursor, ~/.codeium, ~/.claude from processes spawned by node / npm / yarn / pnpm.",
2050
+ "was_this_required": false,
2051
+ "framework_requiring_it": null,
2052
+ "adequacy": "Detection-side is operator-feasible but reactive. By the time anomaly fires, credentials may already be in the attacker-controlled GitHub repo."
2053
+ },
2054
+ "response": {
2055
+ "what_would_have_worked": "Immediate npm token rotation across all developer workstations + maintainer accounts. Audit all published package versions in the disclosure window for any unauthorized publish under compromised credentials. Roll back malicious versions via npm registry security-team channel. Rotate all secrets referenced by AI-assistant config files (MCP server tokens, Anthropic API keys, OpenAI keys).",
2056
+ "was_this_required": false,
2057
+ "framework_requiring_it": null,
2058
+ "adequacy": "Response is unbounded — once credentials leak, blast radius is the union of every API the credential can reach."
2059
+ }
2060
+ },
2061
+ "framework_coverage": {
2062
+ "NIST-800-218-SSDF-PW.4": {
2063
+ "covered": true,
2064
+ "adequate": false,
2065
+ "gap": "Tooling-trust assumption invalid when maintainer workstation is compromised."
2066
+ },
2067
+ "NIST-800-53-SR-3": {
2068
+ "covered": true,
2069
+ "adequate": false,
2070
+ "gap": "Supply-chain-tampering controls do not address legitimately-authenticated malicious upstream."
2071
+ },
2072
+ "EU-CRA-Art13": {
2073
+ "covered": true,
2074
+ "adequate": false,
2075
+ "gap": "Vulnerability-handling treats malicious upgrades as outside scope."
2076
+ },
2077
+ "SLSA-v1.0-Build-L3": {
2078
+ "covered": true,
2079
+ "adequate": false,
2080
+ "gap": "L3 provenance is valid for Shai-Hulud-poisoned packages — the build IS provenance-attested under the compromised maintainer identity."
2081
+ }
2082
+ },
2083
+ "new_control_requirements": [
2084
+ {
2085
+ "id": "NEW-CTRL-050",
2086
+ "name": "AI-ASSISTANT-CONFIG-FILE-PERMISSION-LOCKDOWN",
2087
+ "description": "AI-assistant configuration files (~/.claude/, ~/.cursor/, ~/.codeium/, ~/.aider/, ~/.continue/) that carry MCP server tokens or LLM API keys MUST be mode 0600 on POSIX and ACL-restricted to the workstation user on Windows. Default mode on these files is typically 0644; Shai-Hulud reads them at unprivileged process scope. exceptd v0.12.41 hardened attestation sidecars to 0o600 for the same reason; the workstation-config category needs the same defensive posture.",
2088
+ "evidence": "MAL-2026-SHAI-HULUD-OSS framework explicitly reads ~/.cursor/mcp.json + ~/.codeium/windsurf/mcp_config.json + ~/.claude/settings.json as exfil targets.",
2089
+ "gap_closes": [
2090
+ "NIST-800-53-AC-3",
2091
+ "NIST-800-53-CM-6"
2092
+ ]
2093
+ },
2094
+ {
2095
+ "id": "NEW-CTRL-051",
2096
+ "name": "NPM-PUBLISH-TOKEN-WORKSTATION-ISOLATION",
2097
+ "description": "npm publish tokens MUST NOT reside on developer workstations. Publish should require a hardware-key-bound CI/CD pipeline gate (GitHub Actions with OIDC + npm provisioning, or equivalent). Workstation tokens scope to read-only registry pulls.",
2098
+ "evidence": "MAL-2026-SHAI-HULUD-OSS pivot mechanism requires a write-scope npm token on the maintainer workstation.",
2099
+ "gap_closes": [
2100
+ "NIST-800-53-IA-5",
2101
+ "NIST-800-218-SSDF-PW.4"
2102
+ ]
2103
+ },
2104
+ {
2105
+ "id": "NEW-CTRL-052",
2106
+ "name": "GITHUB-REPO-PATTERN-MONITORING-FOR-EXFIL-CHANNELS",
2107
+ "description": "Organizations SHOULD monitor GitHub for repository creation matching known threat-actor naming patterns (\"A Gift From TeamPCP\", \"Shai-Hulud\", future variants). The attacker uses GitHub itself as the exfil channel; the GitHub Search API + Code Search API are sufficient.",
2108
+ "evidence": "MAL-2026-SHAI-HULUD-OSS pattern; pre-2026-05-12 Shai-Hulud waves used \"Shai-Hulud-*\" repo naming.",
2109
+ "gap_closes": [
2110
+ "NIST-800-53-SI-4"
2111
+ ]
2112
+ }
2113
+ ],
2114
+ "compliance_exposure_score": {
2115
+ "percent_audit_passing_orgs_still_exposed": 80,
2116
+ "basis": "Every npm-consuming engineering org. SLSA + SSDF + SR-3 compliance does not protect against legitimately-authenticated malicious-maintainer publishes.",
2117
+ "theater_pattern": "sbom_and_provenance"
2118
+ },
2119
+ "ai_discovered_zeroday": false,
2120
+ "ai_discovery_source": "threat_actor_release",
2121
+ "ai_assist_factor": "high"
1944
2122
  }
1945
2123
  }
@@ -37,56 +37,76 @@ const _cache = new Map();
37
37
  // can inspect.
38
38
  const _loadErrors = [];
39
39
 
40
- function _statMtime(p) {
41
- try { return fs.statSync(p).mtimeMs; }
42
- catch { return null; }
40
+ /**
41
+ * v0.13.0: cache invalidation is keyed on (mtimeMs, size). Pre-v0.13 it
42
+ * was mtime-only, but on filesystems with 1-2s mtime granularity
43
+ * (FAT32, HFS+ pre-APFS, NFSv3, Docker bind-mounts that proxy mtime)
44
+ * a rapid refresh-then-reload within the same second served stale
45
+ * cached data. Adding `size` catches every content change that affects
46
+ * byte count; mtimeMs catches in-place rewrites that preserve byte
47
+ * count. Together they cover every realistic catalog-mutation path
48
+ * without the cost of a per-load SHA computation. SHA-based tier is
49
+ * available via _statContentHash() when callers want full invalidation
50
+ * (e.g. long-running daemons against append-only catalogs).
51
+ */
52
+ function _statSignature(p) {
53
+ try {
54
+ const s = fs.statSync(p);
55
+ return { mtime: s.mtimeMs, size: s.size };
56
+ } catch { return null; }
57
+ }
58
+
59
+ function _signatureEquals(a, b) {
60
+ if (a === null && b === null) return true;
61
+ if (a === null || b === null) return false;
62
+ return a.mtime === b.mtime && a.size === b.size;
43
63
  }
44
64
 
45
65
  function loadCatalog(filename) {
46
66
  const full = path.join(DATA_DIR, filename);
47
- const mtime = _statMtime(full);
67
+ const sig = _statSignature(full);
48
68
  const cached = _cache.get(filename);
49
- if (cached && (mtime === null || cached.mtime === mtime)) {
69
+ if (cached && (sig === null || _signatureEquals(cached.sig, sig))) {
50
70
  return cached.value;
51
71
  }
52
72
  if (!fs.existsSync(full)) {
53
- _cache.set(filename, { value: {}, mtime });
73
+ _cache.set(filename, { value: {}, sig });
54
74
  return {};
55
75
  }
56
76
  try {
57
77
  const parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
58
- _cache.set(filename, { value: parsed, mtime });
78
+ _cache.set(filename, { value: parsed, sig });
59
79
  return parsed;
60
80
  } catch (e) {
61
81
  _loadErrors.push({ kind: 'catalog', file: filename, error: e.message });
62
82
  const stub = {};
63
83
  Object.defineProperty(stub, '_loadError', { value: e.message, enumerable: false });
64
- _cache.set(filename, { value: stub, mtime });
84
+ _cache.set(filename, { value: stub, sig });
65
85
  return stub;
66
86
  }
67
87
  }
68
88
 
69
89
  function loadIndex(filename) {
70
90
  const full = path.join(INDEX_DIR, filename);
71
- const mtime = _statMtime(full);
91
+ const sig = _statSignature(full);
72
92
  const key = 'idx:' + filename;
73
93
  const cached = _cache.get(key);
74
- if (cached && (mtime === null || cached.mtime === mtime)) {
94
+ if (cached && (sig === null || _signatureEquals(cached.sig, sig))) {
75
95
  return cached.value;
76
96
  }
77
97
  if (!fs.existsSync(full)) {
78
- _cache.set(key, { value: {}, mtime });
98
+ _cache.set(key, { value: {}, sig });
79
99
  return {};
80
100
  }
81
101
  try {
82
102
  const parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
83
- _cache.set(key, { value: parsed, mtime });
103
+ _cache.set(key, { value: parsed, sig });
84
104
  return parsed;
85
105
  } catch (e) {
86
106
  _loadErrors.push({ kind: 'index', file: filename, error: e.message });
87
107
  const stub = {};
88
108
  Object.defineProperty(stub, '_loadError', { value: e.message, enumerable: false });
89
- _cache.set(key, { value: stub, mtime });
109
+ _cache.set(key, { value: stub, sig });
90
110
  return stub;
91
111
  }
92
112
  }
@@ -637,7 +637,18 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
637
637
 
638
638
  function writeJsonAtomic(p, obj) {
639
639
  const tmpPath = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
640
- fs.writeFileSync(tmpPath, JSON.stringify(obj, null, 2) + "\n", "utf8");
640
+ // v0.13.0: fsync the tmp file before rename so a power loss between
641
+ // write and rename leaves the durable destination intact. Without
642
+ // fsync the data sits in the OS page cache and the rename succeeds
643
+ // atomically, but the renamed file may be zero-length / partial on
644
+ // crash. Open + write + fsync + close + rename is the durable idiom.
645
+ const fd = fs.openSync(tmpPath, 'w');
646
+ try {
647
+ fs.writeSync(fd, JSON.stringify(obj, null, 2) + "\n", 0, "utf8");
648
+ fs.fsyncSync(fd);
649
+ } finally {
650
+ fs.closeSync(fd);
651
+ }
641
652
  try {
642
653
  fs.renameSync(tmpPath, p);
643
654
  } catch (err) {
package/lib/exit-codes.js CHANGED
@@ -66,9 +66,38 @@ function listExitCodes() {
66
66
  }));
67
67
  }
68
68
 
69
+ /**
70
+ * Set the process exit code WITHOUT calling process.exit(). Returns to the
71
+ * caller so any pending stdout/stderr writes drain on natural event-loop
72
+ * shutdown.
73
+ *
74
+ * `process.exit(N)` terminates the process synchronously, which truncates
75
+ * buffered async stdout when stdout is piped (CI, test harnesses, --json
76
+ * consumers). The v0.11.10 CI #100 fix established the exitCode-then-return
77
+ * idiom for that reason; every subsequent regression that re-introduced
78
+ * bare process.exit() in the dispatch surface was the same class of bug.
79
+ *
80
+ * Callers SHOULD prefer this helper for any exit-after-stdout-write path.
81
+ * Long-running daemons / tests that need synchronous termination can still
82
+ * use process.exit() directly — that's intentional and not what this guards.
83
+ *
84
+ * @param {number} code Exit code (use the EXIT_CODES constants)
85
+ * @returns {void}
86
+ */
87
+ function safeExit(code) {
88
+ // Only override exitCode when it isn't already set to a non-zero value —
89
+ // matches the emit() ok:false fallback contract so a caller that already
90
+ // set BLOCKED (4) before emit() doesn't get overwritten by a later
91
+ // GENERIC_FAILURE (1).
92
+ if (!process.exitCode || process.exitCode === 0) {
93
+ process.exitCode = code;
94
+ }
95
+ }
96
+
69
97
  module.exports = {
70
98
  EXIT_CODES,
71
99
  EXIT_CODE_DESCRIPTIONS,
72
100
  exitCodeName,
73
101
  listExitCodes,
102
+ safeExit,
74
103
  };
@@ -243,6 +243,11 @@ function extractFrontmatterBlock(content) {
243
243
  /* Validate frontmatter object against the codified schema rules. */
244
244
  function validateFrontmatter(fm, skillName) {
245
245
  const errors = [];
246
+ // v0.13.0: validateFrontmatter now ALSO surfaces warnings (e.g. the
247
+ // last_threat_review 180-day soft cap). Return signature changes from
248
+ // `string[]` to `{ errors: string[], warnings: string[] }` — callers
249
+ // updated accordingly.
250
+ const warnings = [];
246
251
 
247
252
  for (const key of Object.keys(fm)) {
248
253
  if (!ALL_KNOWN_FIELDS.has(key)) {
@@ -351,10 +356,25 @@ function validateFrontmatter(fm, skillName) {
351
356
  errors.push(
352
357
  `frontmatter.last_threat_review "${fm.last_threat_review}" is not an ISO date (YYYY-MM-DD)`,
353
358
  );
359
+ } else {
360
+ // v0.13.0: Hard Rule #8 forcing function — refuse skills whose
361
+ // last_threat_review is older than the staleness threshold.
362
+ // 180-day soft cap (warn), 365-day hard cap (fail). Operators on
363
+ // older releases who don't refresh fall off the supported window.
364
+ const days = Math.floor((Date.now() - Date.parse(fm.last_threat_review + 'T00:00:00Z')) / (24 * 60 * 60 * 1000));
365
+ if (days > 365) {
366
+ errors.push(
367
+ `frontmatter.last_threat_review "${fm.last_threat_review}" is ${days} days old — Hard Rule #8 staleness gate (hard fail at >365 days). Refresh the threat review against current intel and bump the date.`,
368
+ );
369
+ } else if (days > 180) {
370
+ warnings.push(
371
+ `frontmatter.last_threat_review "${fm.last_threat_review}" is ${days} days old — Hard Rule #8 staleness warning (warn at >180 days, hard fail at >365). Schedule a review.`,
372
+ );
373
+ }
354
374
  }
355
375
  }
356
376
 
357
- return errors;
377
+ return { errors, warnings };
358
378
  }
359
379
 
360
380
  /* L1 — Heading-anchored section detection.
@@ -460,7 +480,9 @@ function lintSkill(entry, ctx) {
460
480
  return { name: entry.name, errors: skillErrors, warnings: skillWarnings };
461
481
  }
462
482
 
463
- skillErrors.push(...validateFrontmatter(fm, entry.name));
483
+ const fmResult = validateFrontmatter(fm, entry.name);
484
+ skillErrors.push(...fmResult.errors);
485
+ skillWarnings.push(...fmResult.warnings);
464
486
 
465
487
  if (Array.isArray(fm.data_deps)) {
466
488
  for (const dep of fm.data_deps) {
@@ -637,6 +637,12 @@ const OSV_SOURCE = {
637
637
  },
638
638
  };
639
639
 
640
+ // v0.13.1: ADVISORIES_SOURCE polls Qualys TRU + RHSA + USN + ZDI primary
641
+ // feeds and surfaces CVE IDs not yet in the catalog. Report-only — no
642
+ // auto-catalog mutation. Closes the post-mortem gap on CVE-2026-46333
643
+ // (ssh-keysign-pwn) where the existing NVD-based pollers lagged by 3+ days.
644
+ const { ADVISORIES_SOURCE } = require('./source-advisories');
645
+
640
646
  const ALL_SOURCES = {
641
647
  kev: KEV_SOURCE,
642
648
  epss: EPSS_SOURCE,
@@ -645,6 +651,7 @@ const ALL_SOURCES = {
645
651
  pins: PINS_SOURCE,
646
652
  ghsa: GHSA_SOURCE,
647
653
  osv: OSV_SOURCE,
654
+ advisories: ADVISORIES_SOURCE,
648
655
  };
649
656
 
650
657
  // --- Cache-mode helpers ------------------------------------------------
@@ -1066,7 +1073,16 @@ function loadCtx(opts) {
1066
1073
  // worker threads) never collide on the same scratch path.
1067
1074
  function writeJsonAtomic(p, obj) {
1068
1075
  const tmpPath = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
1069
- fs.writeFileSync(tmpPath, JSON.stringify(obj, null, 2) + "\n", "utf8");
1076
+ // v0.13.0: fsync the tmp file before rename so a power loss between
1077
+ // write and rename leaves the durable destination intact. See the
1078
+ // matching helper in lib/cve-curation.js for the rationale.
1079
+ const fd = fs.openSync(tmpPath, 'w');
1080
+ try {
1081
+ fs.writeSync(fd, JSON.stringify(obj, null, 2) + "\n", 0, "utf8");
1082
+ fs.fsyncSync(fd);
1083
+ } finally {
1084
+ fs.closeSync(fd);
1085
+ }
1070
1086
  try {
1071
1087
  fs.renameSync(tmpPath, p);
1072
1088
  } catch (err) {
package/lib/scoring.js CHANGED
@@ -346,6 +346,54 @@ function compare(cveId, catalog, opts) {
346
346
  return out;
347
347
  }
348
348
 
349
+ /**
350
+ * v0.13.0: detect rwep_factors shape. The catalog historically stored
351
+ * factors in two distinct shapes that look identical at the field level:
352
+ *
353
+ * Shape A (raw): `{ cisa_kev: true, blast_radius: 30, ... }`
354
+ * - booleans + integers in their natural form
355
+ * - score derives from `scoreCustom(factors)` which applies weights
356
+ *
357
+ * Shape B (post-weight): `{ cisa_kev: 25, blast_radius: 30, ... }`
358
+ * - integers in their post-weight contribution (cisa_kev: 25 not true)
359
+ * - score = sum of values; no second weight pass
360
+ *
361
+ * Mixing shapes inside ONE entry silently breaks the sum invariant —
362
+ * a CVE with `cisa_kev: true, blast_radius: 30` reports rwep 30 (just
363
+ * blast_radius summed) when the operator-intended score is 55 (KEV + br).
364
+ * Until v0.13 nothing caught this; v0.13 adds shape detection that fires
365
+ * an error when the entry mixes booleans with non-trivial numeric weights.
366
+ *
367
+ * Returns 'A' for raw, 'B' for post-weight, 'unknown' for empty/edge
368
+ * cases, or 'mixed' for the violating case.
369
+ */
370
+ function detectFactorShape(factors) {
371
+ if (!factors || typeof factors !== 'object') return 'unknown';
372
+ const boolFields = ['cisa_kev', 'poc_available', 'ai_assisted_weaponization', 'ai_discovered', 'active_exploitation', 'patch_available', 'live_patch_available', 'patch_required_reboot'];
373
+ let sawBool = false;
374
+ let sawWeightedInt = false;
375
+ for (const [k, v] of Object.entries(factors)) {
376
+ if (k === 'blast_radius') continue; // always integer in both shapes
377
+ if (typeof v === 'boolean' || v === null) {
378
+ sawBool = true;
379
+ } else if (typeof v === 'number' && Math.abs(v) >= 5 && boolFields.includes(k)) {
380
+ // Field that's nominally boolean carrying a numeric weight (e.g. 25,
381
+ // 20, 15) — Shape B signature.
382
+ sawWeightedInt = true;
383
+ } else if (typeof v === 'number' && (v === 0 || v === 1) && boolFields.includes(k)) {
384
+ // 0/1 on a boolean-named field could be either shape; ambiguous, ignore.
385
+ continue;
386
+ } else if (typeof v === 'string' && boolFields.includes(k)) {
387
+ // String values (e.g. active_exploitation: 'confirmed') are Shape A.
388
+ sawBool = true;
389
+ }
390
+ }
391
+ if (sawBool && sawWeightedInt) return 'mixed';
392
+ if (sawWeightedInt) return 'B';
393
+ if (sawBool) return 'A';
394
+ return 'unknown';
395
+ }
396
+
349
397
  function validate(catalog) {
350
398
  const errors = [];
351
399
  for (const [cveId, entry] of Object.entries(catalog)) {
@@ -371,6 +419,13 @@ function validate(catalog) {
371
419
  if (entry.live_patch_available && (!entry.live_patch_tools || entry.live_patch_tools.length === 0)) {
372
420
  errors.push(`${cveId}: live_patch_available=true but live_patch_tools is empty`);
373
421
  }
422
+ // v0.13.0: detect Shape A / Shape B / mixed factor shape. A 'mixed'
423
+ // shape would silently break the sum invariant; refuse it. See
424
+ // detectFactorShape() doc above for the failure mode.
425
+ const shape = detectFactorShape(entry.rwep_factors);
426
+ if (shape === 'mixed') {
427
+ errors.push(`${cveId}: rwep_factors mixes Shape A (booleans) with Shape B (post-weight integers) — sum invariant cannot hold. Convert factors to a single shape.`);
428
+ }
374
429
  const calculatedRwep = scoreCustom({
375
430
  cisa_kev: entry.cisa_kev,
376
431
  poc_available: entry.poc_available,