@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.
- package/CHANGELOG.md +124 -0
- package/bin/exceptd.js +52 -44
- package/data/_indexes/_meta.json +49 -49
- package/data/_indexes/activity-feed.json +2 -2
- package/data/_indexes/catalog-summaries.json +2 -2
- package/data/_indexes/chains.json +1531 -575
- package/data/_indexes/jurisdiction-map.json +15 -4
- package/data/_indexes/section-offsets.json +1244 -1244
- package/data/_indexes/token-budget.json +173 -173
- package/data/atlas-ttps.json +55 -11
- package/data/attack-techniques.json +124 -19
- package/data/cve-catalog.json +194 -27
- package/data/cwe-catalog.json +15 -5
- package/data/framework-control-gaps.json +32 -10
- package/data/playbooks/ai-api.json +5 -0
- package/data/playbooks/cicd-pipeline-compromise.json +970 -0
- package/data/playbooks/cloud-iam-incident.json +4 -1
- package/data/playbooks/cred-stores.json +10 -0
- package/data/playbooks/framework.json +16 -0
- package/data/playbooks/hardening.json +4 -0
- package/data/playbooks/identity-sso-compromise.json +951 -0
- package/data/playbooks/idp-incident.json +3 -0
- package/data/playbooks/kernel.json +6 -0
- package/data/playbooks/llm-tool-use-exfil.json +963 -0
- package/data/playbooks/mcp.json +6 -0
- package/data/playbooks/runtime.json +4 -0
- package/data/playbooks/sbom.json +13 -0
- package/data/playbooks/secrets.json +6 -0
- package/data/playbooks/webhook-callback-abuse.json +916 -0
- package/data/zeroday-lessons.json +178 -0
- package/lib/cross-ref-api.js +33 -13
- package/lib/cve-curation.js +12 -1
- package/lib/exit-codes.js +29 -0
- package/lib/lint-skills.js +24 -2
- package/lib/refresh-external.js +17 -1
- package/lib/scoring.js +55 -0
- package/lib/source-advisories.js +281 -0
- package/manifest.json +83 -83
- package/orchestrator/index.js +207 -24
- package/package.json +1 -1
- package/sbom.cdx.json +134 -79
- package/scripts/predeploy.js +7 -13
- package/scripts/refresh-reverse-refs.js +86 -0
- package/scripts/refresh-sbom.js +21 -4
- package/skills/age-gates-child-safety/skill.md +1 -5
- package/skills/ai-attack-surface/skill.md +11 -4
- package/skills/ai-c2-detection/skill.md +11 -2
- package/skills/ai-risk-management/skill.md +4 -2
- package/skills/api-security/skill.md +7 -8
- package/skills/attack-surface-pentest/skill.md +2 -2
- package/skills/cloud-iam-incident/skill.md +1 -5
- package/skills/cloud-security/skill.md +0 -4
- package/skills/compliance-theater/skill.md +10 -2
- package/skills/container-runtime-security/skill.md +1 -3
- package/skills/dlp-gap-analysis/skill.md +3 -4
- package/skills/email-security-anti-phishing/skill.md +1 -8
- package/skills/exploit-scoring/skill.md +7 -2
- package/skills/framework-gap-analysis/skill.md +1 -1
- package/skills/fuzz-testing-strategy/skill.md +1 -2
- package/skills/global-grc/skill.md +3 -2
- package/skills/identity-assurance/skill.md +1 -3
- package/skills/idp-incident-response/skill.md +1 -4
- package/skills/incident-response-playbook/skill.md +1 -5
- package/skills/kernel-lpe-triage/skill.md +2 -2
- package/skills/mcp-agent-trust/skill.md +13 -3
- package/skills/mlops-security/skill.md +2 -3
- package/skills/ot-ics-security/skill.md +0 -3
- package/skills/policy-exception-gen/skill.md +11 -3
- package/skills/pqc-first/skill.md +4 -2
- package/skills/rag-pipeline-security/skill.md +2 -0
- package/skills/ransomware-response/skill.md +1 -5
- package/skills/researcher/skill.md +4 -3
- package/skills/sector-energy/skill.md +0 -4
- package/skills/sector-federal-government/skill.md +2 -3
- package/skills/sector-financial/skill.md +1 -4
- package/skills/sector-healthcare/skill.md +0 -5
- package/skills/sector-telecom/skill.md +0 -4
- package/skills/security-maturity-tiers/skill.md +1 -2
- package/skills/skill-update-loop/skill.md +4 -3
- package/skills/supply-chain-integrity/skill.md +4 -3
- package/skills/threat-model-currency/skill.md +1 -1
- package/skills/threat-modeling-methodology/skill.md +2 -1
- 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
|
}
|
package/lib/cross-ref-api.js
CHANGED
|
@@ -37,56 +37,76 @@ const _cache = new Map();
|
|
|
37
37
|
// can inspect.
|
|
38
38
|
const _loadErrors = [];
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
67
|
+
const sig = _statSignature(full);
|
|
48
68
|
const cached = _cache.get(filename);
|
|
49
|
-
if (cached && (
|
|
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: {},
|
|
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,
|
|
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,
|
|
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
|
|
91
|
+
const sig = _statSignature(full);
|
|
72
92
|
const key = 'idx:' + filename;
|
|
73
93
|
const cached = _cache.get(key);
|
|
74
|
-
if (cached && (
|
|
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: {},
|
|
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,
|
|
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,
|
|
109
|
+
_cache.set(key, { value: stub, sig });
|
|
90
110
|
return stub;
|
|
91
111
|
}
|
|
92
112
|
}
|
package/lib/cve-curation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/lib/lint-skills.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/lib/refresh-external.js
CHANGED
|
@@ -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
|
-
|
|
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,
|