@blamejs/exceptd-skills 0.16.24 → 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.
- package/AGENTS.md +5 -5
- package/ARCHITECTURE.md +3 -3
- package/CHANGELOG.md +18 -0
- package/CONTEXT.md +2 -2
- package/README.md +5 -5
- package/agents/threat-researcher.md +2 -2
- package/data/_indexes/_meta.json +39 -39
- package/data/_indexes/activity-feed.json +240 -240
- package/data/_indexes/catalog-summaries.json +3 -3
- package/data/_indexes/currency.json +64 -64
- package/data/_indexes/recipes.json +1 -1
- package/data/_indexes/section-offsets.json +510 -510
- package/data/_indexes/summary-cards.json +33 -33
- package/data/_indexes/token-budget.json +200 -200
- package/data/atlas-ttps.json +7 -7
- package/data/attack-techniques.json +5 -5
- package/data/framework-control-gaps.json +3 -3
- package/lib/auto-discovery.js +7 -9
- package/lib/cvss.js +108 -0
- package/lib/prefetch.js +97 -5
- package/lib/refresh-external.js +62 -26
- package/lib/schemas/manifest.schema.json +1 -1
- package/lib/schemas/skill-frontmatter.schema.json +1 -1
- package/lib/version-pins.js +3 -3
- package/manifest-snapshot.json +2 -2
- package/manifest-snapshot.sha256 +1 -1
- package/manifest.json +124 -124
- package/package.json +1 -1
- package/sbom.cdx.json +133 -118
- package/scripts/builders/catalog-summaries.js +1 -1
- package/scripts/builders/recipes.js +1 -1
- package/scripts/run-e2e-scenarios.js +48 -17
- package/skills/age-gates-child-safety/skill.md +3 -3
- package/skills/ai-attack-surface/skill.md +4 -4
- package/skills/ai-c2-detection/skill.md +5 -5
- package/skills/api-security/skill.md +2 -2
- package/skills/attack-surface-pentest/skill.md +4 -4
- package/skills/cloud-security/skill.md +3 -3
- package/skills/compliance-theater/skill.md +3 -3
- package/skills/container-runtime-security/skill.md +3 -3
- package/skills/coordinated-vuln-disclosure/skill.md +2 -2
- package/skills/defensive-countermeasure-mapping/skill.md +3 -3
- package/skills/dlp-gap-analysis/skill.md +5 -5
- package/skills/exploit-scoring/skill.md +2 -2
- package/skills/framework-gap-analysis/skill.md +4 -4
- package/skills/fuzz-testing-strategy/skill.md +2 -2
- package/skills/incident-response-playbook/skill.md +3 -3
- package/skills/mcp-agent-trust/skill.md +2 -2
- package/skills/mlops-security/skill.md +3 -3
- package/skills/ot-ics-security/skill.md +3 -3
- package/skills/policy-exception-gen/skill.md +3 -3
- package/skills/pqc-first/skill.md +2 -2
- package/skills/rag-pipeline-security/skill.md +4 -4
- package/skills/ransomware-response/skill.md +2 -2
- package/skills/sector-energy/skill.md +2 -2
- package/skills/sector-federal-government/skill.md +2 -2
- package/skills/sector-financial/skill.md +4 -4
- package/skills/sector-healthcare/skill.md +3 -3
- package/skills/security-maturity-tiers/skill.md +1 -1
- package/skills/skill-update-loop/skill.md +6 -6
- package/skills/supply-chain-integrity/skill.md +2 -2
- package/skills/threat-model-currency/skill.md +8 -8
- package/skills/threat-modeling-methodology/skill.md +2 -2
- package/skills/webapp-security/skill.md +2 -2
- package/skills/zeroday-gap-learn/skill.md +3 -3
- package/sources/validators/cve-validator.js +12 -13
package/data/atlas-ttps.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_meta": {
|
|
3
3
|
"schema_version": "1.0.0",
|
|
4
|
-
"atlas_version": "
|
|
5
|
-
"atlas_release_date": "2026-05-
|
|
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-
|
|
9
|
-
"last_threat_review": "2026-
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
5
|
-
"last_threat_review": "2026-
|
|
6
|
-
"attack_version": "19.
|
|
7
|
-
"attack_version_date": "2026-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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.",
|
package/lib/auto-discovery.js
CHANGED
|
@@ -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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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:
|
|
199
|
-
cvss_vector:
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
package/lib/refresh-external.js
CHANGED
|
@@ -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);
|
|
@@ -109,13 +110,17 @@ function parseArgs(argv) {
|
|
|
109
110
|
// older than 7d or one that was prefetched without a signing keypair.
|
|
110
111
|
// EXCEPTD_FORCE_STALE=1 mirrors for non-interactive automation.
|
|
111
112
|
else if (a === "--force-stale") out.forceStale = true;
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
113
|
+
// --prefetch / --no-network are prefetch-cache operations. Capture them so
|
|
114
|
+
// main() can delegate to lib/prefetch.js (the same routing bin/exceptd.js
|
|
115
|
+
// performs) when this script is invoked directly — otherwise the help
|
|
116
|
+
// text's "report-only, no cache write" promise for --no-network is a lie
|
|
117
|
+
// on the direct path, which would fall through to the live refresh loop.
|
|
118
|
+
else if (a === "--no-network") { out.noNetwork = true; }
|
|
119
|
+
else if (a === "--prefetch") { out.prefetch = true; }
|
|
120
|
+
// Remaining bin-translated aliases are tolerated as no-ops at this layer
|
|
121
|
+
// so the unknown-flag guard below doesn't false-reject them.
|
|
117
122
|
else if (
|
|
118
|
-
a === "--
|
|
123
|
+
a === "--indexes-only" ||
|
|
119
124
|
a === "--network" || a === "--curate" || a === "--force-stale-acked"
|
|
120
125
|
) { /* accepted, no-op at this layer */ }
|
|
121
126
|
// Any remaining --flag is an unrecognized typo. Record it; refuse after
|
|
@@ -148,8 +153,10 @@ Modes:
|
|
|
148
153
|
place. Same trust anchor as \`npm update -g\`, only the
|
|
149
154
|
data slice changes — useful when you want fresher
|
|
150
155
|
intel without re-resolving CLI/lib code.
|
|
151
|
-
--prefetch
|
|
152
|
-
|
|
156
|
+
--prefetch populate the cache for offline use. Equivalent to
|
|
157
|
+
\`exceptd prefetch\`.
|
|
158
|
+
--no-network report-only: list what would be fetched, WITHOUT writing
|
|
159
|
+
the cache (the dry-run opposite of --prefetch).
|
|
153
160
|
--from-cache [<p>] read from prefetch cache (default .cache/upstream).
|
|
154
161
|
Combine with --apply to upsert against cached data
|
|
155
162
|
entirely offline. Cache must be pre-populated via --prefetch.
|
|
@@ -926,17 +933,27 @@ function nvdDiffFromCache(ctx) {
|
|
|
926
933
|
if (!payload) { errors++; continue; }
|
|
927
934
|
const vuln = payload.vulnerabilities?.[0]?.cve;
|
|
928
935
|
if (!vuln) continue;
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
const
|
|
932
|
-
|
|
933
|
-
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;
|
|
934
940
|
const local = ctx.cveCatalog[id];
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
+
}
|
|
940
957
|
}
|
|
941
958
|
}
|
|
942
959
|
const status = errors === 0 ? "ok" : errors === cves.length ? "unreachable" : "partial";
|
|
@@ -1099,15 +1116,16 @@ function loadCtx(opts) {
|
|
|
1099
1116
|
const abs = path.resolve(opts.fromCache);
|
|
1100
1117
|
ctx.cacheDir = abs;
|
|
1101
1118
|
if (!fs.existsSync(abs)) {
|
|
1102
|
-
//
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
1106
|
-
//
|
|
1119
|
+
// Operators following the air-gap workflow hit this with an unhelpful
|
|
1120
|
+
// "path does not exist" stack trace. The cache is populated by
|
|
1121
|
+
// `exceptd refresh --prefetch` (which routes to prefetch) — NOT by
|
|
1122
|
+
// `--no-network`, which is the report-only dry run that writes nothing.
|
|
1123
|
+
// Tell them exactly that, and emit a structured JSON error to stderr
|
|
1124
|
+
// instead of a fatal stack trace.
|
|
1107
1125
|
const err = new Error(
|
|
1108
1126
|
`refresh: --from-cache path does not exist: ${abs}\n` +
|
|
1109
|
-
`Hint: the cache is populated by running \`exceptd refresh --
|
|
1110
|
-
`on a connected host first. Air-gap workflow: (1) on connected host: \`exceptd refresh --
|
|
1127
|
+
`Hint: the cache is populated by running \`exceptd refresh --prefetch\` ` +
|
|
1128
|
+
`on a connected host first. Air-gap workflow: (1) on connected host: \`exceptd refresh --prefetch\`, ` +
|
|
1111
1129
|
`(2) copy .cache/upstream/ across the boundary, (3) on offline host: \`exceptd refresh --from-cache --apply\`.`
|
|
1112
1130
|
);
|
|
1113
1131
|
err._exceptd_hint = true;
|
|
@@ -1526,6 +1544,24 @@ async function main() {
|
|
|
1526
1544
|
return;
|
|
1527
1545
|
}
|
|
1528
1546
|
|
|
1547
|
+
// `--prefetch` / `--no-network` are prefetch-cache operations. The operator
|
|
1548
|
+
// path (bin/exceptd.js) routes them to lib/prefetch.js; when this script is
|
|
1549
|
+
// invoked directly, delegate the SAME way so behavior matches the help text:
|
|
1550
|
+
// --prefetch populates the cache, --no-network is a report-only dry run that
|
|
1551
|
+
// writes nothing. Without this, the direct path fell through to the live
|
|
1552
|
+
// refresh loop and could egress + write refresh-report.json despite
|
|
1553
|
+
// --no-network.
|
|
1554
|
+
if (opts.prefetch || opts.noNetwork) {
|
|
1555
|
+
const { spawnSync } = require("child_process");
|
|
1556
|
+
const pfArgs = [require.resolve("./prefetch.js")];
|
|
1557
|
+
if (opts.noNetwork) pfArgs.push("--no-network");
|
|
1558
|
+
if (opts.source) pfArgs.push("--source", opts.source);
|
|
1559
|
+
if (opts.quiet) pfArgs.push("--quiet");
|
|
1560
|
+
const r = spawnSync(process.execPath, pfArgs, { stdio: "inherit" });
|
|
1561
|
+
process.exitCode = r.status == null ? 1 : r.status;
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1529
1565
|
// v0.12.0: `--advisory <id>` short-circuits the normal source loop and
|
|
1530
1566
|
// seeds a single CVE catalog entry from GHSA. Exits non-zero ("draft
|
|
1531
1567
|
// written, please review") so CI pipelines surface the needed editorial
|
|
@@ -1703,4 +1739,4 @@ if (require.main === module) {
|
|
|
1703
1739
|
});
|
|
1704
1740
|
}
|
|
1705
1741
|
|
|
1706
|
-
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
|
|
55
|
+
"description": "MITRE ATLAS TTP IDs at the pinned version (currently v2026.05)."
|
|
56
56
|
},
|
|
57
57
|
"attack_refs": {
|
|
58
58
|
"type": "array",
|
package/lib/version-pins.js
CHANGED
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
* operator must read.
|
|
31
31
|
*
|
|
32
32
|
* API:
|
|
33
|
-
* getAtlasVersion() → "
|
|
34
|
-
* getAttackVersion() → "19.
|
|
35
|
-
* getAtlasReleaseDate() → "2026-05-
|
|
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
|
|
package/manifest-snapshot.json
CHANGED
|
@@ -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-
|
|
4
|
-
"atlas_version": "
|
|
3
|
+
"_generated_at": "2026-06-10T15:20:38.137Z",
|
|
4
|
+
"atlas_version": "2026.05",
|
|
5
5
|
"skill_count": 51,
|
|
6
6
|
"skills": [
|
|
7
7
|
{
|
package/manifest-snapshot.sha256
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
b4e322034ba1ebafa3e706772a9cada8131a52adc42197ecda1705bb13d2b131 manifest-snapshot.json
|