@blamejs/exceptd-skills 0.16.25 → 0.16.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/AGENTS.md +5 -5
  2. package/ARCHITECTURE.md +3 -3
  3. package/CHANGELOG.md +14 -0
  4. package/CONTEXT.md +2 -2
  5. package/README.md +5 -5
  6. package/agents/threat-researcher.md +2 -2
  7. package/data/_indexes/_meta.json +39 -39
  8. package/data/_indexes/activity-feed.json +240 -240
  9. package/data/_indexes/catalog-summaries.json +3 -3
  10. package/data/_indexes/currency.json +64 -64
  11. package/data/_indexes/recipes.json +1 -1
  12. package/data/_indexes/section-offsets.json +510 -510
  13. package/data/_indexes/summary-cards.json +33 -33
  14. package/data/_indexes/token-budget.json +200 -200
  15. package/data/atlas-ttps.json +7 -7
  16. package/data/attack-techniques.json +5 -5
  17. package/data/framework-control-gaps.json +3 -3
  18. package/lib/auto-discovery.js +7 -9
  19. package/lib/cvss.js +108 -0
  20. package/lib/prefetch.js +97 -5
  21. package/lib/refresh-external.js +22 -11
  22. package/lib/schemas/manifest.schema.json +1 -1
  23. package/lib/schemas/skill-frontmatter.schema.json +1 -1
  24. package/lib/version-pins.js +3 -3
  25. package/manifest-snapshot.json +2 -2
  26. package/manifest-snapshot.sha256 +1 -1
  27. package/manifest.json +124 -124
  28. package/package.json +1 -1
  29. package/sbom.cdx.json +133 -118
  30. package/scripts/builders/catalog-summaries.js +1 -1
  31. package/scripts/builders/recipes.js +1 -1
  32. package/scripts/run-e2e-scenarios.js +48 -17
  33. package/skills/age-gates-child-safety/skill.md +3 -3
  34. package/skills/ai-attack-surface/skill.md +4 -4
  35. package/skills/ai-c2-detection/skill.md +5 -5
  36. package/skills/api-security/skill.md +2 -2
  37. package/skills/attack-surface-pentest/skill.md +4 -4
  38. package/skills/cloud-security/skill.md +3 -3
  39. package/skills/compliance-theater/skill.md +3 -3
  40. package/skills/container-runtime-security/skill.md +3 -3
  41. package/skills/coordinated-vuln-disclosure/skill.md +2 -2
  42. package/skills/defensive-countermeasure-mapping/skill.md +3 -3
  43. package/skills/dlp-gap-analysis/skill.md +5 -5
  44. package/skills/exploit-scoring/skill.md +2 -2
  45. package/skills/framework-gap-analysis/skill.md +4 -4
  46. package/skills/fuzz-testing-strategy/skill.md +2 -2
  47. package/skills/incident-response-playbook/skill.md +3 -3
  48. package/skills/mcp-agent-trust/skill.md +2 -2
  49. package/skills/mlops-security/skill.md +3 -3
  50. package/skills/ot-ics-security/skill.md +3 -3
  51. package/skills/policy-exception-gen/skill.md +3 -3
  52. package/skills/pqc-first/skill.md +2 -2
  53. package/skills/rag-pipeline-security/skill.md +4 -4
  54. package/skills/ransomware-response/skill.md +2 -2
  55. package/skills/sector-energy/skill.md +2 -2
  56. package/skills/sector-federal-government/skill.md +2 -2
  57. package/skills/sector-financial/skill.md +4 -4
  58. package/skills/sector-healthcare/skill.md +3 -3
  59. package/skills/security-maturity-tiers/skill.md +1 -1
  60. package/skills/skill-update-loop/skill.md +6 -6
  61. package/skills/supply-chain-integrity/skill.md +2 -2
  62. package/skills/threat-model-currency/skill.md +8 -8
  63. package/skills/threat-modeling-methodology/skill.md +2 -2
  64. package/skills/webapp-security/skill.md +2 -2
  65. package/skills/zeroday-gap-learn/skill.md +3 -3
  66. package/sources/validators/cve-validator.js +12 -13
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "_meta": {
3
3
  "schema_version": "1.0.0",
4
- "atlas_version": "5.6.0",
5
- "atlas_release_date": "2026-05-08",
4
+ "atlas_version": "2026.05",
5
+ "atlas_release_date": "2026-05-27",
6
6
  "secure_ai_v2_release_date": "2026-05-06",
7
7
  "secure_ai_v2_source": "https://ctid.mitre.org/blog/2026/05/06/secure-ai-v2-release",
8
- "last_updated": "2026-05-19",
9
- "last_threat_review": "2026-05-19",
8
+ "last_updated": "2026-06-10",
9
+ "last_threat_review": "2026-06-10",
10
10
  "source": "https://atlas.mitre.org",
11
- "note": "AI-relevant ATLAS v5.6.0 TTPs with framework_gap field. framework_gap: no framework has a control that addresses this TTP. secure_ai_v2_layer flags entries included in the CTID Secure AI v2 layered set (May 2026); maturity reflects CTID's technique-maturity classification (low / moderate / high).",
11
+ "note": "AI-relevant ATLAS v2026.05 TTPs with framework_gap field. ATLAS content now follows a YYYY.MM calendar-versioning scheme; v2026.05 adds platform tags (Predictive AI, Generative AI, Agentic AI, Enterprise) to every technique, with no technique-ID removals or renames. framework_gap: no framework has a control that addresses this TTP. secure_ai_v2_layer flags entries included in the CTID Secure AI v2 layered set (May 2026); maturity reflects CTID's technique-maturity classification (low / moderate / high).",
12
12
  "tlp": "CLEAR",
13
13
  "source_confidence": {
14
14
  "scheme": "Admiralty (A-F + 1-6)",
@@ -21,7 +21,7 @@
21
21
  "rebuild_after_days": 365,
22
22
  "note": "Per-entry last_verified governs decay. Skills depending on this catalog must check entry freshness before high-stakes use."
23
23
  },
24
- "bulk_intake_note": " v0.13.18: +137 AML.* techniques from MITRE ATLAS STIX bundle (atlas-navigator-data dist/stix-atlas.json). Now tracking ATLAS v5.6.0 (May 2026 release). Auto-imported entries flagged _auto_imported:true + _intake_method:v0.13.18-bulk-mitre-atlas-stix for downstream curation."
24
+ "bulk_intake_note": " v0.13.18: +137 AML.* techniques from MITRE ATLAS STIX bundle (atlas-navigator-data dist/stix-atlas.json). Now tracking ATLAS v2026.05 (May 2026 release). Auto-imported entries flagged _auto_imported:true + _intake_method:v0.13.18-bulk-mitre-atlas-stix for downstream curation."
25
25
  },
