@blamejs/exceptd-skills 0.12.10 → 0.12.11

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 CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.11 — 2026-05-13
4
+
5
+ **Patch: OSV source hardening, indicator regex widening, CWE/framework-gap reconciliation. v0.12.10 audit closeout.**
6
+
7
+ ### OSV source hardening
8
+
9
+ `lib/source-osv.js` matures from greenfield to GHSA-parity:
10
+
11
+ - **Structured fixture-I/O error envelope.** Missing or malformed `EXCEPTD_OSV_FIXTURE` paths no longer crash with a Node stack trace; the source returns `{ok:false, error, source:"offline"}` matching the GHSA convention. Operators piping the CLI through `jq` or scripting around exit codes get a structured failure they can branch on.
12
+ - **Case-fold ids before lookup.** `fetchAdvisoryById("mal-2026-3083")` (lowercase) now resolves correctly. OSV.dev's `/v1/vulns/{id}` is case-sensitive — the source uppercases the id at entry before any branch on fixture lookup or network call.
13
+ - **Highest-CVSS-version wins + compute from vector.** `extractCvss` previously overwrote the chosen vector on every loop iteration ("last wins" not "highest-version wins") and returned `null` `score` when the OSV record carried only a vector string with no embedded numeric tail. Both fixed: explicit version-comparison via the `CVSS:N.M` prefix, and a new `cvss3BaseScore(vector)` helper that computes the CVSS 3.1 base score per FIRST §7.1 (handles Scope:U + Scope:C). MAL-* records that previously normalized to `cvss_score: null` / `active_exploitation: "unknown"` now carry computed scores.
14
+ - **GHSA-404 → OSV fallback for CVE-*.** `seedSingleAdvisory` previously routed `CVE-*` unconditionally through `source-ghsa`. When GHSA returned 404 for a CVE that had only PYSEC / RUSTSEC / SNYK / MAL coverage, the operator saw `GHSA returned HTTP 404` even though OSV had the record. Now: on GHSA-404 for a CVE-* id, retry via `source-osv.fetchAdvisoryById(id)`; surface the combined error when both 404.
15
+ - **`epss_note` on non-CVE drafts.** Non-CVE catalog keys (MAL-*, SNYK-*, RUSTSEC-*, etc.) now carry a populated `epss_note` documenting the FIRST EPSS API limitation — drafts no longer look incomplete to downstream consumers grepping for the field.
16
+ - **`verification_sources` deduped.** The canonical `osv.dev/vulnerability/<id>` URL was previously both prepended unconditionally AND pulled from `rec.references[]`. Deduped via `new Set` before return.
17
+ - **`buildDiff` error categorization.** Returns `unreachable_count` + `normalize_error_count` separately so an operator can distinguish "OSV unreachable" from "10 ids returned but none normalized cleanly."
18
+ - **`GHSA-` dropped from `OSV_ID_PREFIXES`.** The export previously listed GHSA-* even though the dispatcher unconditionally routes GHSA-* through `source-ghsa`. `isOsvId("GHSA-...")` now returns false. A top-of-file comment documents the routing decision (GHSA has richer field coverage for that namespace).
19
+ - **`OSV_HOST_OVERRIDE` env var for offline HTTP testing.** New stubbing surface — lets `tests/source-osv.test.js` spin up a local HTTP server to exercise HTTP 500 / 429 / timeout / parse-error paths previously uncovered. 429 surfaces as `rate-limited`; timeout error message clarified.
20
+ - **`seedSingleAdvisory` exported** for in-process testing.
21
+
22
+ ### Indicator regex widening
23
+
24
+ `gha-workflow-script-injection-sink` (added v0.12.10) previously anchored on `run:\s*\|` (block-scalar pipe only). Single-line `run: echo "${{ github.event.comment.body }}"` bypassed the regex despite being the same vulnerability class. Widened to `run:[\s\S]*?...` which admits both block-scalar AND single-line forms. The indicator's `confidence` drops from `deterministic` → `high` and `deterministic` flag flips to `false` to reflect the reasoning step still required for the false-positive demotion (sandboxed `pull_request` + `contents: read` permissions). `tests/gha-workflow-script-injection-sink.test.js` lands as a new end-to-end regex test with 8 fixture YAML cases covering both the catch and the FP-demotion classes. All 5 of this repo's own `.github/workflows/*.yml` files remain clean against the widened regex.
25
+
26
+ ### CWE reverse-references
27
+
28
+ The v0.12.10 catalog additions cited existing CWEs (CWE-89, CWE-77, CWE-94) without updating their reverse-reference `evidence_cves` arrays. Bidirectional linkage restored: CWE-89 now lists CVE-2026-42208 (LiteLLM SQLi), CWE-77 lists MAL-2026-3083 (elementary-data secondary classification), CWE-94 adds MAL-2026-3083 alongside the existing CVE-2025-53773 and CVE-2026-30615.
29
+
30
+ ### Framework-control-gaps key reconciliation
31
+
32
+ Eight `framework_control_gaps` keys used by the v0.12.10 catalog additions did not resolve in `data/framework-control-gaps.json`. Six reconciled to canonical existing forms: `SLSA-L3` → `SLSA-v1.0-Build-L3`; `OWASP-LLM01` → `OWASP-LLM-Top-10-2025-LLM01`; `NIST-800-218-PO.4` → `NIST-800-218-SSDF`; `NIS2-Art21-2d` / `-2g` → `NIS2-Art21-patch-management`; `NIS2-Art21-2e` → `NIS2-Art21-incident-handling`. Two genuinely-distinct citations gained new entries in the framework-gaps catalog: `EU-CRA-Art13` (essential cybersecurity requirements + technical documentation; the elementary-data class of supply-chain compromise where the maintainer is a victim) and `NIST-800-53-SI-10` (information input validation; the trust-boundary-vs-inside-boundary distinction that argument-injection / SQL-injection / prompt-injection exploit). All `framework_control_gaps` references in the catalog now resolve to a real entry.
33
+
34
+ ### Repository
35
+
36
+ - `lib/source-ghsa.js` "unrecognized id format" error message widened to enumerate the OSV-native prefixes operators can pass via `--advisory` (was previously CVE/GHSA only).
37
+ - `README.md` documents the OSV source: install command, `--advisory MAL-...` form, `EXCEPTD_OSV_FIXTURE` env var, the fresh-disclosure workflow expanded to mention OSV's coverage breadth.
38
+
39
+ Test count: 462 → 492 (+30: 18 OSV source-hardening tests + 10 indicator regex tests + 2 catalog drift assertions). Predeploy gates: 15/15. Skills: 38/38 signed and verified.
40
+
3
41
  ## 0.12.10 — 2026-05-13
4
42
 
5
43
  **Patch: OSV.dev wired as an upstream source, three new catalog entries, one new library-author indicator.**
package/README.md CHANGED
@@ -135,6 +135,7 @@ You want to refresh CVE/RFC data, run currency checks, or generate reports. Inst
135
135
  npx @blamejs/exceptd-skills doctor # health check
136
136
  npx @blamejs/exceptd-skills refresh --apply --swarm # pull KEV/NVD/EPSS/RFC/GHSA + apply
137
137
  npx @blamejs/exceptd-skills refresh --advisory CVE-2026-45321 # seed one CVE draft from GHSA
138
+ npx @blamejs/exceptd-skills refresh --advisory MAL-2026-3083 # seed via OSV (MAL-/SNYK-/RUSTSEC-/USN-/PYSEC-/GO-/MGASA-/UVI-)
138
139
  npx @blamejs/exceptd-skills refresh --curate CVE-2026-45321 # surface editorial questions for a draft
139
140
  npx @blamejs/exceptd-skills refresh --network # swap data/ from latest signed npm tarball
