@blamejs/exceptd-skills 0.10.2 → 0.10.3

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.
@@ -0,0 +1,170 @@
1
+ # Sources
2
+
3
+ The sources directory is the data quality gate for exceptd Security. Every claim in every skill must trace to a primary source. Bad data in produces bad analysis out — this directory makes source integrity a first-class concern.
4
+
5
+ ## The Problem: Data Corruption in Security Intelligence
6
+
7
+ Security intelligence has several common failure modes:
8
+ - **Stale data**: A CVE is marked as "no public PoC" when a PoC went public six months ago
9
+ - **Misattribution**: A CVSS score copied from a secondary source that applied the wrong vector
10
+ - **Fabricated details**: AI-summarized threat intel that introduced plausible-but-wrong specifics
11
+ - **Framework version drift**: A control ID that changed in a framework revision but wasn't updated in skills
12
+ - **Dead links**: Source URLs that return 404 — removing the ability to verify
13
+
14
+ The sources system prevents these failures by:
15
+ 1. Maintaining a registry of authoritative primary sources per data type
16
+ 2. Providing validators that check data against primary sources
17
+ 3. Tracking source verification dates and flagging stale verifications
18
+ 4. Making multi-agent research verifiable and auditable
19
+
20
+ ---
21
+
22
+ ## Directory Structure
23
+
24
+ ```
25
+ sources/
26
+ ├── README.md # This file
27
+ ├── index.json # Source registry — authoritative sources per data type
28
+ ├── SOURCES.md # Guide for adding and verifying sources
29
+ ├── validators/
30
+ │ ├── cve-validator.js # Cross-check CVE data against NVD API
31
+ │ ├── kev-validator.js # Verify CISA KEV status against official feed
32
+ │ ├── atlas-validator.js # Verify ATLAS TTP IDs against mitre.org
33
+ │ └── framework-validator.js # Verify framework control IDs
34
+ └── feeds/
35
+ ├── cisa-kev-snapshot.json # Snapshot of CISA KEV at last verification
36
+ ├── atlas-version.json # Current ATLAS version metadata
37
+ └── nvd-recent.json # Recent NVD entries (last 30 days)
38
+ ```
39
+
40
+ ---
41
+
42
+ ## Primary Sources by Data Type
43
+
44
+ ### CVE Data
45
+
46
+ | Field | Authoritative Source | Update Frequency |
47
+ |---|---|---|
48
+ | CVSS score + vector | NVD (nvd.nist.gov/vuln/detail/CVE-XXXX) | On NVD analysis |
49
+ | CISA KEV status | CISA KEV catalog (cisa.gov/known-exploited-vulnerabilities-catalog) | Real-time feed |
50
+ | PoC availability | NVD references + researcher advisories | Monitor CVE references |
51
+ | Active exploitation | CISA KEV, threat intelligence, incident reports | Monitor |
52
+ | Affected versions | Vendor advisory (Red Hat, Ubuntu, etc.) | On vendor advisory |
53
+ | Patch availability | Vendor advisory | On vendor advisory |
54
+ | Live patch support | kpatch.com, ubuntu.com/security/livepatch, suse.com/products/live-patching | On vendor announcement |
55
+
56
+ **Never use as primary source:** Wikipedia, news articles, blog posts, AI-generated summaries, secondary aggregators without NVD cross-reference.
57
+
58
+ ### ATLAS TTPs
59
+
60
+ | Field | Authoritative Source |
61
+ |---|---|
62
+ | TTP ID | atlas.mitre.org (canonical IDs may change between versions) |
63
+ | TTP name | atlas.mitre.org/techniques/ |
64
+ | TTP version | atlas.mitre.org/resources/changelog |
65
+
66
+ **ATLAS version pinning:** All skills reference a specific ATLAS version. When ATLAS updates, TTP IDs must be re-verified. The `atlas-validator.js` checks all skill `atlas_refs` against the current published ATLAS.
67
+
68
+ ### Framework Controls
69
+
70
+ | Framework | Authoritative Source |
71
+ |---|---|
72
+ | NIST 800-53 Rev 5 | csrc.nist.gov/publications/detail/sp/800-53/rev-5/final |
73
+ | ISO 27001:2022 | iso.org/standard/27001 (requires purchase for full text) |
74
+ | SOC 2 | aicpa.org (TSC 2017) |
75
+ | PCI DSS 4.0 | pcisecuritystandards.org/document_library |
76
+ | NIS2 | eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32022L2555 |
77
+ | DORA | eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32022R2554 |
78
+ | EU AI Act | eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32024R1689 |
79
+ | EU CRA | Official Journal of EU |
80
+ | NCSC CAF | ncsc.gov.uk/collection/cyber-assessment-framework |
81
+ | ASD ISM | cyber.gov.au/resources-business-and-government/essential-cyber-security/ism |
82
+ | ASD Essential 8 | cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight |
83
+ | MAS TRM | mas.gov.sg/regulation/guidelines/technology-risk-management-guidelines |
84
+ | CIS Controls v8 | cisecurity.org/controls/v8 |
85
+ | CSA CCM v4 | cloudsecurityalliance.org/research/cloud-controls-matrix |
86
+
87
+ ### PQC Standards
88
+
89
+ | Standard | Authoritative Source |
90
+ |---|---|
91
+ | FIPS 203 (ML-KEM) | csrc.nist.gov/pubs/fips/203/final |
92
+ | FIPS 204 (ML-DSA) | csrc.nist.gov/pubs/fips/204/final |
93
+ | FIPS 205 (SLH-DSA) | csrc.nist.gov/pubs/fips/205/final |
94
+ | FIPS 206 (HQC, pending) | csrc.nist.gov/projects/post-quantum-cryptography |
95
+ | OpenSSL 3.5 release notes | github.com/openssl/openssl/blob/master/CHANGES.md |
96
+ | CNSA 2.0 | cnss.gov |
97
+
98
+ ---
99
+
100
+ ## Source Verification Requirement
101
+
102
+ Every entry in `data/cve-catalog.json` must have a `source_verified` field:
103
+ ```json
104
+ {
105
+ "source_verified": "2026-05-01",
106
+ "verification_sources": [
107
+ "https://nvd.nist.gov/vuln/detail/CVE-2026-31431",
108
+ "https://www.cisa.gov/known-exploited-vulnerabilities-catalog"
109
+ ]
110
+ }
111
+ ```
112
+
113
+ A `source_verified` date older than 90 days triggers a reverification requirement in the skill-update-loop.
114
+
115
+ ---
116
+
117
+ ## Multi-Agent Research Protocol
118
+
119
+ When agents research new threat intelligence, they must:
120
+ 1. Identify primary sources (from the registry above)
121
+ 2. Record what was found at each source and when
122
+ 3. Cross-reference across at least 2 independent sources for critical claims
123
+ 4. Flag any claim that could only be verified from a single source
124
+ 5. Record the agent ID and timestamp in the `source_verified` audit trail
125
+
126
+ See `agents/threat-researcher.md` for the research agent protocol.
127
+
128
+ ---
129
+
130
+ ## Bad Data Prevention
131
+
132
+ These categories of sources are **rejected** for skill data:
133
+
134
+ | Source Type | Why Rejected |
135
+ |---|---|
136
+ | AI-generated summaries without primary source citation | Plausible hallucination risk |
137
+ | News articles | Often inaccurate on technical details, not updated when details change |
138
+ | Blog posts | No editorial standard, often repost errors from other blogs |
139
+ | Wikipedia | Community-edited, not authoritative for CVE details or framework text |
140
+ | Secondary aggregators without NVD cross-reference | May lag or misquote NVD |
141
+ | Social media / X posts | Not citable, not stable |
142
+ | Forum posts | Not authoritative |
143
+
144
+ The only exception: researcher/discoverer announcements about their own research (e.g., Hyunwoo Kim's Dirty Frag disclosure) may be used as a source alongside NVD, since the researcher is the primary source for their own findings.
145
+
146
+ ---
147
+
148
+ ## Validators
149
+
150
+ Real validation against primary sources lives in `sources/validators/`. These are
151
+ zero-dependency Node 24 modules (stdlib `fetch`, `AbortController`, `fs/promises`
152
+ only). Every network call has a 10s timeout and degrades to an `unreachable`
153
+ status rather than throwing — the validators are safe to run in airgapped CI.
154
+
155
+ | Module | Purpose | Upstream |
156
+ |---|---|---|
157
+ | [`validators/cve-validator.js`](validators/cve-validator.js) | Cross-check one CVE's CVSS score, vector, and KEV status against NVD and the CISA KEV feed. Caches the KEV feed once per process. | NVD `services.nvd.nist.gov` + CISA KEV JSON |
158
+ | [`validators/atlas-validator.js`](validators/atlas-validator.js) | Confirm the pinned MITRE ATLAS version (in `manifest.json` and `sources/index.json`) matches the latest upstream release. | GitHub releases for `mitre-atlas/atlas-data`, raw `ATLAS.yaml` fallback |
159
+ | [`validators/index.js`](validators/index.js) | Barrel export plus `validateAllCves(catalog)` for catalog-wide aggregation with bounded concurrency. | — |
160
+
161
+ The orchestrator wires the CVE validator into the CLI:
162
+
163
+ ```
164
+ node orchestrator/index.js validate-cves # live cross-check, non-zero exit on drift
165
+ node orchestrator/index.js validate-cves --offline # local view only, no network
166
+ node orchestrator/index.js validate-cves --no-fail # report drift but always exit 0
167
+ ```
168
+
169
+ Feed snapshots are written under `sources/feeds/`; see `sources/feeds/README.md`
170
+ for the cache contract and freshness thresholds.
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * atlas-validator.js — Confirm pinned MITRE ATLAS version against upstream.
5
+ *
6
+ * Zero npm dependencies. Node 24 stdlib only.
7
+ *
8
+ * MITRE ATLAS does not (as of v5.x) publish a stable machine-readable changelog JSON.
9
+ * The canonical source-of-truth for releases is the public GitHub repo:
10
+ * https://raw.githubusercontent.com/mitre-atlas/atlas-data/main/dist/ATLAS.yaml
11
+ * which carries an `id: ATLAS` / `version: x.y.z` header. The GitHub releases API
12
+ * also lists tagged versions:
13
+ * https://api.github.com/repos/mitre-atlas/atlas-data/releases/latest
14
+ *
15
+ * We prefer the releases API (lightweight JSON, no YAML parsing), fall back to the
16
+ * raw YAML version line, and finally report unreachable if both fail. Both are
17
+ * read-only public endpoints; no auth is required.
18
+ *
19
+ * Exported:
20
+ * validateAtlasVersion(opts?) -> Promise<{
21
+ * pinned: string|null,
22
+ * pinned_sources: { manifest: string|null, index: string|null },
23
+ * latest: string|null,
24
+ * drift: boolean,
25
+ * status: 'match'|'drift'|'unreachable'|'unknown',
26
+ * fetched_from: string|null,
27
+ * error: string|null
28
+ * }>
29
+ */
30
+
31
+ const fs = require('node:fs/promises');
32
+ const path = require('node:path');
33
+
34
+ const REQUEST_TIMEOUT_MS = 10_000;
35
+ const USER_AGENT = 'exceptd-security/atlas-validator (+https://exceptd.com)';
36
+
37
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
38
+ const MANIFEST_PATH = path.join(REPO_ROOT, 'manifest.json');
39
+ const SOURCES_INDEX_PATH = path.join(REPO_ROOT, 'sources', 'index.json');
40
+
41
+ const GH_RELEASE_URL = 'https://api.github.com/repos/mitre-atlas/atlas-data/releases/latest';
42
+ const RAW_ATLAS_YAML = 'https://raw.githubusercontent.com/mitre-atlas/atlas-data/main/dist/ATLAS.yaml';
43
+
44
+ async function timedFetch(url, accept = 'application/json') {
45
+ const controller = new AbortController();
46
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
47
+ try {
48
+ const res = await fetch(url, {
49
+ signal: controller.signal,
50
+ headers: { 'User-Agent': USER_AGENT, Accept: accept },
51
+ });
52
+ if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
53
+ const body = accept.includes('json') ? await res.json() : await res.text();
54
+ return { ok: true, body };
55
+ } catch (err) {
56
+ const code = err.name === 'AbortError' ? 'timeout' : (err.code || 'network_error');
57
+ return { ok: false, error: `${code}: ${err.message}` };
58
+ } finally {
59
+ clearTimeout(timer);
60
+ }
61
+ }
62
+
63
+ function normalizeVersion(v) {
64
+ if (!v || typeof v !== 'string') return null;
65
+ // Strip leading "v" / "ATLAS-v" prefixes; trim.
66
+ return v.trim().replace(/^ATLAS[-_ ]?/i, '').replace(/^v/i, '');
67
+ }
68
+
69
+ async function readPinnedVersions() {
70
+ const out = { manifest: null, index: null };
71
+ try {
72
+ const manifest = JSON.parse(await fs.readFile(MANIFEST_PATH, 'utf8'));
73
+ out.manifest = normalizeVersion(
74
+ manifest?._meta?.atlas_version || manifest?.atlas_version || null
75
+ );
76
+ } catch { /* leave null */ }
77
+ try {
78
+ const idx = JSON.parse(await fs.readFile(SOURCES_INDEX_PATH, 'utf8'));
79
+ out.index = normalizeVersion(idx?.sources?.atlas?.current_version || null);
80
+ } catch { /* leave null */ }
81
+ return out;
82
+ }
83
+
84
+ async function fetchLatestFromGithubReleases() {
85
+ const res = await timedFetch(GH_RELEASE_URL);
86
+ if (!res.ok) return { ok: false, error: res.error };
87
+ const tag = res.body?.tag_name || res.body?.name || null;
88
+ const version = normalizeVersion(tag);
89
+ if (!version) return { ok: false, error: 'no tag_name in response' };
90
+ return { ok: true, version, source: 'github-releases' };
91
+ }
92
+
93
+ async function fetchLatestFromRawYaml() {
94
+ const res = await timedFetch(RAW_ATLAS_YAML, 'text/yaml');
95
+ if (!res.ok) return { ok: false, error: res.error };
96
+ // Naive YAML scrape: look for a top-level `version:` line within the first 200 lines.
97
+ const text = String(res.body).split(/\r?\n/).slice(0, 200).join('\n');
98
+ const match = text.match(/^version:\s*['"]?([0-9]+(?:\.[0-9]+){1,2})['"]?\s*$/m);
99
+ if (!match) return { ok: false, error: 'version line not found in ATLAS.yaml' };
100
+ return { ok: true, version: normalizeVersion(match[1]), source: 'raw-yaml' };
101
+ }
102
+
103
+ async function validateAtlasVersion(_opts = {}) {
104
+ const pinned_sources = await readPinnedVersions();
105
+ // Canonical pinned value: prefer manifest._meta or top-level, then sources/index.json.
106
+ const pinned = pinned_sources.manifest || pinned_sources.index || null;
107
+
108
+ // Cross-check that the two pinned locations agree.
109
+ const pinnedDisagree =
110
+ pinned_sources.manifest &&
111
+ pinned_sources.index &&
112
+ pinned_sources.manifest !== pinned_sources.index;
113
+
114
+ // Try GitHub releases first, fall back to raw YAML.
115
+ let upstream = await fetchLatestFromGithubReleases();
116
+ if (!upstream.ok) {
117
+ const fallback = await fetchLatestFromRawYaml();
118
+ if (fallback.ok) upstream = fallback;
119
+ }
120
+
121
+ if (!upstream.ok) {
122
+ return {
123
+ pinned,
124
+ pinned_sources,
125
+ latest: null,
126
+ drift: pinnedDisagree === true, // internal drift is still reportable offline
127
+ status: 'unreachable',
128
+ fetched_from: null,
129
+ error: upstream.error,
130
+ };
131
+ }
132
+
133
+ const latest = upstream.version;
134
+ if (!pinned) {
135
+ return {
136
+ pinned: null,
137
+ pinned_sources,
138
+ latest,
139
+ drift: true,
140
+ status: 'unknown',
141
+ fetched_from: upstream.source,
142
+ error: 'no pinned ATLAS version found in manifest.json or sources/index.json',
143
+ };
144
+ }
145
+
146
+ const drift = pinned !== latest || pinnedDisagree === true;
147
+ return {
148
+ pinned,
149
+ pinned_sources,
150
+ latest,
151
+ drift,
152
+ status: drift ? 'drift' : 'match',
153
+ fetched_from: upstream.source,
154
+ error: null,
155
+ };
156
+ }
157
+
158
+ module.exports = { validateAtlasVersion };
@@ -0,0 +1,277 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * cve-validator.js — Cross-check local CVE catalog entries against NVD, CISA KEV, and EPSS.
5
+ *
6
+ * Zero npm dependencies. Node 24 stdlib only.
7
+ *
8
+ * Exported:
9
+ * validateCve(cveId, localCatalogEntry) -> Promise<ValidationResult>
10
+ * getKevCache() -> Map (KEV map by CVE ID, lazy-loaded)
11
+ * resetKevCache() -> void (testing helper)
12
+ *
13
+ * ValidationResult shape:
14
+ * {
15
+ * cve_id: 'CVE-YYYY-NNNNN',
16
+ * status: 'match' | 'drift' | 'unreachable' | 'missing',
17
+ * discrepancies: [ { field, local, fetched, severity } ],
18
+ * fetched: {
19
+ * cvss_score, cvss_vector, in_kev, kev_date,
20
+ * epss: { score, percentile, date } | null,
21
+ * sources: { nvd, kev, epss }
22
+ * },
23
+ * local: {
24
+ * cvss_score, cvss_vector, cisa_kev, cisa_kev_date,
25
+ * epss_score, epss_percentile, epss_date
26
+ * },
27
+ * drift: { local_epss, fetched_epss } | null // populated when EPSS drift is present
28
+ * }
29
+ *
30
+ * Network resilience:
31
+ * - Every fetch has a 10s AbortController timeout.
32
+ * - Network/parse errors return { status: 'unreachable', ... } — never throw.
33
+ * - The KEV feed is fetched once per process and cached in module-level memory.
34
+ * - The EPSS API is queried per-CVE (FIRST recommends per-CVE lookups for fresh data).
35
+ */
36
+
37
+ const NVD_API = 'https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=';
38
+ const KEV_FEED = 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json';
39
+ const EPSS_API = 'https://api.first.org/data/v1/epss?cve=';
40
+ const REQUEST_TIMEOUT_MS = 10_000;
41
+ const EPSS_DRIFT_THRESHOLD = 0.05; // |Δscore| or |Δpercentile| > 0.05 flags drift
42
+ const USER_AGENT = 'exceptd-security/cve-validator (+https://exceptd.com)';
43
+
44
+ let kevCachePromise = null; // Promise<Map<cveId, kevEntry>> | null
45
+ let kevCacheError = null; // { code, message } if last attempt failed (per process)
46
+
47
+ async function timedFetch(url) {
48
+ const controller = new AbortController();
49
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
50
+ try {
51
+ const res = await fetch(url, {
52
+ signal: controller.signal,
53
+ headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
54
+ });
55
+ if (!res.ok) {
56
+ return { ok: false, error: `HTTP ${res.status}`, status: res.status };
57
+ }
58
+ const json = await res.json();
59
+ return { ok: true, json };
60
+ } catch (err) {
61
+ const code = err.name === 'AbortError' ? 'timeout' : (err.code || 'network_error');
62
+ return { ok: false, error: `${code}: ${err.message}`, code };
63
+ } finally {
64
+ clearTimeout(timer);
65
+ }
66
+ }
67
+
68
+ async function loadKevCache() {
69
+ if (kevCachePromise) return kevCachePromise;
70
+ kevCachePromise = (async () => {
71
+ const result = await timedFetch(KEV_FEED);
72
+ if (!result.ok) {
73
+ kevCacheError = { code: 'kev_unreachable', message: result.error };
74
+ return null;
75
+ }
76
+ const map = new Map();
77
+ const items = Array.isArray(result.json?.vulnerabilities) ? result.json.vulnerabilities : [];
78
+ for (const v of items) {
79
+ if (v && v.cveID) map.set(v.cveID, v);
80
+ }
81
+ return map;
82
+ })();
83
+ return kevCachePromise;
84
+ }
85
+
86
+ function getKevCache() {
87
+ return kevCachePromise;
88
+ }
89
+
90
+ function resetKevCache() {
91
+ kevCachePromise = null;
92
+ kevCacheError = null;
93
+ }
94
+
95
+ function extractNvdCvss(nvdJson) {
96
+ // NVD response: vulnerabilities[0].cve.metrics.cvssMetricV31[0].cvssData
97
+ const vuln = nvdJson?.vulnerabilities?.[0]?.cve;
98
+ if (!vuln) return null;
99
+ const metrics = vuln.metrics || {};
100
+ const ordered = [
101
+ ...(metrics.cvssMetricV31 || []),
102
+ ...(metrics.cvssMetricV30 || []),
103
+ ...(metrics.cvssMetricV2 || []),
104
+ ];
105
+ // Prefer Primary type if present
106
+ const primary = ordered.find(m => m.type === 'Primary') || ordered[0];
107
+ if (!primary?.cvssData) return null;
108
+ return {
109
+ score: typeof primary.cvssData.baseScore === 'number' ? primary.cvssData.baseScore : null,
110
+ vector: primary.cvssData.vectorString || null,
111
+ source: primary.source || null,
112
+ };
113
+ }
114
+
115
+ function pushDiscrepancy(list, field, local, fetched, severity = 'warning') {
116
+ list.push({ field, local, fetched, severity });
117
+ }
118
+
119
+ function extractEpss(epssJson, cveId) {
120
+ // FIRST EPSS response shape: { status: "OK", data: [ { cve, epss, percentile, date } ] }
121
+ const data = Array.isArray(epssJson?.data) ? epssJson.data : [];
122
+ if (data.length === 0) return null;
123
+ const row = data.find(r => r?.cve === cveId) || data[0];
124
+ if (!row) return null;
125
+ // EPSS returns strings; coerce defensively.
126
+ const score = row.epss !== undefined && row.epss !== null ? Number(row.epss) : null;
127
+ const percentile = row.percentile !== undefined && row.percentile !== null ? Number(row.percentile) : null;
128
+ return {
129
+ score: Number.isFinite(score) ? score : null,
130
+ percentile: Number.isFinite(percentile) ? percentile : null,
131
+ date: typeof row.date === 'string' ? row.date : null,
132
+ };
133
+ }
134
+
135
+ async function validateCve(cveId, localEntry) {
136
+ if (!cveId || typeof cveId !== 'string') {
137
+ throw new TypeError('validateCve: cveId must be a string');
138
+ }
139
+
140
+ const local = {
141
+ cvss_score: localEntry?.cvss_score ?? null,
142
+ cvss_vector: localEntry?.cvss_vector ?? null,
143
+ cisa_kev: localEntry?.cisa_kev ?? null,
144
+ cisa_kev_date: localEntry?.cisa_kev_date ?? null,
145
+ epss_score: localEntry?.epss_score ?? null,
146
+ epss_percentile: localEntry?.epss_percentile ?? null,
147
+ epss_date: localEntry?.epss_date ?? null,
148
+ };
149
+
150
+ const fetched = {
151
+ cvss_score: null,
152
+ cvss_vector: null,
153
+ in_kev: null,
154
+ kev_date: null,
155
+ epss: null,
156
+ sources: { nvd: null, kev: null, epss: null },
157
+ };
158
+ const discrepancies = [];
159
+ let drift = null;
160
+
161
+ // Run NVD + KEV + EPSS in parallel.
162
+ const [nvdResult, kevMap, epssResult] = await Promise.all([
163
+ timedFetch(NVD_API + encodeURIComponent(cveId)),
164
+ loadKevCache(),
165
+ timedFetch(EPSS_API + encodeURIComponent(cveId)),
166
+ ]);
167
+
168
+ // --- NVD branch ---
169
+ let nvdReachable = false;
170
+ let cveFoundInNvd = false;
171
+ if (nvdResult.ok) {
172
+ nvdReachable = true;
173
+ const totalResults = nvdResult.json?.totalResults ?? nvdResult.json?.vulnerabilities?.length ?? 0;
174
+ if (totalResults === 0) {
175
+ fetched.sources.nvd = { reachable: true, found: false };
176
+ } else {
177
+ cveFoundInNvd = true;
178
+ const cvss = extractNvdCvss(nvdResult.json);
179
+ if (cvss) {
180
+ fetched.cvss_score = cvss.score;
181
+ fetched.cvss_vector = cvss.vector;
182
+ fetched.sources.nvd = { reachable: true, found: true, source: cvss.source };
183
+ } else {
184
+ fetched.sources.nvd = { reachable: true, found: true, source: null, note: 'no cvss metrics in NVD response' };
185
+ }
186
+ }
187
+ } else {
188
+ fetched.sources.nvd = { reachable: false, error: nvdResult.error };
189
+ }
190
+
191
+ // --- KEV branch ---
192
+ if (kevMap === null) {
193
+ fetched.sources.kev = { reachable: false, error: kevCacheError?.message || 'unknown' };
194
+ } else {
195
+ const hit = kevMap.get(cveId);
196
+ fetched.in_kev = !!hit;
197
+ fetched.kev_date = hit?.dateAdded || null;
198
+ fetched.sources.kev = { reachable: true, total_entries: kevMap.size };
199
+ }
200
+
201
+ // --- EPSS branch ---
202
+ let epssReachable = false;
203
+ if (epssResult.ok) {
204
+ epssReachable = true;
205
+ const epss = extractEpss(epssResult.json, cveId);
206
+ if (epss) {
207
+ fetched.epss = epss;
208
+ fetched.sources.epss = { reachable: true, found: true, date: epss.date };
209
+ } else {
210
+ fetched.sources.epss = { reachable: true, found: false };
211
+ }
212
+ } else {
213
+ fetched.sources.epss = { reachable: false, error: epssResult.error };
214
+ }
215
+
216
+ // --- Status decision ---
217
+ // Only declare 'unreachable' if every source failed. EPSS being down alone
218
+ // should not block NVD/KEV drift detection.
219
+ if (!nvdReachable && (kevMap === null) && !epssReachable) {
220
+ return { cve_id: cveId, status: 'unreachable', discrepancies, fetched, local, drift };
221
+ }
222
+
223
+ if (nvdReachable && !cveFoundInNvd) {
224
+ return { cve_id: cveId, status: 'missing', discrepancies, fetched, local, drift };
225
+ }
226
+
227
+ // --- Compare CVSS (only if NVD reachable & has data) ---
228
+ if (cveFoundInNvd && fetched.cvss_score !== null && local.cvss_score !== null) {
229
+ if (Math.abs(fetched.cvss_score - local.cvss_score) > 0.05) {
230
+ pushDiscrepancy(discrepancies, 'cvss_score', local.cvss_score, fetched.cvss_score, 'high');
231
+ }
232
+ }
233
+ if (cveFoundInNvd && fetched.cvss_vector && local.cvss_vector) {
234
+ if (fetched.cvss_vector !== local.cvss_vector) {
235
+ pushDiscrepancy(discrepancies, 'cvss_vector', local.cvss_vector, fetched.cvss_vector, 'medium');
236
+ }
237
+ }
238
+
239
+ // --- Compare KEV (only if KEV reachable) ---
240
+ if (kevMap !== null) {
241
+ if (typeof local.cisa_kev === 'boolean' && local.cisa_kev !== fetched.in_kev) {
242
+ pushDiscrepancy(discrepancies, 'cisa_kev', local.cisa_kev, fetched.in_kev, 'high');
243
+ }
244
+ if (local.cisa_kev_date && fetched.kev_date && local.cisa_kev_date !== fetched.kev_date) {
245
+ pushDiscrepancy(discrepancies, 'cisa_kev_date', local.cisa_kev_date, fetched.kev_date, 'low');
246
+ }
247
+ }
248
+
249
+ // --- Compare EPSS (only if EPSS reachable + both sides have data) ---
250
+ if (epssReachable && fetched.epss) {
251
+ const fScore = fetched.epss.score;
252
+ const fPct = fetched.epss.percentile;
253
+ const lScore = typeof local.epss_score === 'number' ? local.epss_score : null;
254
+ const lPct = typeof local.epss_percentile === 'number' ? local.epss_percentile : null;
255
+
256
+ let epssDrift = false;
257
+ if (lScore !== null && fScore !== null && Math.abs(fScore - lScore) > EPSS_DRIFT_THRESHOLD) {
258
+ pushDiscrepancy(discrepancies, 'epss_score', lScore, fScore, 'medium');
259
+ epssDrift = true;
260
+ }
261
+ if (lPct !== null && fPct !== null && Math.abs(fPct - lPct) > EPSS_DRIFT_THRESHOLD) {
262
+ pushDiscrepancy(discrepancies, 'epss_percentile', lPct, fPct, 'medium');
263
+ epssDrift = true;
264
+ }
265
+ if (epssDrift) {
266
+ drift = {
267
+ local_epss: { score: lScore, percentile: lPct, date: local.epss_date },
268
+ fetched_epss: { score: fScore, percentile: fPct, date: fetched.epss.date },
269
+ };
270
+ }
271
+ }
272
+
273
+ const status = discrepancies.length === 0 ? 'match' : 'drift';
274
+ return { cve_id: cveId, status, discrepancies, fetched, local, drift };
275
+ }
276
+
277
+ module.exports = { validateCve, getKevCache, resetKevCache };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * sources/validators/index.js — barrel export.
5
+ *
6
+ * Re-exports:
7
+ * - validateCve(cveId, localEntry) — NVD + CISA KEV cross-check (per CVE)
8
+ * - validateAtlasVersion() — Confirm pinned ATLAS version matches upstream
9
+ * - validateAllCves(catalog, opts?) — Aggregate CVE validation across the local catalog
10
+ *
11
+ * Aggregate report shape:
12
+ * {
13
+ * generated_at: ISO timestamp,
14
+ * total: number,
15
+ * by_status: { match, drift, unreachable, missing },
16
+ * drift_count: number,
17
+ * results: ValidationResult[] // see cve-validator.js
18
+ * }
19
+ */
20
+
21
+ const { validateCve, getKevCache, resetKevCache } = require('./cve-validator');
22
+ const { validateAtlasVersion } = require('./atlas-validator');
23
+ const { validateRfc, validateAllRfcs } = require('./rfc-validator');
24
+
25
+ /**
26
+ * @param {object} catalog - parsed data/cve-catalog.json (the whole object incl. _meta)
27
+ * @param {object} [opts]
28
+ * @param {number} [opts.concurrency=4] - parallel NVD lookups (NVD allows 5 rps anonymously)
29
+ * @returns {Promise<object>} aggregate report
30
+ */
31
+ async function validateAllCves(catalog, opts = {}) {
32
+ const concurrency = Math.max(1, Math.min(8, opts.concurrency || 4));
33
+ if (!catalog || typeof catalog !== 'object') {
34
+ throw new TypeError('validateAllCves: catalog must be an object');
35
+ }
36
+
37
+ const ids = Object.keys(catalog).filter(k => /^CVE-\d{4}-\d{4,7}$/.test(k));
38
+ const results = [];
39
+ const by_status = { match: 0, drift: 0, unreachable: 0, missing: 0 };
40
+
41
+ // Simple windowed concurrency — no extra deps.
42
+ let cursor = 0;
43
+ async function worker() {
44
+ while (cursor < ids.length) {
45
+ const idx = cursor++;
46
+ const id = ids[idx];
47
+ try {
48
+ const res = await validateCve(id, catalog[id]);
49
+ results[idx] = res;
50
+ by_status[res.status] = (by_status[res.status] || 0) + 1;
51
+ } catch (err) {
52
+ // Defensive: validateCve already swallows network errors; this is a logic error.
53
+ results[idx] = {
54
+ cve_id: id,
55
+ status: 'unreachable',
56
+ discrepancies: [],
57
+ fetched: { sources: { nvd: null, kev: null } },
58
+ local: catalog[id] || null,
59
+ error: err.message,
60
+ };
61
+ by_status.unreachable++;
62
+ }
63
+ }
64
+ }
65
+
66
+ const workers = Array.from({ length: Math.min(concurrency, ids.length) }, () => worker());
67
+ await Promise.all(workers);
68
+
69
+ return {
70
+ generated_at: new Date().toISOString(),
71
+ total: ids.length,
72
+ by_status,
73
+ drift_count: by_status.drift,
74
+ results,
75
+ };
76
+ }
77
+
78
+ module.exports = {
79
+ validateCve,
80
+ validateAtlasVersion,
81
+ validateAllCves,
82
+ validateRfc,
83
+ validateAllRfcs,
84
+ getKevCache,
85
+ resetKevCache,
86
+ };