26
26
  "AML.T0001": {
27
27
  "id": "AML.T0001",
@@ -536,7 +536,7 @@
536
536
  "Multiple production AI assistant prompt injection incidents 2025-2026"
537
537
  ],
538
538
  "framework_gap": true,
539
- "framework_gap_detail": "No framework has a control for prompt injection as an access control failure vector. The attack uses the AI service account's authorized permissions — from AC-2's perspective, the access is authorized. MITRE ATLAS v5.6.0 documents the technique; no framework has implemented controls. OWASP LLM Top 10 documents the class; it is not incorporated in any compliance framework.",
539
+ "framework_gap_detail": "No framework has a control for prompt injection as an access control failure vector. The attack uses the AI service account's authorized permissions — from AC-2's perspective, the access is authorized. MITRE ATLAS v2026.05 documents the technique; no framework has implemented controls. OWASP LLM Top 10 documents the class; it is not incorporated in any compliance framework.",
540
540
  "controls_that_partially_help": [
541
541
  "NIST-800-53-AC-2",
542
542
  "ISO-27001-2022-A.8.28"
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "_meta": {
3
3
  "schema_version": "1.0.0",
4
- "last_updated": "2026-05-19",
5
- "last_threat_review": "2026-05-19",
6
- "attack_version": "19.0",
7
- "attack_version_date": "2026-04-28",
4
+ "last_updated": "2026-06-10",
5
+ "last_threat_review": "2026-06-10",
6
+ "attack_version": "19.1",
7
+ "attack_version_date": "2026-05-12",
8
8
  "tactic_split_note": "ATT&CK v19 split Defense Evasion (TA0005) into Stealth (TA0005) and Defense Impairment (TA0112). Entries here record their post-split tactic; entries whose tactic moved carry `tactic_moved_from: \"Defense Evasion\"` for traceability.",
9
9
  "detection_strategies_note": "ATT&CK v18 introduced Detection Strategies as first-class objects (691 strategies, 1739 analytics at v18 release). Entries reference applicable strategy IDs (DSxxxx) where the technique has canonical strategy coverage; absence does not imply lack of detection.",
10
- "source": "https://attack.mitre.org — MITRE ATT&CK Enterprise + ICS, v19.0 (April 2026). Only techniques currently referenced by shipped exceptd skills and playbooks. The full ATT&CK matrix is intentionally not duplicated here; this is a resolution catalog for cross-reference validation, not a substitute for attack.mitre.org. See `npm run refresh-attack-techniques` (v0.13.0+) for the full corpus.",
10
+ "source": "https://attack.mitre.org — MITRE ATT&CK Enterprise + ICS, v19.1 (May 2026). Only techniques currently referenced by shipped exceptd skills and playbooks. The full ATT&CK matrix is intentionally not duplicated here; this is a resolution catalog for cross-reference validation, not a substitute for attack.mitre.org. See `npm run refresh-attack-techniques` (v0.13.0+) for the full corpus.",
11
11
  "tlp": "CLEAR",
12
12
  "source_confidence": {
13
13
  "scheme": "Admiralty (A-F + 1-6)",
@@ -468,7 +468,7 @@
468
468
  "Treating 'Top 25 addressed' as a compliance signal creates a compliance-theatre risk for organisations with significant AI surface",
469
469
  "No cross-walk requirement to ATLAS TTPs — CWE addresses weaknesses; ATLAS addresses adversary techniques. Both are needed for AI coverage"
470
470
  ],
471
- "real_requirement": "Programmes that claim 'Top 25 addressed' as compliance evidence must additionally: (1) enumerate AI-relevant CWEs outside the Top 25 (CWE-1426 Improper Output Validation, CWE-1039 Inadequate Detection of Adversarial Input, CWE-1230 Exposure of Sensitive Info Through Metadata) with explicit treatment, (2) cross-walk to ATLAS v5.6.0 TTPs for adversarial coverage, (3) re-baseline against the next-published Top 25 with delta analysis. Aligns with EU CRA Annex I, UK NCSC, AU ISM, ISO 27001 A.8.28.",
471
+ "real_requirement": "Programmes that claim 'Top 25 addressed' as compliance evidence must additionally: (1) enumerate AI-relevant CWEs outside the Top 25 (CWE-1426 Improper Output Validation, CWE-1039 Inadequate Detection of Adversarial Input, CWE-1230 Exposure of Sensitive Info Through Metadata) with explicit treatment, (2) cross-walk to ATLAS v2026.05 TTPs for adversarial coverage, (3) re-baseline against the next-published Top 25 with delta analysis. Aligns with EU CRA Annex I, UK NCSC, AU ISM, ISO 27001 A.8.28.",
472
472
  "status": "open",
473
473
  "opened_date": "2026-05-11",
474
474
  "evidence_cves": [],
@@ -1804,7 +1804,7 @@
1804
1804
  "LLM-API-as-C2 (SesameOp pattern, ATLAS AML.T0096) is not in the clause 6.1.2 example threat list — risk register templates omit it",
1805
1805
  "No requirement to link AI risk register entries to specific TTP IDs (ATLAS / ATT&CK) — risks remain framework-internal abstractions"
1806
1806
  ],
1807
- "real_requirement": "Clause 6.1.2 risk registers must (1) ingest ATLAS v5.6.0 TTPs as enumerated AI-specific threat sources, (2) cross-reference jurisdictional obligations (EU AI Act Annex III, NIS2 Art. 21, DORA Art. 28, UK CAF B4, AU ISM AI annex, ISO 27001:2022 A.5.7), (3) include AI-API-as-C2 and prompt-injection-as-RCE as named scenarios, (4) be re-run on threat-intel triggers, not only on calendar cycles.",
1807
+ "real_requirement": "Clause 6.1.2 risk registers must (1) ingest ATLAS v2026.05 TTPs as enumerated AI-specific threat sources, (2) cross-reference jurisdictional obligations (EU AI Act Annex III, NIS2 Art. 21, DORA Art. 28, UK CAF B4, AU ISM AI annex, ISO 27001:2022 A.5.7), (3) include AI-API-as-C2 and prompt-injection-as-RCE as named scenarios, (4) be re-run on threat-intel triggers, not only on calendar cycles.",
1808
1808
  "status": "open",
1809
1809
  "opened_date": "2026-05-11",
1810
1810
  "evidence_cves": [],
@@ -7145,7 +7145,7 @@
7145
7145
  }
