@blamejs/exceptd-skills 0.10.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +114 -0
- package/bin/exceptd.js +1874 -143
- package/data/_indexes/_meta.json +2 -2
- package/lib/playbook-runner.js +222 -9
- package/lib/prefetch.js +9 -1
- package/manifest-snapshot.json +1 -1
- package/manifest.json +39 -39
- package/orchestrator/index.js +98 -8
- package/package.json +2 -1
- package/sbom.cdx.json +6 -6
- package/sources/README.md +170 -0
- package/sources/validators/atlas-validator.js +158 -0
- package/sources/validators/cve-validator.js +277 -0
- package/sources/validators/index.js +86 -0
- package/sources/validators/rfc-validator.js +165 -0
- package/sources/validators/version-pin-validator.js +144 -0
|
@@ -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
|
+
};
|