140
141
  ```
@@ -148,7 +149,7 @@ exceptd help
148
149
 
149
150
  Air-gapped operation: run `exceptd refresh --prefetch` on a connected host, copy the resulting `.cache/upstream/` to the airgap, run `exceptd refresh --from-cache <path> --apply` over there. The vendored upstream snapshots replace every network call.
150
151
 
151
- Fresh-disclosure workflow (v0.12.0): the nightly auto-PR job pulls KEV / NVD / EPSS / IETF / **GHSA** (added in v0.12.0). KEV typically takes days; NVD ~10 days; GHSA fires within hours of disclosure and covers npm + PyPI + Maven + Go + NuGet + …. New CVE IDs land as drafts (`_auto_imported: true`, `_draft: true`) that the catalog validator treats as warnings, not errors — operators get the fresh entry immediately, editorial review (framework gaps, IoCs, ATLAS/ATT&CK refs) follows via `exceptd refresh --curate <CVE-ID>`. For "I want this CVE today, not tomorrow": `exceptd refresh --advisory <CVE-or-GHSA-ID> --apply`.
152
+ Fresh-disclosure workflow (v0.12.0): the nightly auto-PR job pulls KEV / NVD / EPSS / IETF / **GHSA** (added in v0.12.0) / **OSV** (added in v0.12.10). KEV typically takes days; NVD ~10 days; GHSA fires within hours of disclosure and covers npm + PyPI + Maven + Go + NuGet + …; OSV aggregates the OSSF Malicious Packages dataset (`MAL-*` keys) + Snyk + RustSec + Mageia + Ubuntu USN + Go Vuln DB + PYSEC + UVI on top of GHSA — useful for malicious-package compromises that don't have CVEs yet (`exceptd refresh --advisory MAL-2026-3083`). New IDs land as drafts (`_auto_imported: true`, `_draft: true`) that the catalog validator treats as warnings, not errors — operators get the fresh entry immediately, editorial review (framework gaps, IoCs, ATLAS/ATT&CK refs) follows via `exceptd refresh --curate <ID>`. For "I want this advisory today, not tomorrow": `exceptd refresh --advisory <CVE-or-GHSA-or-MAL-or-SNYK-or-RUSTSEC-ID> --apply`.
152
153
 
153
154
  Optional env vars for higher rate budgets:
154
155
 
@@ -157,6 +158,7 @@ Optional env vars for higher rate budgets:
157
158
  | `NVD_API_KEY` | Lifts NVD 2.0 from 5 → 50 requests per 30s window. Free key at <https://nvd.nist.gov/developers/request-an-api-key>. |
158
159
  | `GITHUB_TOKEN` | Lifts GitHub Releases + GHSA from 60 → 5000 requests per hour. |
159
160
  | `EXCEPTD_GHSA_FIXTURE` | Path to a JSON fixture matching the api.github.com/advisories shape. For offline tests + air-gap workflows. |
161
+ | `EXCEPTD_OSV_FIXTURE` | Path to a JSON fixture matching the OSV schema (https://ossf.github.io/osv-schema/). For offline tests + air-gap workflows against the OSV source (added v0.12.10). |
160
162
  | `EXCEPTD_REGISTRY_FIXTURE` | Path to a JSON fixture matching the npm registry response. Used by `doctor --registry-check` + `run --upstream-check` + `refresh --network` for offline testing. |
161
163
 
162
164
  ### 3. Maintainer (extend / sign / publish)
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-13T17:30:56.669Z",
3
+ "generated_at": "2026-05-13T21:19:48.889Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 49,
6
6
  "source_hashes": {
7
- "manifest.json": "b7501793892cdfd22ede52a21ec60629d000a5a562373948dd33c1b840776189",
7
+ "manifest.json": "b7e77cd5de579732b6dd352720557c3ba2ac93f472de50f4e1f861a665a2760b",
8
8
  "data/atlas-ttps.json": "f3f75ff2778a0a2c7d953a21386bc4f265cb2685ce41242eee45f9e9f2a6add6",
9
- "data/cve-catalog.json": "e4ee5a94bfab0109c2dbd9531a1cd3ad96ce37ad4ec36523d699beace5b6d5d4",
10
- "data/cwe-catalog.json": "9d71498894a74a235d2c9dae97d062499529cb031184a4011172bf6dce9f3c3d",
9
+ "data/cve-catalog.json": "197f5313d93f0a7225d5ff275e21cbd067b3970a6f2fdc6da35f81c847e8bdee",
10
+ "data/cwe-catalog.json": "19ce1fad3ed0b0687ec9a328b2d6cd1b544eea7f19140234ec1a8467de1f908d",
11
11
  "data/d3fend-catalog.json": "d219520c8d3eb61a270b25ea60f64721035e98a8d5d51d1a4e1f1140d9a586f9",
12
12
  "data/dlp-controls.json": "8ea8d907aea0a2cfd772b048a62122a322ba3284a5c36a272ad5e9d392564cb5",
13
13
  "data/exploit-availability.json": "7dad52f459c324c40aa4df7cd9157f6a19f670fdfb9d8f687d777c9d99798668",
14
- "data/framework-control-gaps.json": "8804a10bf77e987453ea76ae717153118dc5cc625f42e98f78213b08fa144f73",
14
+ "data/framework-control-gaps.json": "9240ea4a825090fe2716947f2f6f9171c065a133ef003e04d2fbc4f01fc55bdf",
15
15
  "data/global-frameworks.json": "84fd19061f052e4ccf66308a7b8d3fd38e00325e97e9e5e19e4d9b302c128957",
16
16
  "data/rfc-references.json": "583360bae01e324d752bd28a7d344b4276478381426428d683fc82b0ac19d64a",
17
17
  "data/zeroday-lessons.json": "d670e73dfd5237ceb71a56326676d90c05387b9547f8ed6f3a60a153854b444b",
@@ -349,7 +349,7 @@
349
349
  "artifact": "data/framework-control-gaps.json",
350
350
  "path": "data/framework-control-gaps.json",
351
351
  "schema_version": "1.0.0",
352
- "entry_count": 59
352
+ "entry_count": 61
353
353
  },
354
354
  {
355
355
  "date": "2026-05-01",
@@ -150,7 +150,7 @@
150
150
  "rebuild_after_days": 365,
151
151
  "note": "Per-entry last_verified governs decay. Skills depending on this catalog must check entry freshness before high-stakes use."
152
152
  },
153
- "entry_count": 59,
153
+ "entry_count": 61,
154
154
  "sample_keys": [
155
155
  "NIST-800-53-SI-2",
156
156
  "NIST-800-53-SC-8",
@@ -2084,7 +2084,9 @@
2084
2084
  "AU-Essential-8-MFA",
2085
2085
  "AU-Essential-8-Patch",
2086
2086
  "EU-AI-Act-Art-15",
2087
+ "EU-CRA-Art13",
2087
2088
  "NIS2-Art21-incident-handling",
2089
+ "NIST-800-53-SI-10",
2088
2090
  "UK-CAF-A1",
2089
2091
  "UK-CAF-B2",
2090
2092
  "UK-CAF-C1",
@@ -708,11 +708,11 @@
708
708
  "Set npm registry cooldown: .npmrc `before=72h` (npm 11+) or `minimumReleaseAge=4320` to refuse any fresh-publish under 72 hours"
709
709
  ],
710
710
  "framework_control_gaps": {
711
- "SLSA-L3": "FIRST documented npm package shipping valid SLSA provenance while being malicious — provenance only proves WHICH pipeline built the artifact, not that the pipeline BEHAVED AS INTENDED. SLSA L3 build integrity is necessary but insufficient against cache-poisoning attacks within the build.",
711
+ "SLSA-v1.0-Build-L3": "FIRST documented npm package shipping valid SLSA provenance while being malicious — provenance only proves WHICH pipeline built the artifact, not that the pipeline BEHAVED AS INTENDED. SLSA L3 build integrity is necessary but insufficient against cache-poisoning attacks within the build.",
712
712
  "NIST-800-53-SA-12": "Supply chain protection treats provenance + signing as the trust anchor. CVE-2026-45321 demonstrates both can be intact on a malicious package.",
713
713
  "NIST-800-218-SSDF": "PS.3 + PO.3 don't address cache poisoning between sibling workflows in the same repo. SSDF presumes per-workflow trust isolation that GitHub Actions' shared actions/cache breaks.",
714
714
  "EU-CRA-Art13": "Required vulnerability handling doesn't cover the case where the upstream maintainer is unwitting — the maintainer was a victim, not a participant.",
715
- "NIS2-Art21-2d": "Supply chain risk management presumes detectable signal at consumption. Valid provenance neutralizes the standard consumer-side check.",
715
+ "NIS2-Art21-patch-management": "Supply chain risk management presumes detectable signal at consumption. Valid provenance neutralizes the standard consumer-side check.",
716
716
  "DORA-Art28": "ICT third-party risk doesn't cover transitive cache poisoning in upstream CI/CD."
717
717
  },
718
718
  "atlas_refs": [
@@ -875,11 +875,11 @@
875
875
  "GHCR :latest re-points to clean image; rebuild any image FROM elementary-data:0.23.3"
876
876
  ],
877
877
  "framework_control_gaps": {
878
- "SLSA-L3": "Same shape as CVE-2026-45321 — provenance valid, payload malicious. The publishing pipeline ran on a malicious orphan commit and emitted a legitimate signed release. SLSA-L3 attests WHICH pipeline built the artifact, not that the pipeline was driven by trusted inputs.",
878
+ "SLSA-v1.0-Build-L3": "Same shape as CVE-2026-45321 — provenance valid, payload malicious. The publishing pipeline ran on a malicious orphan commit and emitted a legitimate signed release. SLSA-L3 attests WHICH pipeline built the artifact, not that the pipeline was driven by trusted inputs.",
879
879
  "NIST-800-53-SA-12": "Supply chain protection treats signed release as the trust anchor. The signature was valid; the input to the signing pipeline was attacker-controlled.",
880
- "NIST-800-218-PO.4": "Define and use secure development security checks. Direct interpolation of github.event.* into run: scripts is a documented secure-development anti-pattern (GitHub Actions docs explicitly warn against it) but is not framework-enforced.",
880
+ "NIST-800-218-SSDF": "Define and use secure development security checks. Direct interpolation of github.event.* into run: scripts is a documented secure-development anti-pattern (GitHub Actions docs explicitly warn against it) but is not framework-enforced.",
881
881
  "EU-CRA-Art13": "Required vulnerability handling doesn't address the case where the maintainer was an unwitting publisher.",
882
- "NIS2-Art21-2d": "Supply chain risk management presumes detectable signal at consumption. Valid signature neutralizes consumer-side checks."
882
+ "NIS2-Art21-patch-management": "Supply chain risk management presumes detectable signal at consumption. Valid signature neutralizes consumer-side checks."
883
883
  },
884
884
  "atlas_refs": [
885
885
  "AML.T0010",
@@ -1026,8 +1026,8 @@
1026
1026
  ],
1027
1027
  "framework_control_gaps": {
1028
1028
  "NIST-800-53-SI-10": "Input validation control doesn't address argument-vs-statement distinction in SQL libraries. SI-10 is satisfied by 'we validate inputs' regardless of whether the validation runs before the SQL parameter binding.",
1029
- "OWASP-LLM01": "Prompt injection control set doesn't address the AI-PROXY backend SQL surface — LiteLLM is the substrate that gates LLM API access, not the LLM itself.",
1030
- "NIS2-Art21-2e": "Cryptographic measures control doesn't address application-layer SQL injection.",
1029
+ "OWASP-LLM-Top-10-2025-LLM01": "Prompt injection control set doesn't address the AI-PROXY backend SQL surface — LiteLLM is the substrate that gates LLM API access, not the LLM itself.",
1030
+ "NIS2-Art21-incident-handling": "Cryptographic measures control doesn't address application-layer SQL injection.",
1031
1031
  "EU-AI-Act-Art-15": "Robustness + cybersecurity requirement is undefined operationally for AI gateway infrastructure."
1032
1032
  },
1033
1033
  "atlas_refs": [
@@ -1126,8 +1126,8 @@
1126
1126
  ],
1127
1127
  "framework_control_gaps": {
1128
1128
  "NIST-800-53-SI-10": "Input validation control doesn't address the argv-vs-string boundary that argument injection exploits — many MCP servers concatenate user input into shell commands without registering this as a code-review failure.",
1129
- "OWASP-LLM01": "Prompt-injection-as-access-control gap — the attacker doesn't compromise the MCP server directly; they feed adversarial input that the AI passes through.",
1130
- "NIS2-Art21-2g": "Patch management presumes traditional CVE timelines; MCP plugin ecosystem patch awareness lags."
1129
+ "OWASP-LLM-Top-10-2025-LLM01": "Prompt-injection-as-access-control gap — the attacker doesn't compromise the MCP server directly; they feed adversarial input that the AI passes through.",
1130
+ "NIS2-Art21-patch-management": "Patch management presumes traditional CVE timelines; MCP plugin ecosystem patch awareness lags."
1131
1131
  },
1132
1132
  "atlas_refs": [
1133
1133
  "AML.T0053",
@@ -115,7 +115,7 @@
115
115
  "skills_referencing": [
116
116
  "exploit-scoring"
117
117
  ],
118
- "evidence_cves": [],
118
+ "evidence_cves": ["CVE-2026-42208"],
119
119
  "framework_controls_partially_addressing": [
120
120
  "NIST-800-53-SI-10",
121
121
  "ISO-27001-2022-A.8.28",
@@ -247,7 +247,8 @@
247
247
  ],
248
248
  "evidence_cves": [
249
249
  "CVE-2025-53773",
250
- "CVE-2026-30615"
250
+ "CVE-2026-30615",
251
+ "MAL-2026-3083"
251
252
  ],
252
253
  "framework_controls_partially_addressing": [
253
254
  "NIST-800-53-SI-10",
@@ -392,7 +393,7 @@
392
393
  "mcp-agent-trust",
393
394
  "ai-attack-surface"
394
395
  ],
395
- "evidence_cves": [],
396
+ "evidence_cves": ["MAL-2026-3083"],
396
397
  "framework_controls_partially_addressing": [
397
398
  "NIST-800-53-SI-10",
398
399
  "ISO-27001-2022-A.8.28"
@@ -1494,5 +1494,57 @@
1494
1494
  "AML.T0048"
1495
1495
  ],
1496
1496
  "attack_refs": []
1497
+ },
1498
+ "EU-CRA-Art13": {
1499
+ "framework": "EU Cyber Resilience Act (2024/2847)",
1500
+ "control_id": "Art. 13",
1501
+ "control_name": "Essential cybersecurity requirements + technical documentation",
1502
+ "designed_for": "Manufacturers placing products with digital elements on the EU market; sets the essential cybersecurity requirements (Annex I) and the technical-documentation duty",
1503
+ "misses": [
1504
+ "Vulnerability handling clauses presume the maintainer is aware of the vulnerability and able to remediate. The elementary-data PyPI worm (MAL-2026-3083) compromised the publishing pipeline — the maintainer was a victim, not a participant — and the published release carried a valid signature.",
1505
+ "'Technical documentation' obligations do not require the manufacturer to retain or publish the build-pipeline configuration that produced each release. Operators consuming a malicious release have no way to inspect the workflow that built it.",
1506
+ "Art. 14 (24-hour notification of actively-exploited vulnerabilities) clock starts from manufacturer awareness; supply-chain-victim manufacturers may not know they are exploited until consumer-side detection (StepSecurity / Snyk / OSV) surfaces the IoCs."
1507
+ ],
1508
+ "real_requirement": "Manufacturer publishes the canonical build-pipeline definition alongside each release (workflow file hash, runner attestation, scope of secrets accessed). Operators verify the published pipeline matches the pipeline that produced the release-being-installed. Notification clock starts from FIRST awareness — manufacturer's OR competent-authority's OR widely-published security researcher's.",
1509
+ "status": "open",
1510
+ "opened_date": "2026-05-13",
1511
+ "evidence_cves": [
1512
+ "MAL-2026-3083",
1513
+ "CVE-2025-53773"
1514
+ ],
1515
+ "atlas_refs": [
1516
+ "AML.T0010",
1517
+ "AML.T0055"
1518
+ ],
1519
+ "attack_refs": [
1520
+ "T1195.001",
1521
+ "T1195.002"
1522
+ ]
1523
+ },
1524
+ "NIST-800-53-SI-10": {
1525
+ "framework": "NIST SP 800-53 Rev 5",
1526
+ "control_id": "SI-10",
1527
+ "control_name": "Information Input Validation",
1528
+ "designed_for": "Validating untrusted input at system boundaries before consumption by downstream code paths",
1529
+ "misses": [
1530
+ "Treats 'input validation' as a single layer at the trust boundary. Modern injection classes (SQL, argument, command, prompt) live INSIDE the trust boundary — the input is already 'validated' as authentic but the consumer concatenates it into a syntax the original validator did not anticipate (SQL query, kubectl argv, shell command).",
1531
+ "Does not distinguish argv-array vs string-form invocation. CVE-2026-39884 (mcp-server-kubernetes argument injection) and the broader CWE-88 class are invisible to a SI-10-compliant codebase that 'validates' the user-input string for length and character class.",
1532
+ "Does not address parameterised-query vs string-concat distinction. CVE-2026-42208 (LiteLLM SQLi on CISA KEV) is the cardinal recent example — input was validated, then concatenated into SQL during error-handling, which the validator did not gate.",
1533
+ "Auditing for SI-10 typically samples function boundaries; the argument-injection / SQL-injection / prompt-injection failure modes all occur inside the boundary."
1534
+ ],
1535
+ "real_requirement": "Per-injection-class structural controls in addition to boundary validation. Parameterised queries enforced at the ORM/driver level (CWE-89). Argv-array form for spawned subprocesses (CWE-88). Tool-arg / function-call sanitisation in MCP / AI-agent surfaces (CWE-94). Lint rules flagging string-concat into SQL, exec, or AI-tool arguments. SI-10 compliance attestation augmented with a per-class checklist that names the specific structural control.",
1536
+ "status": "open",
1537
+ "opened_date": "2026-05-13",
1538
+ "evidence_cves": [
1539
+ "CVE-2026-42208",
1540
+ "CVE-2026-39884"
1541
+ ],
1542
+ "atlas_refs": [
1543
+ "AML.T0053"
1544
+ ],
1545
+ "attack_refs": [
1546
+ "T1190",
1547
+ "T1059"
1548
+ ]
1497
1549
  }
1498
1550
  }
@@ -760,10 +760,10 @@
760
760
  {
761
761
  "id": "gha-workflow-script-injection-sink",
762
762
  "type": "file_path",
763
- "value": "Within the release-workflows artifact (any file under .github/workflows/*.yml): a `run:` shell script body directly interpolates an attacker-controllable github.event field — ${{ github.event.comment.body }}, ${{ github.event.issue.body }}, ${{ github.event.issue.title }}, ${{ github.event.pull_request.body }}, ${{ github.event.pull_request.title }}, ${{ github.event.review.body }}, ${{ github.event.head_commit.message }}, ${{ github.head_ref }}, ${{ github.event.discussion.body }}, ${{ github.event.discussion.title }} — without first capturing the value into an env: variable. Grep regex (multi-line YAML aware): `run:\\s*\\|[\\s\\S]*?\\$\\{\\{\\s*github\\.(event\\.(comment|issue|pull_request|review|head_commit|discussion)\\.|head_ref)`. Corroborate via the branch-tag-protection artifact: if any workflow with this sink also triggers on `pull_request_target` / `issue_comment` / `pull_request_review_comment` AND its job has `permissions: contents: write` (or unrestricted GITHUB_TOKEN), the sink is exploitable by any GitHub user who can comment on the repo.",
763
+ "value": "Within the release-workflows artifact (any file under .github/workflows/*.yml): a `run:` shell block-scalar (`run: |`) OR single-line (`run: <command>`) — interpolates an attacker-controllable github.event field — ${{ github.event.comment.body }}, ${{ github.event.issue.body }}, ${{ github.event.issue.title }}, ${{ github.event.pull_request.body }}, ${{ github.event.pull_request.title }}, ${{ github.event.review.body }}, ${{ github.event.head_commit.message }}, ${{ github.head_ref }}, ${{ github.event.discussion.body }}, ${{ github.event.discussion.title }} — without first capturing the value into an env: variable. Grep regex (multi-line YAML aware, matches both block-scalar and single-line run: shapes): `run:[\\s\\S]*?\\$\\{\\{\\s*github\\.(event\\.(comment|issue|pull_request|review|head_commit|discussion)\\.|head_ref)`. Corroborate via the branch-tag-protection artifact: if any workflow with this sink also triggers on `pull_request_target` / `issue_comment` / `pull_request_review_comment` AND its job has `permissions: contents: write` (or unrestricted GITHUB_TOKEN), the sink is exploitable by any GitHub user who can comment on the repo.",
764
764
  "description": "GitHub Actions script-injection sink. Elementary-data 0.23.3 (April 2026) was forged via this exact pattern — `${{ github.event.comment.body }}` interpolated into a `run:` block in update_pylon_issue.yml, escalated via the workflow's GITHUB_TOKEN to publish a malicious release. Without this indicator, a publisher account compromise via attacker-controlled comments looks identical to a clean release at the consumer side.",
765
- "confidence": "deterministic",
766
- "deterministic": true,
765
+ "confidence": "high",
766
+ "deterministic": false,
767
767
  "false_positive_checks_required": [
768
768
  "If the run: block reads the github.event field via an `env:` variable first (env: COMMENT_BODY: ${{ github.event.comment.body }}) and then references $COMMENT_BODY in the shell — that is the documented-safe pattern; demote to miss.",
769
769
  "If the workflow only runs in a sandboxed `pull_request` event (not `pull_request_target`) AND has default `permissions: contents: read` AND does not use secrets.* — the sink is not exploitable; demote to miss."
@@ -125,10 +125,14 @@ Modes:
125
125
  --swarm fan out sources across worker threads. Best with --from-cache.
126
126
  --advisory <id> (v0.12.0) seed a single catalog entry from an advisory ID.
127
127
  CVE-* and GHSA-* route through the GitHub Advisory
128
- Database. MAL-*, SNYK-*, RUSTSEC-*, USN-*, UVI-*, GO-*,
129
- MGASA-*, PYSEC-*, and other OSV-native namespaces route
130
- through OSV.dev (v0.12.10). Writes a DRAFT to
131
- data/cve-catalog.json marked with _auto_imported: true.
128
+ Database. When GHSA returns 404 for a CVE-* id
129
+ (CNAs / OSV mirrors operate on different cadences) the
130
+ dispatcher falls back to OSV.dev's /v1/vulns/{id}
131
+ before failing (v0.12.11). MAL-*, SNYK-*, RUSTSEC-*,
132
+ USN-*, UVI-*, GO-*, MGASA-*, PYSEC-*, and other
133
+ OSV-native namespaces route through OSV.dev (v0.12.10).
134
+ Writes a DRAFT to data/cve-catalog.json marked with
135
+ _auto_imported: true.
132
136
  Editorial fields (framework_control_gaps, iocs,
133
137
  atlas_refs, attack_refs) remain null pending review via:
134
138
  exceptd run cve-curation --advisory <id>
@@ -861,7 +865,27 @@ async function seedSingleAdvisory(opts) {
861
865
  const sourceName = useOsv ? "osv" : "ghsa";
862
866
  const fixtureEnv = useOsv ? "EXCEPTD_OSV_FIXTURE" : "EXCEPTD_GHSA_FIXTURE";
863
867
 
864
- const result = await sourceMod.fetchAdvisoryById(id, {});
868
+ let result = await sourceMod.fetchAdvisoryById(id, {});
869
+ // F4 (v0.12.11): CVE-* identifiers may have an OSV record before GHSA
870
+ // publishes one (CNAs and OSV mirrors operate on different cadences).
871
+ // When GHSA returns 404 specifically, retry through OSV's /v1/vulns/{id}
872
+ // — OSV indexes CVE ids as primary keys. If both 404, surface a combined
873
+ // error message so operators know both sources were tried before failing.
874
+ let fallbackSourceUsed = null;
875
+ if (!result.ok && !useOsv && /^CVE-/i.test(id) && /HTTP 404/.test(result.error || "")) {
876
+ const fallback = await osvMod.fetchAdvisoryById(id, {});
877
+ if (fallback.ok) {
878
+ result = fallback;
879
+ fallbackSourceUsed = "osv";
880
+ } else if (/HTTP 404/.test(fallback.error || "") || /not in fixture/.test(fallback.error || "")) {
881
+ // Both sources tried, both 404 — combine the error message.
882
+ const combined = { ok: false, verb: "refresh", error: `--advisory ${id}: not found in GHSA or OSV (GHSA: ${result.error}; OSV: ${fallback.error})`, source: "offline", routed_to: "ghsa+osv", hint: `Both GHSA and OSV.dev returned 404 for ${id}. Verify the CVE id (CVE-YYYY-NNNN) and that an advisory record exists upstream.` };
883
+ if (opts.json) process.stdout.write(JSON.stringify(combined) + "\n");
884
+ else process.stderr.write(`[refresh --advisory] ${combined.error}\n hint: ${combined.hint}\n`);
885
+ process.exitCode = 2;
886
+ return;
887
+ }
888
+ }
865
889
  if (!result.ok) {
866
890
  const err = { ok: false, verb: "refresh", error: `--advisory ${id}: ${result.error}`, source: result.source, routed_to: sourceName, hint: `Verify the ID format (CVE-YYYY-NNNN, GHSA-*, MAL-*, SNYK-*, RUSTSEC-*, USN-*, etc.) and network reachability. Set ${fixtureEnv} for offline testing.` };
867
891
  if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
@@ -869,17 +893,21 @@ async function seedSingleAdvisory(opts) {
869
893
  process.exitCode = 2;
870
894
  return;
871
895
  }
896
+ // If the OSV fallback fired, normalize/route through the OSV module from
897
+ // here on — the advisory shape is OSV's, not GHSA's.
898
+ const effectiveMod = fallbackSourceUsed === "osv" ? osvMod : sourceMod;
899
+ const effectiveName = fallbackSourceUsed === "osv" ? "osv" : sourceName;
872
900
  const advisory = result.advisories[0];
873
901
  if (!advisory) {
874
- const err = { ok: false, verb: "refresh", error: `--advisory ${id}: no matching advisory found`, source: result.source, routed_to: sourceName };
902
+ const err = { ok: false, verb: "refresh", error: `--advisory ${id}: no matching advisory found`, source: result.source, routed_to: effectiveName };
875
903
  if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
876
904
  else process.stderr.write(`[refresh --advisory] ${err.error}\n`);
877
905
  process.exitCode = 2;
878
906
  return;
879
907
  }
880
- const normalized = sourceMod.normalizeAdvisory(advisory);
908
+ const normalized = effectiveMod.normalizeAdvisory(advisory);
881
909
  if (!normalized) {
882
- const err = { ok: false, verb: "refresh", error: `--advisory ${id}: advisory could not be normalized (missing required fields)`, routed_to: sourceName, source_id: advisory.ghsa_id || advisory.id || null };
910
+ const err = { ok: false, verb: "refresh", error: `--advisory ${id}: advisory could not be normalized (missing required fields)`, routed_to: effectiveName, source_id: advisory.ghsa_id || advisory.id || null };
883
911
  if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
884
912
  else process.stderr.write(`[refresh --advisory] ${err.error}\n`);
885
913
  process.exitCode = 2;
@@ -1063,4 +1091,4 @@ if (require.main === module) {
1063
1091
  });
1064
1092
  }
1065
1093
 
1066
- module.exports = { ALL_SOURCES, loadCtx, parseArgs };
1094
+ module.exports = { ALL_SOURCES, loadCtx, parseArgs, seedSingleAdvisory };
@@ -119,7 +119,13 @@ async function fetchAdvisoryById(id, opts = {}) {
119
119
  if (/^CVE-\d{4}-\d+$/i.test(id)) {
120
120
  return fetchAdvisories({ ...opts, path: `/advisories?cve_id=${encodeURIComponent(id.toUpperCase())}` });
121
121
  }
122
- return { ok: false, error: `unrecognized id format (expected CVE-YYYY-NNNN or GHSA-*): ${id}`, source: "offline" };
122
+ // v0.12.11: widen the error to enumerate OSV-native prefixes operators
123
+ // running `exceptd refresh --advisory FOO-BAR` previously got an error
124
+ // mentioning only CVE / GHSA, even though MAL-*, SNYK-*, RUSTSEC-*,
125
+ // USN-*, PYSEC-*, GO-*, MGASA-*, UVI- are also valid id shapes routed
126
+ // through source-osv. The hint here mirrors lib/refresh-external.js
127
+ // seedSingleAdvisory's documented acceptance set.
128
+ return { ok: false, error: `unrecognized id format: ${id}. Expected one of: CVE-YYYY-NNNN, GHSA-* (routed through source-ghsa); MAL-* / SNYK-* / RUSTSEC-* / USN-* / PYSEC-* / GO-* / MGASA-* / UVI- (routed through source-osv).`, source: "offline" };
123
129
  }
124
130
 
125
131
  /**
package/lib/source-osv.js CHANGED
@@ -39,17 +39,24 @@
39
39
  const https = require("https");
40
40
  const fs = require("fs");
41
41
 
42
+ // OSV_HOST_OVERRIDE lets tests redirect the network call to a local HTTP
43
+ // server bound on 127.0.0.1:<port>. The override accepts either a bare
44
+ // `host:port` string or a full `http://host:port` URL. When set, the
45
+ // underlying request switches from `https` to `http` so the test server
46
+ // doesn't need a TLS cert. Production callers never set this.
42
47
  const OSV_HOST = "api.osv.dev";
43
48
  const REQUEST_TIMEOUT_MS = 10000;
44
49
  const USER_AGENT = "exceptd-security/source-osv (+https://exceptd.com)";
45
50
 
46
- // Identifier namespaces OSV uses as PRIMARY keys (i.e. that route through
47
- // this module rather than GHSA's CVE-search path). Keep this list in sync
48
- // with the dispatcher in lib/refresh-external.js adding a new prefix
49
- // here is not enough; the dispatcher's --advisory regex must also accept it.
51
+ // Identifier namespaces OSV uses as PRIMARY keys. GHSA-* is intentionally
52
+ // NOT in this list `seedSingleAdvisory` in lib/refresh-external.js routes
53
+ // CVE-* and GHSA-* through `source-ghsa` because GHSA carries richer field
54
+ // coverage (cvss object, vulnerable_version_range string, ghsa_id linkage)
55
+ // than OSV's import of the same advisories. Keep this list in sync with the
56
+ // dispatcher in lib/refresh-external.js — adding a new prefix here is not
57
+ // enough; the dispatcher's --advisory regex must also accept it.
50
58
  const OSV_ID_PREFIXES = [
51
59
  "MAL-", // OSSF Malicious Packages
52
- "GHSA-", // GitHub Security Advisories (OSV import)
53
60
  "SNYK-", // Snyk
54
61
  "RUSTSEC-", // RustSec
55
62
  "GO-", // Go vuln DB
@@ -72,24 +79,47 @@ const OSV_ID_PREFIXES = [
72
79
 
73
80
  /**
74
81
  * Return true when `id` looks like an OSV-native primary key (i.e. NOT a
75
- * CVE-* identifier). CVE-* identifiers continue to route through the GHSA
76
- * source because GHSA carries richer field coverage for CVE-keyed records.
82
+ * CVE-* identifier and NOT a GHSA-* identifier). Both CVE-* and GHSA-*
83
+ * route through `source-ghsa` for richer field coverage.
77
84
  */
78
85
  function isOsvId(id) {
79
86
  if (!id || typeof id !== "string") return false;
80
87
  const up = id.toUpperCase();
81
88
  if (/^CVE-\d{4}-\d+$/.test(up)) return false;
89
+ if (up.startsWith("GHSA-")) return false;
82
90
  return OSV_ID_PREFIXES.some((p) => up.startsWith(p));
83
91
  }
84
92
 
85
93
  /**
86
- * Low-level HTTPS GET against OSV. Resolves to { ok, record|error, source }.
94
+ * Resolve the OSV transport target. When OSV_HOST_OVERRIDE is set the
95
+ * request switches to plain HTTP on the override host:port so test
96
+ * harnesses can stand up a local server without TLS. Production omits the
97
+ * override entirely and lands on api.osv.dev over HTTPS.
87
98
  */
88
- function osvGet(path, timeoutMs = REQUEST_TIMEOUT_MS) {
99
+ function osvTransport() {
100
+ const override = process.env.OSV_HOST_OVERRIDE;
101
+ if (!override) return { mod: https, host: OSV_HOST, port: 443 };
102
+ // Accept either "host:port" or a full URL.
103
+ let raw = override.trim();
104
+ if (/^https?:\/\//i.test(raw)) {
105
+ const u = new URL(raw);
106
+ return { mod: require("http"), host: u.hostname, port: parseInt(u.port, 10) || 80 };
107
+ }
108
+ const [h, p] = raw.split(":");
109
+ return { mod: require("http"), host: h || "127.0.0.1", port: parseInt(p, 10) || 80 };
110
+ }
111
+
112
+ /**
113
+ * Low-level GET against OSV. Resolves to { ok, record|error, source }.
114
+ * Honors OSV_HOST_OVERRIDE for offline tests.
115
+ */
116
+ function osvGet(reqPath, timeoutMs = REQUEST_TIMEOUT_MS) {
89
117
  return new Promise((resolve) => {
90
- const req = https.get({
91
- host: OSV_HOST,
92
- path,
118
+ const { mod, host, port } = osvTransport();
119
+ const req = mod.get({
120
+ host,
121
+ port,
122
+ path: reqPath,
93
123
  headers: {
94
124
  "Accept": "application/json",
95
125
  "User-Agent": USER_AGENT,
@@ -98,7 +128,11 @@ function osvGet(path, timeoutMs = REQUEST_TIMEOUT_MS) {
98
128
  }, (res) => {
99
129
  if (res.statusCode !== 200) {
100
130
  res.resume();
101
- return resolve({ ok: false, error: `OSV returned HTTP ${res.statusCode}`, source: "offline" });
131
+ const status = res.statusCode;
132
+ const error = status === 429
133
+ ? `OSV rate-limited (HTTP 429)`
134
+ : `OSV returned HTTP ${status}`;
135
+ return resolve({ ok: false, error, status, source: "offline" });
102
136
  }
103
137
  const chunks = [];
104
138
  res.on("data", (c) => chunks.push(c));
@@ -111,20 +145,22 @@ function osvGet(path, timeoutMs = REQUEST_TIMEOUT_MS) {
111
145
  }
112
146
  });
113
147
  });
114
- req.on("timeout", () => req.destroy(new Error("timeout")));
148
+ req.on("timeout", () => req.destroy(new Error("OSV request timed out")));
115
149
  req.on("error", (e) => resolve({ ok: false, error: e.message, source: "offline" }));
116
150
  });
117
151
  }
118
152
 
119
153
  /**
120
- * Low-level HTTPS POST against OSV. Body is JSON-stringified.
154
+ * Low-level POST against OSV. Body is JSON-stringified.
121
155
  */
122
- function osvPost(path, body, timeoutMs = REQUEST_TIMEOUT_MS) {
156
+ function osvPost(reqPath, body, timeoutMs = REQUEST_TIMEOUT_MS) {
123
157
  return new Promise((resolve) => {
124
158
  const payload = Buffer.from(JSON.stringify(body), "utf8");
125
- const req = https.request({
126
- host: OSV_HOST,
127
- path,
159
+ const { mod, host, port } = osvTransport();
160
+ const req = mod.request({
161
+ host,
162
+ port,
163
+ path: reqPath,
128
164
  method: "POST",
129
165
  headers: {
130
166
  "Content-Type": "application/json",
@@ -136,7 +172,11 @@ function osvPost(path, body, timeoutMs = REQUEST_TIMEOUT_MS) {
136
172
  }, (res) => {
137
173
  if (res.statusCode !== 200) {
138
174
  res.resume();
139
- return resolve({ ok: false, error: `OSV returned HTTP ${res.statusCode}`, source: "offline" });
175
+ const status = res.statusCode;
176
+ const error = status === 429
177
+ ? `OSV rate-limited (HTTP 429)`
178
+ : `OSV returned HTTP ${status}`;
179
+ return resolve({ ok: false, error, status, source: "offline" });
140
180
  }
141
181
  const chunks = [];
142
182
  res.on("data", (c) => chunks.push(c));
@@ -149,7 +189,7 @@ function osvPost(path, body, timeoutMs = REQUEST_TIMEOUT_MS) {
149
189
  }
150
190
  });
151
191
  });
152
- req.on("timeout", () => req.destroy(new Error("timeout")));
192
+ req.on("timeout", () => req.destroy(new Error("OSV request timed out")));
153
193
  req.on("error", (e) => resolve({ ok: false, error: e.message, source: "offline" }));
154
194
  req.write(payload);
155
195
  req.end();
@@ -157,14 +197,36 @@ function osvPost(path, body, timeoutMs = REQUEST_TIMEOUT_MS) {
157
197
  }
158
198
 
159
199
  /**
160
- * Read EXCEPTD_OSV_FIXTURE and return an array of OSV records. Accepts
161
- * either a single object or an array on disk.
200
+ * Read EXCEPTD_OSV_FIXTURE and return a structured envelope. Matches the
201
+ * GHSA-source convention: on any failure (missing file, malformed JSON,
202
+ * root not object/array) return `{ ok: false, error, source: "offline" }`
203
+ * rather than throw — operators on the CLI surface get a structured error
204
+ * instead of a Node stack trace.
205
+ *
206
+ * Returns:
207
+ * null when env var is unset
208
+ * { ok: true, advisories: [...], source } on success
209
+ * { ok: false, error, source: "offline" } on any failure
162
210
  */
163
211
  function readFixture() {
164
212
  const fp = process.env.EXCEPTD_OSV_FIXTURE;
165
213
  if (!fp) return null;
166
- const raw = JSON.parse(fs.readFileSync(fp, "utf8"));
167
- return Array.isArray(raw) ? raw : [raw];
214
+ let raw;
215
+ try {
216
+ raw = fs.readFileSync(fp, "utf8");
217
+ } catch (e) {
218
+ return { ok: false, error: `fixture: ${e.message}`, source: "offline" };
219
+ }
220
+ let parsed;
221
+ try {
222
+ parsed = JSON.parse(raw);
223
+ } catch (e) {
224
+ return { ok: false, error: `fixture: ${e.message}`, source: "offline" };
225
+ }
226
+ if (parsed == null || (typeof parsed !== "object")) {
227
+ return { ok: false, error: `fixture: root must be an OSV record object or array (got ${typeof parsed})`, source: "offline" };
228
+ }
229
+ return { ok: true, advisories: Array.isArray(parsed) ? parsed : [parsed], source: "fixture" };
168
230
  }
169
231
 
170
232
  /**
@@ -176,12 +238,19 @@ function readFixture() {
176
238
  */
177
239
  async function fetchAdvisoryById(id, opts = {}) {
178
240
  if (!id || typeof id !== "string") {
179
- return { ok: false, error: "id is required (MAL-*, GHSA-*, SNYK-*, etc.)", source: "offline" };
241
+ return { ok: false, error: "id is required (MAL-*, SNYK-*, RUSTSEC-*, etc.)", source: "offline" };
180
242
  }
243
+ // OSV.dev's /v1/vulns/{id} is case-sensitive — `mal-2026-3083` 404s while
244
+ // `MAL-2026-3083` resolves. Uppercase at entry so operators piping
245
+ // lowercase ids from grep/jq don't get a surprising 404 from the network
246
+ // path. Fixture lookup already case-folds, so this normalization is a
247
+ // no-op there but harmless.
248
+ id = id.toUpperCase();
181
249
  const fixture = readFixture();
182
250
  if (fixture) {
183
- const want = id.toUpperCase();
184
- const match = fixture.find((rec) => {
251
+ if (!fixture.ok) return fixture; // F1: structured error envelope
252
+ const want = id;
253
+ const match = fixture.advisories.find((rec) => {
185
254
  const recId = (rec && rec.id) ? String(rec.id).toUpperCase() : null;
186
255
  if (recId === want) return true;
187
256
  const aliases = Array.isArray(rec?.aliases) ? rec.aliases.map((a) => String(a).toUpperCase()) : [];
@@ -205,9 +274,10 @@ async function fetchAdvisoriesForPackage(name, ecosystem, version, opts = {}) {
205
274
  }
206
275
  const fixture = readFixture();
207
276
  if (fixture) {
277
+ if (!fixture.ok) return fixture; // F1: structured error envelope
208
278
  // Best-effort fixture filtering: match any record whose `affected[]`
209
279
  // contains the requested package + ecosystem (+ version when set).
210
- const matches = fixture.filter((rec) => {
280
+ const matches = fixture.advisories.filter((rec) => {
211
281
  const affected = Array.isArray(rec?.affected) ? rec.affected : [];
212
282
  return affected.some((a) => {
213
283
  const pkg = a?.package || {};
@@ -241,37 +311,112 @@ function pickCatalogKey(rec) {
241
311
  }
242
312
 
243
313
  /**
244
- * Pull a numeric CVSS score out of an OSV severity[] entry (CVSS v3 / v4
245
- * vector strings start with "CVSS:3.x/" or "CVSS:4.0/"). Returns null if
246
- * no parseable score is present.
314
+ * CVSS 3.1 base-score computation from a vector string. Implements Table 6
315
+ * of the FIRST CVSS 3.1 specification. Used when an OSV record carries a
316
+ * vector but no embedded numeric score (the common case for MAL-* records).
317
+ * Returns null on malformed input.
318
+ *
319
+ * Reference: https://www.first.org/cvss/v3.1/specification-document
320
+ */
321
+ function cvss3BaseScore(vector) {
322
+ if (typeof vector !== "string") return null;
323
+ const m = vector.match(/^CVSS:3\.\d\/(.+)$/);
324
+ if (!m) return null;
325
+ const parts = m[1].split("/");
326
+ const metrics = {};
327
+ for (const p of parts) {
328
+ const [k, v] = p.split(":");
329
+ if (!k || !v) return null;
330
+ metrics[k] = v;
331
+ }
332
+ // Required metrics — bail if any are missing.
333
+ for (const k of ["AV", "AC", "PR", "UI", "S", "C", "I", "A"]) {
334
+ if (!metrics[k]) return null;
335
+ }
336
+ const AV_W = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 };
337
+ const AC_W = { L: 0.77, H: 0.44 };
338
+ const UI_W = { N: 0.85, R: 0.62 };
339
+ const CIA_W = { H: 0.56, L: 0.22, N: 0 };
340
+ // PR weights depend on Scope.
341
+ const PR_W_U = { N: 0.85, L: 0.62, H: 0.27 };
342
+ const PR_W_C = { N: 0.85, L: 0.68, H: 0.5 };
343
+ const scope = metrics.S;
344
+ if (scope !== "U" && scope !== "C") return null;
345
+ const av = AV_W[metrics.AV];
346
+ const ac = AC_W[metrics.AC];
347
+ const ui = UI_W[metrics.UI];
348
+ const pr = (scope === "C" ? PR_W_C : PR_W_U)[metrics.PR];
349
+ const c = CIA_W[metrics.C];
350
+ const i = CIA_W[metrics.I];
351
+ const a = CIA_W[metrics.A];
352
+ if ([av, ac, ui, pr, c, i, a].some((x) => x == null)) return null;
353
+ const iss = 1 - ((1 - c) * (1 - i) * (1 - a));
354
+ let impact;
355
+ if (scope === "U") {
356
+ impact = 6.42 * iss;
357
+ } else {
358
+ impact = 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15);
359
+ }
360
+ if (impact <= 0) return 0.0;
361
+ const exploitability = 8.22 * av * ac * pr * ui;
362
+ let base;
363
+ if (scope === "U") {
364
+ base = Math.min(impact + exploitability, 10);
365
+ } else {
366
+ base = Math.min(1.08 * (impact + exploitability), 10);
367
+ }
368
+ // roundUp1: round up to one decimal (CVSS 3.1 §7.1).
369
+ const rounded = Math.ceil(base * 10) / 10;
370
+ if (!Number.isFinite(rounded) || rounded < 0 || rounded > 10) return null;
371
+ return rounded;
372
+ }
373
+
374
+ /**
375
+ * Pull a numeric CVSS score + vector out of an OSV severity[] entry. CVSS
376
+ * vectors start with "CVSS:3.x/" or "CVSS:4.0/". When multiple vectors are
377
+ * present (e.g. both V3 and V4), the highest version wins regardless of
378
+ * array order. When the OSV record has no embedded numeric tail, the score
379
+ * is computed from the vector itself via cvss3BaseScore(). Returns null
380
+ * components when nothing parseable is present.
247
381
  */
248
382
  function extractCvss(rec) {
249
383
  const sev = Array.isArray(rec?.severity) ? rec.severity : [];
250
384
  let score = null;
251
- let vector = null;
385
+ let bestVector = null;
386
+ let bestVersion = 0;
252
387
  for (const s of sev) {
253
388
  if (typeof s?.score !== "string") continue;
254
389
  const v = s.score.trim();
255
- // Bare numeric score
390
+ // Bare numeric score (no vector prefix).
256
391
  const num = parseFloat(v);
257
392
  if (!Number.isNaN(num) && num >= 0 && num <= 10 && !v.includes("/")) {
258
393
  if (score == null) score = num;
259
394
  continue;
260
395
  }
261
- // CVSS vector — accept the highest-version vector we see.
262
- if (/^CVSS:[34]/.test(v)) {
263
- vector = v;
264
- // Try to parse the score out of the trailing fragment if encoded
265
- // as "CVSS:3.1/AV:.../9.3" — most OSV records don't embed it here,
266
- // but some Snyk-imported records do.
267
- const m = v.match(/\/(\d+(?:\.\d+)?)$/);
268
- if (m && score == null) {
269
- const candidate = parseFloat(m[1]);
270
- if (candidate >= 0 && candidate <= 10) score = candidate;
271
- }
396
+ const m = v.match(/^CVSS:(\d+\.\d+)/);
397
+ if (!m) continue;
398
+ const ver = parseFloat(m[1]);
399
+ if (ver > bestVersion) {
400
+ bestVersion = ver;
401
+ bestVector = v;
272
402
  }
273
403
  }
274
- return { score, vector };
404
+ // If we picked a vector, try to read an embedded score from the trailing
405
+ // fragment (some Snyk records carry it as ".../9.3"). Otherwise compute
406
+ // it from the vector for CVSS 3.x. CVSS 4.0 base-score derivation is
407
+ // intentionally not implemented here — that's a v0.13 follow-up.
408
+ if (bestVector && score == null) {
409
+ const tail = bestVector.match(/\/(\d+(?:\.\d+)?)$/);
410
+ if (tail) {
411
+ const candidate = parseFloat(tail[1]);
412
+ if (candidate >= 0 && candidate <= 10) score = candidate;
413
+ }
414
+ if (score == null && /^CVSS:3\./.test(bestVector)) {
415
+ const computed = cvss3BaseScore(bestVector);
416
+ if (computed != null) score = computed;
417
+ }
418
+ }
419
+ return { score, vector: bestVector };
275
420
  }
276
421
 
277
422
  /**
@@ -373,6 +518,23 @@ function normalizeAdvisory(rec) {
373
518
  // OSV.dev canonical advisory URL — used as the primary vendor advisory.
374
519
  const osvUrl = `https://osv.dev/vulnerability/${encodeURIComponent(rec.id)}`;
375
520
 
521
+ // F6: dedupe verification_sources. OSV records frequently carry the
522
+ // canonical osv.dev URL in references[] as well, which would otherwise
523
+ // produce a duplicate alongside the prepended `osvUrl`.
524
+ const verification_sources = Array.from(new Set([
525
+ osvUrl,
526
+ ...(/^CVE-/i.test(catalogKey) ? [`https://nvd.nist.gov/vuln/detail/${catalogKey}`] : []),
527
+ ...refUrls.slice(0, 10),
528
+ ]));
529
+
530
+ // F5: EPSS coverage does not extend to non-CVE identifiers. Surface this
531
+ // explicitly so curators know to re-query if MITRE later assigns a CVE
532
+ // id to the entry. Wording mirrors the MAL-2026-3083 catalog entry.
533
+ const isCveKey = /^CVE-/i.test(catalogKey);
534
+ const epss_note = isCveKey
535
+ ? null
536
+ : "EPSS coverage does not extend to non-CVE identifiers. FIRST EPSS API only indexes CVE keys; MAL-* / SNYK-* / GHSA-* / RUSTSEC-* / etc. return no data. Re-query and populate epss_score when MITRE assigns a CVE id and the entry is renamed.";
537
+
376
538
  return {
377
539
  [catalogKey]: {
378
540
  name: rec.summary || rec.id,
@@ -407,15 +569,12 @@ function normalizeAdvisory(rec) {
407
569
  epss_score: null,
408
570
  epss_percentile: null,
409
571
  epss_date: null,
410
- epss_source: /^CVE-/i.test(catalogKey)
572
+ epss_note,
573
+ epss_source: isCveKey
411
574
  ? `https://api.first.org/data/v1/epss?cve=${catalogKey}`
412
575
  : null,
413
576
  source_verified: published || today,
414
- verification_sources: [
415
- osvUrl,
416
- ...(/^CVE-/i.test(catalogKey) ? [`https://nvd.nist.gov/vuln/detail/${catalogKey}`] : []),
417
- ...refUrls.slice(0, 10),
418
- ],
577
+ verification_sources,
419
578
  vendor_advisories: [
420
579
  {
421
580
  vendor: "OSV.dev",
@@ -451,19 +610,26 @@ async function buildDiff(ctx) {
451
610
  status: "ok",
452
611
  diffs: [],
453
612
  errors: 0,
613
+ unreachable_count: 0,
614
+ normalize_error_count: 0,
454
615
  summary: "OSV: no ids requested (set ctx.osv_ids to seed a draft, or pass --advisory <MAL-...> for one-shot import).",
455
616
  };
456
617
  }
457
618
  const existingKeys = new Set(Object.keys(ctx.cveCatalog || {}));
458
619
  const diffs = [];
459
- let errors = 0;
620
+ // F7: distinguish unreachable (fetch failed, network or 5xx) from
621
+ // normalize-rejected (record fetched but normalization produced null).
622
+ // Operators triaging a refresh-report want to know whether to chase a
623
+ // network outage or a malformed upstream record.
624
+ let unreachable = 0;
625
+ let normalizeErrors = 0;
460
626
  for (const id of ids) {
461
627
  const r = await fetchAdvisoryById(id);
462
- if (!r.ok) { errors++; continue; }
628
+ if (!r.ok) { unreachable++; continue; }
463
629
  const rec = r.advisories[0];
464
- if (!rec) { errors++; continue; }
630
+ if (!rec) { unreachable++; continue; }
465
631
  const normalized = normalizeAdvisory(rec);
466
- if (!normalized) { errors++; continue; }
632
+ if (!normalized) { normalizeErrors++; continue; }
467
633
  const key = Object.keys(normalized)[0];
468
634
  if (existingKeys.has(key)) continue;
469
635
  diffs.push({
@@ -475,11 +641,14 @@ async function buildDiff(ctx) {
475
641
  source: "osv",
476
642
  });
477
643
  }
644
+ const errors = unreachable + normalizeErrors;
478
645
  return {
479
646
  status: errors === 0 ? "ok" : errors === ids.length ? "unreachable" : "partial",
480
647
  diffs,
481
648
  errors,
482
- summary: `OSV fetched ${ids.length} id(s); ${diffs.length} new entry diff(s), ${errors} failure(s).`,
649
+ unreachable_count: unreachable,
650
+ normalize_error_count: normalizeErrors,
651
+ summary: `OSV fetched ${ids.length} id(s); ${diffs.length} new entry diff(s), ${unreachable} unreachable, ${normalizeErrors} normalize-rejected.`,
483
652
  };
484
653
  }
485
654
 
@@ -489,5 +658,7 @@ module.exports = {
489
658
  normalizeAdvisory,
490
659
  buildDiff,
491
660
  isOsvId,
661
+ extractCvss,
662
+ cvss3BaseScore,
492
663
  OSV_ID_PREFIXES,
493
664
  };
@@ -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-13T17:30:20.755Z",
3
+ "_generated_at": "2026-05-13T21:19:34.827Z",
4
4
  "atlas_version": "5.1.0",
5
5
  "skill_count": 38,
6
6
  "skills": [
package/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exceptd-security",
3
- "version": "0.12.10",
3
+ "version": "0.12.11",
4
4
  "description": "AI security skills grounded in mid-2026 threat reality, not stale framework documentation",
5
5
  "homepage": "https://exceptd.com",
6
6
  "license": "Apache-2.0",
@@ -52,7 +52,7 @@
52
52
  ],
53
53
  "last_threat_review": "2026-05-01",
54
54
  "signature": "GfdaqLFofMiou8doFBE68J+ll50MU3EfJh6N6mNL6RwjABmHsbyfOXCeEpR3NlhbDrYJaG4hpZg4PwhN+t9QAA==",
55
- "signed_at": "2026-05-13T17:30:20.336Z",
55
+ "signed_at": "2026-05-13T21:19:34.408Z",
56
56
  "cwe_refs": [
57
57
  "CWE-125",
58
58
  "CWE-362",
@@ -116,7 +116,7 @@
116
116
  ],
117
117
  "last_threat_review": "2026-05-01",
118
118
  "signature": "m93naZQwujXBedWEjRN+88R1b2q/Dzs595Rz0ufsctsSVL2kiqlorzqqwL4mIXBDUM/HJAMRyFzPQoxOhh5qBw==",
119
- "signed_at": "2026-05-13T17:30:20.338Z",
119
+ "signed_at": "2026-05-13T21:19:34.410Z",
120
120
  "cwe_refs": [
121
121
  "CWE-1039",
122
122
  "CWE-1426",
@@ -179,7 +179,7 @@
179
179
  ],
180
180
  "last_threat_review": "2026-05-01",
181
181
  "signature": "gvC+DFTp8TONJiYIq/Uvg/uTCWyQ2vjdU8hhNEjYqTghxwnNbryePTmvcxb1VZDBSv1r+kyE2MpHRBzSdLhxDQ==",
182
- "signed_at": "2026-05-13T17:30:20.338Z",
182
+ "signed_at": "2026-05-13T21:19:34.411Z",
183
183
  "cwe_refs": [
184
184
  "CWE-22",
185
185
  "CWE-345",
@@ -225,7 +225,7 @@
225
225
  "framework_gaps": [],
226
226
  "last_threat_review": "2026-05-01",
227
227
  "signature": "FqTRjHfEgw56pyHnyWzNtnhzDMEePBtmuamtW/iyX+h4yqbvP4Fyr7NRjRs3EgqT4j7oHuEZhV9Jt6ZTBgN4AA==",
228
- "signed_at": "2026-05-13T17:30:20.339Z"
228
+ "signed_at": "2026-05-13T21:19:34.411Z"
229
229
  },
230
230
  {
231
231
  "name": "compliance-theater",
@@ -256,7 +256,7 @@
256
256
  ],
257
257
  "last_threat_review": "2026-05-01",
258
258
  "signature": "69KnW3ol+BZAcIbex0JJo6/71BgBE4S4o9CxZPd5kgzm5kb85PwzE6KfnK40OaE1jz36FUYJgiQZo/T7RiDhAA==",
259
- "signed_at": "2026-05-13T17:30:20.339Z"
259
+ "signed_at": "2026-05-13T21:19:34.412Z"
260
260
  },
261
261
  {
262
262
  "name": "exploit-scoring",
@@ -285,7 +285,7 @@
285
285
  ],
286
286
  "last_threat_review": "2026-05-01",
287
287
  "signature": "k13lnnr4U4H58LOr2rj+ygF80RnVsdpAyGLXAdyLaY5oJFwfmEMMZQVLrcPhDx6cIQgP+Muzgtolhkh577kCCw==",
288
- "signed_at": "2026-05-13T17:30:20.340Z"
288
+ "signed_at": "2026-05-13T21:19:34.412Z"
289
289
  },
290
290
  {
291
291
  "name": "rag-pipeline-security",
@@ -322,7 +322,7 @@
322
322
  ],
323
323
  "last_threat_review": "2026-05-01",
324
324
  "signature": "X4avgw01bNVZjoiYDF+NN9qSOTjaH2I/7nRbPByoTLzMcORO7zTrZtqpGExJiV3Dmn0EtJ2dX07P65MqQIWHCA==",
325
- "signed_at": "2026-05-13T17:30:20.340Z",
325
+ "signed_at": "2026-05-13T21:19:34.412Z",
326
326
  "cwe_refs": [
327
327
  "CWE-1395",
328
328
  "CWE-1426"
@@ -379,7 +379,7 @@
379
379
  ],
380
380
  "last_threat_review": "2026-05-01",
381
381
  "signature": "SQWndklQmECBemQj42XTfdXM9+7iH+LgIb+qanQsUGT6dhQi7RsXlLeMxe74hVvwpDeUKsn22u23J4bWJh+HCw==",
382
- "signed_at": "2026-05-13T17:30:20.340Z",
382
+ "signed_at": "2026-05-13T21:19:34.412Z",
383
383
  "d3fend_refs": [
384
384
  "D3-CA",
385
385
  "D3-CSPP",
@@ -414,7 +414,7 @@
414
414
  "framework_gaps": [],
415
415
  "last_threat_review": "2026-05-01",
416
416
  "signature": "Dc740zhVm0mtlbj80QfgPcAKFyLZbbDJx/gdNHyYui0XKC3YNxykjcbqKOXnfdLAKkeLwWjMqZefkQk49wueAQ==",
417
- "signed_at": "2026-05-13T17:30:20.341Z",
417
+ "signed_at": "2026-05-13T21:19:34.413Z",
418
418
  "cwe_refs": [
419
419
  "CWE-1188"
420
420
  ]
@@ -442,7 +442,7 @@
442
442
  "framework_gaps": [],
443
443
  "last_threat_review": "2026-05-01",
444
444
  "signature": "3w189PCSFqXTTqlzNc14EKcW7vZ/CtAkuKB2hE4ERdJX+0DNo5AOfrPLjOD1/LdZpPLayEjDTZ83WGtEsMu1Cw==",
445
- "signed_at": "2026-05-13T17:30:20.341Z"
445
+ "signed_at": "2026-05-13T21:19:34.413Z"
446
446
  },
447
447
  {
448
448
  "name": "global-grc",
@@ -474,7 +474,7 @@
474
474
  "framework_gaps": [],
475
475
  "last_threat_review": "2026-05-01",
476
476
  "signature": "S/YXUpI/mcG2FpdUTgMsccWBtTaR5A4Ph4QFQw31S9w9Hn/z3sOFHLkb1B5YSwlg+mMOtSIxMdet1eLGSZkTDg==",
477
- "signed_at": "2026-05-13T17:30:20.342Z"
477
+ "signed_at": "2026-05-13T21:19:34.414Z"
478
478
  },
479
479
  {
480
480
  "name": "zeroday-gap-learn",
@@ -501,7 +501,7 @@
501
501
  "framework_gaps": [],
502
502
  "last_threat_review": "2026-05-01",
503
503
  "signature": "84nQaJylnNIZ47BD7dSBryWwntprRc9zqoTb6i5K7jV6cq7bUm6fVlrCTlFGg/oWLMX3x9JHmc9hqZhgdzRMCw==",
504
- "signed_at": "2026-05-13T17:30:20.342Z"
504
+ "signed_at": "2026-05-13T21:19:34.414Z"
505
505
  },
506
506
  {
507
507
  "name": "pqc-first",
@@ -553,7 +553,7 @@
553
553
  ],
554
554
  "last_threat_review": "2026-05-01",
555
555
  "signature": "oEkK5bLS/G5RIHnxlNFJYdzhTJbKZnkJv+W4iS9UJ/uszZHgZGoxygELPc4kn3FowV5eE988SQYG4WKlXtNzCg==",
556
- "signed_at": "2026-05-13T17:30:20.342Z",
556
+ "signed_at": "2026-05-13T21:19:34.414Z",
557
557
  "cwe_refs": [
558
558
  "CWE-327"
559
559
  ],
@@ -600,7 +600,7 @@
600
600
  ],
601
601
  "last_threat_review": "2026-05-01",
602
602
  "signature": "JCrqB0GmldDIYPpgC+U3DDzxpYWJa0QgEQF7L1T8kxY0U0bsa7cw87CNC5KGk1VZRNsCa3v/I4XR1E/T5GkpBA==",
603
- "signed_at": "2026-05-13T17:30:20.343Z"
603
+ "signed_at": "2026-05-13T21:19:34.415Z"
604
604
  },
605
605
  {
606
606
  "name": "security-maturity-tiers",
@@ -637,7 +637,7 @@
637
637
  ],
638
638
  "last_threat_review": "2026-05-01",
639
639
  "signature": "ctxS+0nGTJgJ4YroLpckhG+ryjYxfwvisSeGt2o8OF6eiQBlu/VbrOk03Jb+qkahgD0mLnSeBE4sjRwekGL9BQ==",
640
- "signed_at": "2026-05-13T17:30:20.343Z",
640
+ "signed_at": "2026-05-13T21:19:34.415Z",
641
641
  "cwe_refs": [
642
642
  "CWE-1188"
643
643
  ]
@@ -672,7 +672,7 @@
672
672
  "framework_gaps": [],
673
673
  "last_threat_review": "2026-05-11",
674
674
  "signature": "cdmQWTu9bFjeheH/B6pa88zCtPqVeEDDElnC/h7pUQ/JQSh9HuqCSiK/Jv2C4gaLA9h0dmKVe7NEFajshrZZDA==",
675
- "signed_at": "2026-05-13T17:30:20.343Z"
675
+ "signed_at": "2026-05-13T21:19:34.415Z"
676
676
  },
677
677
  {
678
678
  "name": "attack-surface-pentest",
@@ -743,7 +743,7 @@
743
743
  "PTES revision incorporating AI-surface enumeration"
744
744
  ],
745
745
  "signature": "6YqZpHsmUz1/aTyOPNDUgJquKaacOqEqTIELHc5wlaydDz4bYboutEu/YiTYy+wF/nYo2nOyuJfMdR8jsYwEDQ==",
746
- "signed_at": "2026-05-13T17:30:20.343Z"
746
+ "signed_at": "2026-05-13T21:19:34.416Z"
747
747
  },
748
748
  {
749
749
  "name": "fuzz-testing-strategy",
@@ -803,7 +803,7 @@
803
803
  "OSS-Fuzz-Gen / AI-assisted harness generation becoming the default expectation for OSS maintainers"
804
804
  ],
805
805
  "signature": "+ELdD+1AY5DymBitH7wU65CS60NY1nDoLowJAFn7cE5Gr/5jy9BTkyxsm7PEXaSlXWMOkTf/HQ+uyzyxUVD/Bw==",
806
- "signed_at": "2026-05-13T17:30:20.344Z"
806
+ "signed_at": "2026-05-13T21:19:34.416Z"
807
807
  },
808
808
  {
809
809
  "name": "dlp-gap-analysis",
@@ -878,7 +878,7 @@
878
878
  "Quebec Law 25, India DPDPA, KSA PDPL enforcement actions naming AI-tool prompt data as in-scope personal information"
879
879
  ],
880
880
  "signature": "H1N113M/YhnEPQptJ2B88wwAOB4eMPKuVABQ4DSlMjFMsX0ts1DBCupHHDoHcJHbi8vs+Wi10ekpL5SnsroZDQ==",
881
- "signed_at": "2026-05-13T17:30:20.344Z"
881
+ "signed_at": "2026-05-13T21:19:34.416Z"
882
882
  },
883
883
  {
884
884
  "name": "supply-chain-integrity",
@@ -955,7 +955,7 @@
955
955
  "OpenSSF model-signing — emerging Sigstore-based signing standard for ML model weights; track for production adoption"
956
956
  ],
957
957
  "signature": "TmV9ZBDYqp2pHIbNZKQYf+kZLNAiyagnH/pjK9+LiSd7OdFIN3KEpFHPUNzcivB/CT5q7nl7Tsmvc9UvGo8WDg==",
958
- "signed_at": "2026-05-13T17:30:20.344Z"
958
+ "signed_at": "2026-05-13T21:19:34.416Z"
959
959
  },
960
960
  {
961
961
  "name": "defensive-countermeasure-mapping",
@@ -1012,7 +1012,7 @@
1012
1012
  ],
1013
1013
  "last_threat_review": "2026-05-11",
1014
1014
  "signature": "XZigwq8X/csfrdG10O6Q1V5q0zUqSQGd3QrjRKkZ4fkaodG4mZahYuIQqxc8rU9jjtGAm9LtBXYB+I5csqj9Bw==",
1015
- "signed_at": "2026-05-13T17:30:20.344Z"
1015
+ "signed_at": "2026-05-13T21:19:34.417Z"
1016
1016
  },
1017
1017
  {
1018
1018
  "name": "identity-assurance",
@@ -1079,7 +1079,7 @@
1079
1079
  "d3fend_refs": [],
1080
1080
  "last_threat_review": "2026-05-11",
1081
1081
  "signature": "pCvavMUh5wR/TFl03Vh6ggKJHWPjakOEPDh7IjaD/U3WLBGPF3eyLBzieNLrPDXxIBbCJqnt9E79Bd4e73xCCg==",
1082
- "signed_at": "2026-05-13T17:30:20.345Z"
1082
+ "signed_at": "2026-05-13T21:19:34.417Z"
1083
1083
  },
1084
1084
  {
1085
1085
  "name": "ot-ics-security",
@@ -1135,7 +1135,7 @@
1135
1135
  "d3fend_refs": [],
1136
1136
  "last_threat_review": "2026-05-11",
1137
1137
  "signature": "3V0ZpDLRTmCBx5Li+9m3XKA+k9QR+l0aE55cRPMX+UTDRlStKSG5PgrSGcL2ZKJog9hKaUPmjIVh7kkn54SfAg==",
1138
- "signed_at": "2026-05-13T17:30:20.345Z"
1138
+ "signed_at": "2026-05-13T21:19:34.417Z"
1139
1139
  },
1140
1140
  {
1141
1141
  "name": "coordinated-vuln-disclosure",
@@ -1187,7 +1187,7 @@
1187
1187
  "NYDFS 23 NYCRR 500 amendments potentially adding explicit CVD program requirements"
1188
1188
  ],
1189
1189
  "signature": "UCiNjncvhkZItmLQA/Sm1/NCsOiLMwdCjfUw+067v4NIxhaMMaqRrAeD3KgMyEtov7m2Hq2kfwYSt5+DQsYDCQ==",
1190
- "signed_at": "2026-05-13T17:30:20.345Z"
1190
+ "signed_at": "2026-05-13T21:19:34.418Z"
1191
1191
  },
1192
1192
  {
1193
1193
  "name": "threat-modeling-methodology",
@@ -1237,7 +1237,7 @@
1237
1237
  "PASTA v2 updates incorporating AI/ML application threats"
1238
1238
  ],
1239
1239
  "signature": "i8ZUFT7hwTQJyqYXWh8rchdEUNv1kk0bzkF3BHOANFwuVoM0+mukOPr+rhKdOWCPjTX72EG+0Mbs6hBTSfBBAg==",
1240
- "signed_at": "2026-05-13T17:30:20.346Z"
1240
+ "signed_at": "2026-05-13T21:19:34.418Z"
1241
1241
  },
1242
1242
  {
1243
1243
  "name": "webapp-security",
@@ -1311,7 +1311,7 @@
1311
1311
  "d3fend_refs": [],
1312
1312
  "last_threat_review": "2026-05-11",
1313
1313
  "signature": "3d+Hs8yfERF/NUuVhO3YFYCY+bVn7aAGbNCCyGeqYM2VIt7q/nzNXGfbNfJbSPezClwutOyxbzPwXMj+TjmMDA==",
1314
- "signed_at": "2026-05-13T17:30:20.346Z"
1314
+ "signed_at": "2026-05-13T21:19:34.418Z"
1315
1315
  },
1316
1316
  {
1317
1317
  "name": "ai-risk-management",
@@ -1361,7 +1361,7 @@
1361
1361
  "d3fend_refs": [],
1362
1362
  "last_threat_review": "2026-05-11",
1363
1363
  "signature": "runa3PkfcThZmv5I+F6D1hOR/kfBeVOcdDZHsJ/59WOjsPj4BPi4d9MtP1vV7G9qq9daGGz6YzZGnejjin4pCA==",
1364
- "signed_at": "2026-05-13T17:30:20.346Z"
1364
+ "signed_at": "2026-05-13T21:19:34.418Z"
1365
1365
  },
1366
1366
  {
1367
1367
  "name": "sector-healthcare",
@@ -1421,7 +1421,7 @@
1421
1421
  "d3fend_refs": [],
1422
1422
  "last_threat_review": "2026-05-11",
1423
1423
  "signature": "58fEAPnojhmGSpEIIyWIwfj65A2KB4SMyUCoieJ28OZaUktUF+56wLHQOdAW6v2fSeiriFqZEqGyKyLryGGcBg==",
1424
- "signed_at": "2026-05-13T17:30:20.346Z"
1424
+ "signed_at": "2026-05-13T21:19:34.419Z"
1425
1425
  },
1426
1426
  {
1427
1427
  "name": "sector-financial",
@@ -1502,7 +1502,7 @@
1502
1502
  "TIBER-EU framework v2.0 alignment with DORA TLPT RTS (JC 2024/40); cross-recognition with CBEST and iCAST"
1503
1503
  ],
1504
1504
  "signature": "wByxWUburbU5PB7EHLDVAByCpMbhKRTWqSoVsBmxIDsz2jCiKb3ogJutFnAV2r2oXuQIUaffGvrvACIrJ2GlBQ==",
1505
- "signed_at": "2026-05-13T17:30:20.347Z"
1505
+ "signed_at": "2026-05-13T21:19:34.419Z"
1506
1506
  },
1507
1507
  {
1508
1508
  "name": "sector-federal-government",
@@ -1571,7 +1571,7 @@
1571
1571
  "Australia PSPF 2024 revision and ISM quarterly updates — track for Essential Eight Maturity Level requirements for federal entities"
1572
1572
  ],
1573
1573
  "signature": "gdiwq/+AQxxNJ2t90dITyLd2ZWdDKHxbyS2I+AVRdM45VVKQEAY4XyheGZW8m5wdDF7N9X2ENIE5CRMfP5jnCA==",
1574
- "signed_at": "2026-05-13T17:30:20.347Z"
1574
+ "signed_at": "2026-05-13T21:19:34.420Z"
1575
1575
  },
1576
1576
  {
1577
1577
  "name": "sector-energy",
@@ -1636,7 +1636,7 @@
1636
1636
  "ICS-CERT advisory feed (https://www.cisa.gov/news-events/cybersecurity-advisories/ics-advisories) for vendor CVEs in Siemens, Rockwell, Schneider Electric, ABB, GE Vernova, Hitachi Energy, AVEVA / OSIsoft PI"
1637
1637
  ],
1638
1638
  "signature": "3M6nshB0ZNfr3H/rpcy58+/yve91u8kiTOhwUBePTZ0lQeuzlXZZV6/36i/NTWKC4SwgMn8fsZCpLxA5llRaCg==",
1639
- "signed_at": "2026-05-13T17:30:20.348Z"
1639
+ "signed_at": "2026-05-13T21:19:34.420Z"
1640
1640
  },
1641
1641
  {
1642
1642
  "name": "api-security",
@@ -1705,7 +1705,7 @@
1705
1705
  "d3fend_refs": [],
1706
1706
  "last_threat_review": "2026-05-11",
1707
1707
  "signature": "rLOJBYVELFSVT2zLEByuko5Z1+HwGY99pQOC1XIVHs9l7gmY7F8N8luU/EBvVDcnT9w6nPiC5YKmCy/50LbxBQ==",
1708
- "signed_at": "2026-05-13T17:30:20.348Z"
1708
+ "signed_at": "2026-05-13T21:19:34.420Z"
1709
1709
  },
1710
1710
  {
1711
1711
  "name": "cloud-security",
@@ -1786,7 +1786,7 @@
1786
1786
  "CISA KEV additions for cloud-control-plane CVEs (IMDSv1 abuses, federation token mishandling, cross-tenant boundary failures); CISA Cybersecurity Advisories for cross-cloud advisories"
1787
1787
  ],
1788
1788
  "signature": "PyA9IHcGNrCLC0AJW2jF5D5XheA60igeirjHb3IOz/HitBEg3t2g/siP6pAUImx3IUmwp9vh+A6k+KBuyHRgBQ==",
1789
- "signed_at": "2026-05-13T17:30:20.348Z"
1789
+ "signed_at": "2026-05-13T21:19:34.421Z"
1790
1790
  },
1791
1791
  {
1792
1792
  "name": "container-runtime-security",
@@ -1848,7 +1848,7 @@
1848
1848
  "d3fend_refs": [],
1849
1849
  "last_threat_review": "2026-05-11",
1850
1850
  "signature": "/RQD2SZghTsrG7qurGb1uabyJ2IL9nmbqd412k8tqZw6nzdvvNFWcYDUGBACEQLbSlJvuaJeQdMHQH5ZzzkNDA==",
1851
- "signed_at": "2026-05-13T17:30:20.349Z"
1851
+ "signed_at": "2026-05-13T21:19:34.421Z"
1852
1852
  },
1853
1853
  {
1854
1854
  "name": "mlops-security",
@@ -1919,7 +1919,7 @@
1919
1919
  "MITRE ATLAS v5.2 — track AML.T0010 sub-technique expansion and any new MLOps-pipeline-specific TTPs"
1920
1920
  ],
1921
1921
  "signature": "BeACZFTsTRZyGuOEc7NPGnGUQ84ZrFbYRsg+yqJxCH7QOM69jYJTnExyAyxXfiJyoytzSffJQNK3h0E48rm0BQ==",
1922
- "signed_at": "2026-05-13T17:30:20.349Z"
1922
+ "signed_at": "2026-05-13T21:19:34.421Z"
1923
1923
  },
1924
1924
  {
1925
1925
  "name": "incident-response-playbook",
@@ -1981,7 +1981,7 @@
1981
1981
  "NYDFS 23 NYCRR 500.17 amendments tightening ransom-payment 24h disclosure operationalization"
1982
1982
  ],
1983
1983
  "signature": "oy63A4wX5g9rYq3wWYGSS9L7wndNaa1+wJPRPtiHMvQ6CNhTzN8kCs9Ur2SkT7U9BKYdfPLni0gGMjoeDbx+AA==",
1984
- "signed_at": "2026-05-13T17:30:20.349Z"
1984
+ "signed_at": "2026-05-13T21:19:34.422Z"
1985
1985
  },
1986
1986
  {
1987
1987
  "name": "email-security-anti-phishing",
@@ -2034,7 +2034,7 @@
2034
2034
  "d3fend_refs": [],
2035
2035
  "last_threat_review": "2026-05-11",
2036
2036
  "signature": "EFhMVyQdiLRtYPHsMADrX3aDXyWd/F5sjnGoWtUTeTiIOmrhFMOCHsvpTPotfsLt0u3LgRK5ACgjgeON8PgQAg==",
2037
- "signed_at": "2026-05-13T17:30:20.350Z"
2037
+ "signed_at": "2026-05-13T21:19:34.422Z"
2038
2038
  },
2039
2039
  {
2040
2040
  "name": "age-gates-child-safety",
@@ -2102,7 +2102,7 @@
2102
2102
  "US state adult-site age-verification laws — 19+ states by mid-2026 (TX HB 18 upheld by SCOTUS June 2025 in Free Speech Coalition v. Paxton); track ongoing challenges in remaining states"
2103
2103
  ],
2104
2104
  "signature": "MMWvg3lIf5ygm31zyf1E43t3W9MfRbMBBPrqlj1wOa8AxVJL8LICnAXfmyJ/TNJXwpF+rfZeDdoxXkql8wmtBA==",
2105
- "signed_at": "2026-05-13T17:30:20.350Z"
2105
+ "signed_at": "2026-05-13T21:19:34.422Z"
2106
2106
  }
2107
2107
  ]
2108
2108
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/exceptd-skills",
3
- "version": "0.12.10",
3
+ "version": "0.12.11",
4
4
  "description": "AI security skills grounded in mid-2026 threat reality, not stale framework documentation. 38 skills, 10 catalogs, 34 jurisdictions, pre-computed indexes, Ed25519-signed.",
5
5
  "keywords": [
6
6
  "ai-security",
package/sbom.cdx.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.6",
4
- "serialNumber": "urn:uuid:9f17cd00-16e6-4fd2-b943-190a84d36bc4",
4
+ "serialNumber": "urn:uuid:dac8abdb-9a54-4d36-9f4b-21e8dde200ea",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-05-13T17:23:27.661Z",
7
+ "timestamp": "2026-05-13T21:19:35.259Z",
8
8
  "tools": [
9
9
  {
10
10
  "name": "hand-written",
@@ -13,10 +13,10 @@
13
13
  }
14
14
  ],
15
15
  "component": {
16
- "bom-ref": "pkg:npm/@blamejs/exceptd-skills@0.12.10",
16
+ "bom-ref": "pkg:npm/@blamejs/exceptd-skills@0.12.11",
17
17
  "type": "application",
18
18
  "name": "@blamejs/exceptd-skills",
19
- "version": "0.12.10",
19
+ "version": "0.12.11",
20
20
  "description": "AI security skills grounded in mid-2026 threat reality, not stale framework documentation. 38 skills, 10 catalogs, 34 jurisdictions, pre-computed indexes, Ed25519-signed.",
21
21
  "licenses": [
22
22
  {
@@ -25,11 +25,11 @@
25
25
  }
26
26
  }
27
27
  ],
28
- "purl": "pkg:npm/%40blamejs/exceptd-skills@0.12.10",
28
+ "purl": "pkg:npm/%40blamejs/exceptd-skills@0.12.11",
29
29
  "externalReferences": [
30
30
  {
31
31
  "type": "distribution",
32
- "url": "https://www.npmjs.com/package/@blamejs/exceptd-skills/v/0.12.10"
32
+ "url": "https://www.npmjs.com/package/@blamejs/exceptd-skills/v/0.12.11"
33
33
  },
34
34
  {
35
35
  "type": "vcs",