7146
7146
  },
7147
7147
  "ATLAS-AML.T0048": {
7148
- "framework": "MITRE ATLAS v5.6.0",
7148
+ "framework": "MITRE ATLAS v2026.05",
7149
7149
  "control_id": "AML.T0048",
7150
7150
  "control_name": "External Harms — ML Supply Chain Compromise (bundled-codec / inference-server class)",
7151
7151
  "designed_for": "ATLAS AML.T0048 catalogues external harms from ML supply-chain compromise, including malicious model weights, poisoned training data, and compromised ML libraries. The technique-level guidance covers detection and mitigation at the model-artifact and library-consumption layer.",
@@ -31,6 +31,7 @@ const fs = require("fs");
31
31
  const path = require("path");
32
32
  const crypto = require("crypto");
33
33
  const { scoreCustom, RWEP_WEIGHTS, ACTIVE_EXPLOITATION_LADDER } = require("./scoring");
34
+ const { selectNvdCvss } = require("./cvss");
34
35
 
35
36
  // Stored rwep_factors must reproduce the stored rwep_score.
36
37
  // `buildScoringInputs` is the single source of truth for both — it captures
@@ -187,16 +188,13 @@ function readCachedJson(cacheDir, source, id) {
187
188
  function extractNvdMetrics(payload) {
188
189
  const vuln = payload?.vulnerabilities?.[0]?.cve;
189
190
  if (!vuln) return null;
190
- const m = vuln.metrics || {};
191
- const ordered = [
192
- ...(m.cvssMetricV31 || []),
193
- ...(m.cvssMetricV30 || []),
194
- ...(m.cvssMetricV2 || []),
195
- ];
196
- const primary = ordered.find((x) => x.type === "Primary") || ordered[0];
191
+ // Prefer the newest CVSS version (Primary within it) and normalize a bare
192
+ // v2 vector to its canonical prefix so an auto-imported draft never carries
193
+ // an unprefixed vector that the strict catalog validator would reject.
194
+ const up = selectNvdCvss(vuln.metrics);
197
195
  return {
198
- cvss_score: typeof primary?.cvssData?.baseScore === "number" ? primary.cvssData.baseScore : null,
199
- cvss_vector: primary?.cvssData?.vectorString || null,
196
+ cvss_score: up ? up.baseScore : null,
197
+ cvss_vector: up ? up.vector : null,
200
198
  description: (vuln.descriptions || []).find((d) => d.lang === "en")?.value || null,
201
199
  cwe_refs: ((vuln.weaknesses || [])
202
200
  .flatMap((w) => (w.description || []))
package/lib/cvss.js ADDED
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ /**
3
+ * lib/cvss.js
4
+ *
5
+ * Shared CVSS metric-selection and vector-normalization helpers for every
6
+ * site that ingests NIST NVD scoring (the cache-backed refresh diff, the
7
+ * live per-CVE validator, and the KEV auto-discovery importer).
8
+ *
9
+ * Two properties of NVD's data make naive ingestion lossy:
10
+ *
11
+ * 1. NVD tags the legacy CVSS v2 metric as `type: "Primary"` on pre-v3
12
+ * CVEs, while a modern v3.1 re-score (often supplied by a CNA) rides as
13
+ * `type: "Secondary"`. Selecting a metric by `type === "Primary"` alone
14
+ * therefore picks the *older* v2 score over a newer v3.1 one — silently
15
+ * downgrading a curated v3.1 entry to v2 on every refresh.
16
+ *
17
+ * 2. NVD's `cvssMetricV2` entries carry a bare base vector
18
+ * ("AV:N/AC:L/Au:N/C:C/I:C/A:C") with no "CVSS:2.0/" prefix, whereas
19
+ * v3.x/v4.0 carry the prefix. The catalog schema (and
20
+ * validate-cve-catalog --strict) require the canonical "CVSS:<x.y>/"
21
+ * prefix, so writing a bare v2 vector produces an invalid entry.
22
+ *
23
+ * `selectNvdCvss` resolves (1) by preferring the newest CVSS version present
24
+ * and choosing Primary only *within* that version; it resolves (2) by
25
+ * normalizing the returned vector. Callers additionally guard against
26
+ * cross-version downgrades using `cvssVersionOf` on the locally-curated
27
+ * vector.
28
+ *
29
+ * Zero npm deps. Node stdlib only.
30
+ */
31
+
32
+ // A bare (unprefixed) CVSS v2 base vector. v2 is the only version NVD emits
33
+ // without a "CVSS:x/" prefix, and its grammar carries the Au: (Authentication)
34
+ // metric that v3/v4 dropped — the unambiguous discriminator for a bare v2.
35
+ const BARE_V2_RE = /^AV:[NAL]\/AC:[HML]\/Au:[MSN]\//;
36
+
37
+ // The four canonical version prefixes the catalog accepts (mirrors
38
+ // validate-cve-catalog.js STRICT_CVSS_PATTERN).
39
+ const PREFIXED_RE = /^CVSS:(2\.0|3\.0|3\.1|4\.0)\//;
40
+
41
+ /**
42
+ * The CVSS version a vector declares, as a comparable number (2.0 < 3.0 < 3.1
43
+ * < 4.0). Recognizes the four canonical "CVSS:x.y/" prefixes plus NVD's bare
44
+ * v2 base vector. Returns null for anything unrecognized so callers can treat
45
+ * an unknown version as "do not block" rather than mis-suppressing a diff.
46
+ *
47
+ * @param {string} vector
48
+ * @returns {number|null}
49
+ */
50
+ function cvssVersionOf(vector) {
51
+ if (typeof vector !== "string" || vector.length === 0) return null;
52
+ const m = vector.match(PREFIXED_RE);
53
+ if (m) return Number(m[1]);
54
+ if (BARE_V2_RE.test(vector)) return 2.0;
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * Ensure a vector carries a canonical "CVSS:x.y/" prefix. A bare v2 base
60
+ * vector is prefixed with "CVSS:2.0/"; already-prefixed vectors (and anything
61
+ * unrecognized) pass through unchanged. The output of a recognized vector
62
+ * always satisfies validate-cve-catalog --strict.
63
+ *
64
+ * @param {string} vector
65
+ * @returns {string}
66
+ */
67
+ function normalizeCvssVector(vector) {
68
+ if (typeof vector !== "string" || vector.length === 0) return vector;
69
+ if (PREFIXED_RE.test(vector)) return vector;
70
+ if (BARE_V2_RE.test(vector)) return `CVSS:2.0/${vector}`;
71
+ return vector;
72
+ }
73
+
74
+ /**
75
+ * Select the most authoritative CVSS metric from an NVD `metrics` object.
76
+ * Prefers the newest CVSS version present (4.0 > 3.1 > 3.0 > 2.0); within the
77
+ * chosen version prefers NVD's "Primary" analyst score over a "Secondary"
78
+ * (CNA) one, falling back to the first entry when no Primary exists in that
79
+ * version. The returned vector is normalized to the canonical prefix form.
80
+ *
81
+ * @param {object} metrics The `vulnerabilities[0].cve.metrics` object.
82
+ * @returns {{version:number|null, baseScore:number|null, vector:string|null, source:string|null}|null}
83
+ */
84
+ function selectNvdCvss(metrics) {
85
+ const m = metrics || {};
86
+ const buckets = [
87
+ [4.0, m.cvssMetricV40],
88
+ [3.1, m.cvssMetricV31],
89
+ [3.0, m.cvssMetricV30],
90
+ [2.0, m.cvssMetricV2],
91
+ ];
92
+ for (const [bucketVersion, bucket] of buckets) {
93
+ const arr = Array.isArray(bucket) ? bucket : [];
94
+ if (arr.length === 0) continue;
95
+ const chosen = arr.find((x) => x && x.type === "Primary") || arr[0];
96
+ const data = chosen && chosen.cvssData ? chosen.cvssData : null;
97
+ const declared = data && data.version != null ? Number(data.version) : null;
98
+ return {
99
+ version: Number.isFinite(declared) ? declared : bucketVersion,
100
+ baseScore: typeof data?.baseScore === "number" ? data.baseScore : null,
101
+ vector: data?.vectorString ? normalizeCvssVector(data.vectorString) : null,
102
+ source: chosen && chosen.source ? chosen.source : null,
103
+ };
104
+ }
105
+ return null;
106
+ }
107
+
108
+ module.exports = { cvssVersionOf, normalizeCvssVector, selectNvdCvss };
package/lib/prefetch.js CHANGED
@@ -108,7 +108,7 @@ const SOURCES = {
108
108
  };
109
109
 
110
110
  function parseArgs(argv) {
111
- const out = { maxAgeMs: 24 * 3600 * 1000, source: null, force: false, noNetwork: false, cacheDir: DEFAULT_CACHE, quiet: false, help: false };
111
+ const out = { maxAgeMs: 24 * 3600 * 1000, source: null, force: false, noNetwork: false, cacheDir: DEFAULT_CACHE, quiet: false, help: false, maxErrors: 0 };
112
112
  for (let i = 2; i < argv.length; i++) {
113
113
  const a = argv[i];
114
114
  if (a === "--force") out.force = true;
@@ -121,6 +121,12 @@ function parseArgs(argv) {
121
121
  else if (a.startsWith("--max-age=")) out.maxAgeMs = parseDuration(a.slice("--max-age=".length));
122
122
  else if (a === "--cache-dir") out.cacheDir = path.resolve(argv[++i]);
123
123
  else if (a.startsWith("--cache-dir=")) out.cacheDir = path.resolve(a.slice("--cache-dir=".length));
124
+ // Per-entry fetch-error tolerance. An integer is an absolute budget; an
125
+ // "<N>%" string is a fraction of the planned fetch count. A malformed
126
+ // value is recorded as an arg error so main() refuses with exit 2 rather
127
+ // than silently falling back to an unbounded tolerance.
128
+ else if (a === "--max-errors") { try { out.maxErrors = parseErrorThreshold(argv[++i]); } catch (e) { out._argError = e.message; } }
129
+ else if (a.startsWith("--max-errors=")) { try { out.maxErrors = parseErrorThreshold(a.slice("--max-errors=".length)); } catch (e) { out._argError = e.message; } }
124
130
  // Any remaining --flag is an unrecognized typo. Record it; main() refuses
125
131
  // before any network work rather than silently dropping it.
126
132
  else if (typeof a === "string" && a.startsWith("--")) {
@@ -145,6 +151,73 @@ function parseDuration(s) {
145
151
  return n * mult;
146
152
  }
147
153
 
154
+ // Parse a --max-errors value into either an absolute integer budget or a
155
+ // percentage marker ("<N>%"). Throws on anything else so a typo can't degrade
156
+ // into an unbounded tolerance.
157
+ function parseErrorThreshold(s) {
158
+ const str = String(s == null ? "" : s).trim();
159
+ const m = str.match(/^(\d+)(%?)$/);
160
+ if (!m) throw new Error(`prefetch: invalid --max-errors "${s}" (expected an integer or a percentage like "50" or "5%")`);
161
+ return m[2] === "%" ? `${m[1]}%` : Number(m[1]);
162
+ }
163
+
164
+ // Total entries a run planned to fetch (fetched + skipped-fresh + errored).
165
+ // The denominator for a percentage error budget.
166
+ function plannedCount(result) {
167
+ if (!result) return 0;
168
+ return (result.fetched || 0) + (result.skipped_fresh || 0) + (result.errors || 0);
169
+ }
170
+
171
+ // Resolve a --max-errors value (absolute number, "<N>%" string, or null) into
172
+ // an absolute count against the planned total.
173
+ function errorBudget(maxErrors, planned) {
174
+ if (maxErrors == null) return 0;
175
+ if (typeof maxErrors === "number") return Number.isFinite(maxErrors) ? maxErrors : 0;
176
+ const m = String(maxErrors).match(/^(\d+)%$/);
177
+ if (m) return Math.floor((Number(m[1]) / 100) * (planned || 0));
178
+ const n = Number(maxErrors);
179
+ return Number.isFinite(n) ? n : 0;
180
+ }
181
+
182
+ // Decide prefetch's 0-vs-1 exit code from a completed run. Per-entry fetch
183
+ // errors are counted only after the job queue exhausts its retries, so they
184
+ // are genuine failures — but a best-effort cache warm should tolerate a few
185
+ // transient upstream misses. `opts.maxErrors` is the budget (default 0, so any
186
+ // error exits 1 — the strict contract a manual operator expects). Fatal
187
+ // errors (bad flags, an unhandled throw) are handled in main() and exit 2.
188
+ function exitCodeForResult(result, opts = {}) {
189
+ const errors = (result && result.errors) || 0;
190
+ if (errors === 0) return 0;
191
+ // A source that landed no usable entries this run — nothing freshly fetched
192
+ // and nothing already fresh in the cache, yet errors recorded — is entirely
193
+ // unreachable, and the refresh would silently skip it. A dead KEV feed is
194
+ // only one error (well under any global budget) but means the run missed
195
+ // every new KEV flag. Fail regardless of the budget so a single fully-dead
196
+ // feed can't pass quietly.
197
+ const bySource = (result && result.by_source) || {};
198
+ for (const s of Object.values(bySource)) {
199
+ if (s && (s.errors || 0) > 0 && (s.fetched || 0) === 0 && (s.skipped_fresh || 0) === 0) {
200
+ return 1;
201
+ }
202
+ }
203
+ const budget = errorBudget(opts.maxErrors, plannedCount(result));
204
+ return errors > budget ? 1 : 0;
205
+ }
206
+
207
+ // One-line run summary. When a run has errors, names the per-source counts so
208
+ // "1 error(s)" in a --quiet log is actionable instead of a blind count.
209
+ function formatSummary(result, opts = {}) {
210
+ let line = `prefetch summary: ${result.fetched} fetched, ${result.skipped_fresh} fresh, ${result.errors} error(s)`;
211
+ if (result.errors > 0 && result.by_source) {
212
+ const parts = Object.entries(result.by_source)
213
+ .filter(([, s]) => s && s.errors > 0)
214
+ .map(([name, s]) => `${name}=${s.errors}`);
215
+ if (parts.length) line += ` [${parts.join(", ")}]`;
216
+ }
217
+ if (opts.noNetwork) line += " (dry-run)";
218
+ return line;
219
+ }
220
+
148
221
  function printHelp() {
149
222
  console.log(`prefetch — warm a local cache of every upstream artifact this project consumes.
150
223
 
@@ -589,7 +662,10 @@ async function prefetch(options = {}) {
589
662
  .catch((err) => {
590
663
  result.errors++;
591
664
  result.by_source[item.source].errors++;
592
- log(` [${item.source}] ${item.id}error: ${err.message}`);
665
+ // Errors go to stderr unconditionally they are diagnostics, not the
666
+ // per-entry success chatter --quiet suppresses. A CI run with --quiet
667
+ // still surfaces which source/id failed.
668
+ console.error(` [${item.source}] ${item.id} — error: ${err.message}`);
593
669
  });
594
670
  });
595
671
 
@@ -618,7 +694,7 @@ async function prefetch(options = {}) {
618
694
  // (the noisy part) but the operator still needs one line confirming success.
619
695
  // Without this, --quiet + --no-network was zero output even on dry-run
620
696
  // success, leaving operators unsure if the command had run at all.
621
- console.log(`prefetch summary: ${result.fetched} fetched, ${result.skipped_fresh} fresh, ${result.errors} error(s)${opts.noNetwork ? " (dry-run)" : ""}`);
697
+ console.log(formatSummary(result, { noNetwork: opts.noNetwork }));
622
698
  return result;
623
699
  }
624
700
 
@@ -683,7 +759,7 @@ function readCached(cacheDir, source, id, opts = {}) {
683
759
  // message's known list.
684
760
  const PREFETCH_KNOWN_FLAGS = Object.freeze([
685
761
  "--force", "--no-network", "--dry-run", "--air-gap", "--quiet", "--help", "-h",
686
- "--source", "--max-age", "--cache-dir",
762
+ "--source", "--max-age", "--cache-dir", "--max-errors",
687
763
  ]);
688
764
 
689
765
  async function main() {
@@ -693,6 +769,19 @@ async function main() {
693
769
  return;
694
770
  }
695
771
 
772
+ // A malformed --max-errors value is a usage error — refuse with exit 2
773
+ // (prefetch's usage-error convention) rather than running with an
774
+ // unintended tolerance.
775
+ if (opts._argError) {
776
+ process.stderr.write(JSON.stringify({
777
+ ok: false,
778
+ verb: "prefetch",
779
+ error: opts._argError,
780
+ }) + "\n");
781
+ process.exitCode = 2;
782
+ return;
783
+ }
784
+
696
785
  // Reject unknown flags BEFORE any network work. A swallowed typo (e.g.
697
786
  // `--max-aeg 12h`) previously fell through to a default full-cache fetch.
698
787
  // Exit 2 matches prefetch's existing usage-error convention (invalid
@@ -723,7 +812,7 @@ async function main() {
723
812
  // the `ci` #100 stdout-flush regression.
724
813
  try {
725
814
  const result = await prefetch(opts);
726
- process.exitCode = result.errors > 0 ? 1 : 0;
815
+ process.exitCode = exitCodeForResult(result, opts);
727
816
  } catch (err) {
728
817
  console.error(`prefetch: fatal: ${err.message}`);
729
818
  process.exitCode = 2;
@@ -736,6 +825,9 @@ module.exports = {
736
825
  prefetch,
737
826
  readCached,
738
827
  parseArgs,
828
+ parseErrorThreshold,
829
+ exitCodeForResult,
830
+ formatSummary,
739
831
  SOURCES,
740
832
  DEFAULT_CACHE,
741
833
  // Ed25519 _index.json signing + verification. Exported so
@@ -38,6 +38,7 @@
38
38
  const fs = require("fs");
39
39
  const path = require("path");
40
40
  const { execFileSync } = require("child_process");
41
+ const { selectNvdCvss, cvssVersionOf } = require("./cvss");
41
42
 
42
43
  const ROOT = path.join(__dirname, "..");
43
44
  const ABS = (p) => path.join(ROOT, p);
@@ -932,17 +933,27 @@ function nvdDiffFromCache(ctx) {
932
933
  if (!payload) { errors++; continue; }
933
934
  const vuln = payload.vulnerabilities?.[0]?.cve;
934
935
  if (!vuln) continue;
935
- const m = vuln.metrics || {};
936
- const ordered = [...(m.cvssMetricV31 || []), ...(m.cvssMetricV30 || []), ...(m.cvssMetricV2 || [])];
937
- const primary = ordered.find((x) => x.type === "Primary") || ordered[0];
938
- const upScore = typeof primary?.cvssData?.baseScore === "number" ? primary.cvssData.baseScore : null;
939
- const upVector = primary?.cvssData?.vectorString || null;
936
+ // Prefer the newest CVSS version NVD publishes (Primary within that
937
+ // version), and normalize a bare v2 vector to its canonical prefix.
938
+ const up = selectNvdCvss(vuln.metrics);
939
+ if (!up) continue;
940
940
  const local = ctx.cveCatalog[id];
941
- if (upScore != null && local.cvss_score != null && Math.abs(upScore - local.cvss_score) > 0.05) {
942
- diffs.push({ id, field: "cvss_score", before: local.cvss_score, after: upScore, severity: "high" });
943
- }
944
- if (upVector && local.cvss_vector && upVector !== local.cvss_vector) {
945
- diffs.push({ id, field: "cvss_vector", before: local.cvss_vector, after: upVector, severity: "medium" });
941
+ // Never regress a curated higher-version CVSS to an older upstream metric.
942
+ // NVD keeps many pre-2016 CVEs at v2 only (or tags v2 "Primary" over a
943
+ // v3.1 "Secondary"); the catalog has been curated to v3.1. When the
944
+ // selected upstream metric is an older CVSS version than the curated one,
945
+ // suppress both the score and the vector diff. A same-version drift (a
946
+ // genuine NVD re-score) still flows through.
947
+ const localVersion = cvssVersionOf(local.cvss_vector);
948
+ const isDowngrade =
949
+ up.version != null && localVersion != null && up.version < localVersion;
950
+ if (!isDowngrade) {
951
+ if (up.baseScore != null && local.cvss_score != null && Math.abs(up.baseScore - local.cvss_score) > 0.05) {
952
+ diffs.push({ id, field: "cvss_score", before: local.cvss_score, after: up.baseScore, severity: "high" });
953
+ }
954
+ if (up.vector && local.cvss_vector && up.vector !== local.cvss_vector) {
955
+ diffs.push({ id, field: "cvss_vector", before: local.cvss_vector, after: up.vector, severity: "medium" });
956
+ }
946
957
  }
947
958
  }
948
959
  const status = errors === 0 ? "ok" : errors === cves.length ? "unreachable" : "partial";
@@ -1728,4 +1739,4 @@ if (require.main === module) {
1728
1739
  });
1729
1740
  }
1730
1741
 
1731
- module.exports = { ALL_SOURCES, loadCtx, parseArgs, seedSingleAdvisory, withCatalogLock, writeJsonAtomic };
1742
+ module.exports = { ALL_SOURCES, loadCtx, parseArgs, seedSingleAdvisory, withCatalogLock, writeJsonAtomic, nvdDiffFromCache };
@@ -19,7 +19,7 @@
19
19
  "description": { "type": "string", "minLength": 1 },
20
20
  "homepage": { "type": "string", "format": "uri" },
21
21
  "license": { "type": "string", "minLength": 1 },
22
- "atlas_version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" },
22
+ "atlas_version": { "type": "string", "pattern": "^([0-9]{4}\\.[0-9]{2}(\\.[0-9]+)?|[0-9]+\\.[0-9]+\\.[0-9]+)$", "description": "ATLAS pin: CalVer YYYY.MM[.N] (current upstream scheme since v2026.05) or legacy 3-part semver." },
23
23
  "threat_review_date": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" },
24
24
  "sources_dir": { "type": "string" },
25
25
  "agents_dir": { "type": "string" },
@@ -52,7 +52,7 @@
52
52
  "type": "string",
53
53
  "pattern": "^AML\\.T[0-9]{4}(\\.[0-9]{3})?$"
54
54
  },
55
- "description": "MITRE ATLAS TTP IDs at the pinned version (currently v5.6.0)."
55
+ "description": "MITRE ATLAS TTP IDs at the pinned version (currently v2026.05)."
56
56
  },
57
57
  "attack_refs": {
58
58
  "type": "array",
@@ -30,9 +30,9 @@
30
30
  * operator must read.
31
31
  *
32
32
  * API:
33
- * getAtlasVersion() → "5.6.0"
34
- * getAttackVersion() → "19.0"
35
- * getAtlasReleaseDate() → "2026-05-08"
33
+ * getAtlasVersion() → "2026.05"
34
+ * getAttackVersion() → "19.1"
35
+ * getAtlasReleaseDate() → "2026-05-27"
36
36
  * getAllPins() → { atlas_version, atlas_release_date, attack_version, ... }
37
37
  */
38
38
 
@@ -1,7 +1,7 @@
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-06-05T18:14:35.343Z",
4
- "atlas_version": "5.6.0",
3
+ "_generated_at": "2026-06-10T15:20:38.137Z",
4
+ "atlas_version": "2026.05",
5
5
  "skill_count": 51,
6
6
  "skills": [
7
7
  {
@@ -1 +1 @@
1
- 507b7d47541c9a338602aee3fcedac2233ca1c0046bd41735adbf5b87cd0f50b manifest-snapshot.json
1
+ b4e322034ba1ebafa3e706772a9cada8131a52adc42197ecda1705bb13d2b131 manifest-snapshot.json