@blamejs/exceptd-skills 0.16.28 → 0.16.30
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 +28 -0
- package/README.md +1 -1
- package/bin/exceptd.js +251 -18
- package/data/_indexes/_meta.json +4 -3
- package/data/_indexes/jurisdiction-map.json +31 -158
- package/data/playbooks/crypto.json +6 -0
- package/lib/auto-discovery.js +8 -0
- package/lib/collectors/README.md +3 -2
- package/lib/collectors/library-author.js +26 -9
- package/lib/collectors/secrets.js +8 -1
- package/lib/cross-ref-api.js +96 -31
- package/lib/lint-skills.js +6 -1
- package/lib/playbook-runner.js +264 -52
- package/lib/prefetch.js +78 -6
- package/lib/refresh-external.js +106 -5
- package/lib/scoring.js +49 -5
- package/lib/validate-cve-catalog.js +14 -2
- package/lib/validate-indexes.js +5 -0
- package/lib/validate-playbooks.js +133 -38
- package/manifest.json +53 -53
- package/orchestrator/pipeline.js +16 -4
- package/package.json +1 -1
- package/sbom.cdx.json +73 -58
- package/scripts/build-indexes.js +12 -1
- package/scripts/check-sbom-currency.js +76 -14
- package/scripts/refresh-sbom.js +1 -1
- package/scripts/run-e2e-scenarios.js +41 -11
- package/scripts/sync-package-description.js +74 -0
- package/scripts/verify-shipped-tarball.js +18 -7
- package/sources/validators/cve-validator.js +16 -6
package/lib/playbook-runner.js
CHANGED
|
@@ -1191,10 +1191,21 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1191
1191
|
// `factor_cve_source: 'evidence' | 'domain' | 'none'` so operators see
|
|
1192
1192
|
// which fallback was used.
|
|
1193
1193
|
let factorCveSource = 'none';
|
|
1194
|
-
|
|
1194
|
+
// Prefer an RWEP-eligible (non-VEX-fixed) matched CVE to drive factor
|
|
1195
|
+
// scaling — a vendor-patched CVE must not inflate adjusted RWEP via its
|
|
1196
|
+
// exploitation / KEV / PoC multipliers. Do NOT fall back to matchedCves[0]:
|
|
1197
|
+
// when EVERY evidence-correlated CVE is VEX-fixed (rwepEligible empty but
|
|
1198
|
+
// matchedCves non-empty) the finding is remediated, so factor scaling must be
|
|
1199
|
+
// suppressed entirely — base is already 0 and the fired factors must not
|
|
1200
|
+
// raise the adjusted score (a vendor-fixed CVE's KEV/exploitation/PoC would
|
|
1201
|
+
// otherwise lift it above 0). The domain-CVE and class-weight fallbacks below
|
|
1202
|
+
// are skipped in that case too, so every fired factor scales by 0 via
|
|
1203
|
+
// _factorScale(factor, null, …).
|
|
1204
|
+
const allMatchedVexFixed = matchedCves.length > 0 && rwepEligible.length === 0;
|
|
1205
|
+
let factorCve = rwepEligible[0] || null;
|
|
1195
1206
|
if (factorCve) {
|
|
1196
1207
|
factorCveSource = 'evidence';
|
|
1197
|
-
} else if (workingCatalogCves.length > 0) {
|
|
1208
|
+
} else if (!allMatchedVexFixed && workingCatalogCves.length > 0) {
|
|
1198
1209
|
// Highest rwep_score from domain refs.
|
|
1199
1210
|
factorCve = workingCatalogCves.reduce((worst, c) =>
|
|
1200
1211
|
(typeof c.rwep_score === 'number' && (!worst || c.rwep_score > worst.rwep_score)) ? c : worst,
|
|
@@ -1211,7 +1222,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1211
1222
|
// semantics for this case only: apply the declared weight as-is
|
|
1212
1223
|
// (factor_scale=1, legacy semantics). The factor_cve_source annotation
|
|
1213
1224
|
// surfaces 'class' so operators see which mode the run used.
|
|
1214
|
-
const _classScaleFallback = !factorCve;
|
|
1225
|
+
const _classScaleFallback = !factorCve && !allMatchedVexFixed;
|
|
1215
1226
|
let adjustedRwep = baseRwep;
|
|
1216
1227
|
const rwepBreakdown = [];
|
|
1217
1228
|
for (const input of an.rwep_inputs || []) {
|
|
@@ -1357,9 +1368,15 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1357
1368
|
filter_applied: true,
|
|
1358
1369
|
dropped_cve_count: vexDropped.length,
|
|
1359
1370
|
dropped_cves: vexDropped,
|
|
1371
|
+
// Vendor-fixed CVEs are a KEEP disposition — they stay in matched_cves
|
|
1372
|
+
// annotated vex_status:'fixed' and never enter vexDropped. Surface them
|
|
1373
|
+
// so the two dispositions are distinguishable and the note can be
|
|
1374
|
+
// accurate (the drop note must not list a keep-disposition as a reason).
|
|
1375
|
+
fixed_cves: vexFixedIds,
|
|
1376
|
+
fixed_cve_count: vexFixedIds.length,
|
|
1360
1377
|
note: vexDropped.length
|
|
1361
|
-
? `${vexDropped.length} CVE(s) dropped from analyze because the operator-supplied VEX statement marks them not_affected /
|
|
1362
|
-
: "VEX filter supplied; zero matches dropped (no CVEs in domain.cve_refs matched the VEX not-affected set)."
|
|
1378
|
+
? `${vexDropped.length} CVE(s) dropped from analyze because the operator-supplied VEX statement marks them not_affected / false_positive. Vendor-fixed CVEs are NOT dropped — they remain in matched_cves with vex_status:'fixed'. The dropped CVEs remain in cve-catalog.json; the disposition lives in the VEX file.`
|
|
1379
|
+
: "VEX filter supplied; zero matches dropped (no CVEs in domain.cve_refs matched the VEX not-affected / false_positive set)."
|
|
1363
1380
|
} : null,
|
|
1364
1381
|
// Regex-eval failures surfaced here so operators can see WHICH
|
|
1365
1382
|
// condition expression crashed without the runner dying. Only present
|
|
@@ -1661,6 +1678,15 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1661
1678
|
// upstream `govern.jurisdiction_obligations` has the real data — carry it
|
|
1662
1679
|
// forward. `notification_deadline` is published as an alias for `deadline`
|
|
1663
1680
|
// (matches the field name compliance teams expect on a notification record).
|
|
1681
|
+
// Which engine phases completed in this run. analyze_complete /
|
|
1682
|
+
// validate_complete jurisdictional clocks auto-start (under --ack) only when
|
|
1683
|
+
// their named phase actually ran — by the time close() executes, a
|
|
1684
|
+
// populated analyzeResult / validateResult proves the phase completed in the
|
|
1685
|
+
// same synchronous pass.
|
|
1686
|
+
const phaseFlags = {
|
|
1687
|
+
analyze_complete: !!(analyzeResult && typeof analyzeResult === 'object'),
|
|
1688
|
+
validate_complete: !!(validateResult && typeof validateResult === 'object'),
|
|
1689
|
+
};
|
|
1664
1690
|
const enrichNotification = (na) => {
|
|
1665
1691
|
const obligation = (g.jurisdiction_obligations || []).find(o =>
|
|
1666
1692
|
`${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
|
|
@@ -1671,17 +1697,31 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1671
1697
|
// starts the clock even without a separately-submitted classification.
|
|
1672
1698
|
const engineClassification = analyzeResult?._detect_classification || null;
|
|
1673
1699
|
const clockStart = obligation
|
|
1674
|
-
? computeClockStart(obligation.clock_starts, agentSignals, runOpts, engineClassification)
|
|
1700
|
+
? computeClockStart(obligation.clock_starts, agentSignals, runOpts, engineClassification, phaseFlags, frozenEpoch)
|
|
1675
1701
|
: null;
|
|
1676
|
-
//
|
|
1677
|
-
//
|
|
1678
|
-
//
|
|
1679
|
-
//
|
|
1680
|
-
const
|
|
1681
|
-
|
|
1682
|
-
|
|
1702
|
+
// A valid clock is a real Date whose getTime() is finite. computeClockStart
|
|
1703
|
+
// already returns null on an unparseable operator timestamp, but guard the
|
|
1704
|
+
// arithmetic below independently so no caller (or future code path) can
|
|
1705
|
+
// ever reach new Date(NaN).toISOString() and crash the close phase.
|
|
1706
|
+
const clockValid = clockStart instanceof Date && !Number.isNaN(clockStart.getTime());
|
|
1707
|
+
// Surface clock_pending_ack when an auto-startable event was confirmed but
|
|
1708
|
+
// the operator did NOT pass --ack, so the notification record is visibly
|
|
1709
|
+
// waiting on acknowledgement rather than silently stalled.
|
|
1710
|
+
const autoStartEvent = obligation
|
|
1711
|
+
&& (obligation.clock_starts === 'detect_confirmed'
|
|
1712
|
+
|| obligation.clock_starts === 'analyze_complete'
|
|
1713
|
+
|| obligation.clock_starts === 'validate_complete');
|
|
1714
|
+
const eventReady = obligation && (
|
|
1715
|
+
(obligation.clock_starts === 'detect_confirmed'
|
|
1716
|
+
&& (agentSignals?.detection_classification === 'detected' || engineClassification === 'detected'))
|
|
1717
|
+
|| (obligation.clock_starts === 'analyze_complete' && phaseFlags.analyze_complete)
|
|
1718
|
+
|| (obligation.clock_starts === 'validate_complete' && phaseFlags.validate_complete)
|
|
1719
|
+
);
|
|
1720
|
+
const clockPendingAck = !clockValid
|
|
1721
|
+
&& autoStartEvent
|
|
1722
|
+
&& eventReady
|
|
1683
1723
|
&& !(runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true);
|
|
1684
|
-
const deadline = obligation &&
|
|
1724
|
+
const deadline = obligation && clockValid
|
|
1685
1725
|
? new Date(clockStart.getTime() + obligation.window_hours * 3600 * 1000).toISOString()
|
|
1686
1726
|
: 'pending_clock_start_event';
|
|
1687
1727
|
return {
|
|
@@ -1694,7 +1734,11 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1694
1734
|
obligation_type: obligation?.obligation || null,
|
|
1695
1735
|
window_hours: obligation?.window_hours ?? null,
|
|
1696
1736
|
clock_start_event: obligation?.clock_starts || null,
|
|
1697
|
-
|
|
1737
|
+
// Use the validity gate, not optional-chaining: optional-chaining only
|
|
1738
|
+
// short-circuits null/undefined, so a (hypothetical) Invalid Date would
|
|
1739
|
+
// still reach .toISOString() and throw. clockValid guarantees a finite
|
|
1740
|
+
// instant before we serialize.
|
|
1741
|
+
clock_started_at: clockValid ? clockStart.toISOString() : null,
|
|
1698
1742
|
...(clockPendingAck ? { clock_pending_ack: true } : {}),
|
|
1699
1743
|
deadline,
|
|
1700
1744
|
// Alias matching compliance-team vocabulary.
|
|
@@ -1972,7 +2016,9 @@ function analyzeFindingShape(a) {
|
|
|
1972
2016
|
// CVEs. A .find() lookup would return the first truthy entry — e.g.
|
|
1973
2017
|
// 'suspected' on CVE #1 when CVE #2 is 'confirmed' — under-stating
|
|
1974
2018
|
// the threat in notification drafts.
|
|
1975
|
-
|
|
2019
|
+
// Exclude VEX-fixed (vendor-patched) CVEs: a notification draft must not
|
|
2020
|
+
// assert active exploitation sourced from an already-remediated CVE.
|
|
2021
|
+
active_exploitation: worstActiveExploitation(matched.filter(c => c.vex_status !== 'fixed')),
|
|
1976
2022
|
rwep_adjusted: rwepAdjusted,
|
|
1977
2023
|
rwep_base: a.rwep?.base ?? 0,
|
|
1978
2024
|
// Severity surface for playbook conditions.
|
|
@@ -1983,18 +2029,47 @@ function analyzeFindingShape(a) {
|
|
|
1983
2029
|
};
|
|
1984
2030
|
}
|
|
1985
2031
|
|
|
2032
|
+
// Map a vulnerability identifier to its issuing authority + the canonical
|
|
2033
|
+
// human-readable advisory URL for that authority. CVE ids resolve to NVD;
|
|
2034
|
+
// GHSA/OSV/RUSTSEC/SNYK each have their own advisory database. A MAL- malicious
|
|
2035
|
+
// -package id has no public per-id advisory page, so helpUri is null (the id is
|
|
2036
|
+
// still labelled with its system_name). An unrecognised prefix resolves to a
|
|
2037
|
+
// null helpUri rather than a fabricated link.
|
|
2038
|
+
//
|
|
2039
|
+
// Used by the SARIF rule emitter (helpUri) so non-CVE matched ids no longer
|
|
2040
|
+
// get a hardcoded nvd.nist.gov/vuln/detail/<id> URL — that URL 404s for every
|
|
2041
|
+
// MAL-/GHSA-/OSV-/RUSTSEC- id and mislabels it as an NVD CVE. The same
|
|
2042
|
+
// prefix→authority knowledge lives in the CSAF ids[] branch (csafIdsFor); both
|
|
2043
|
+
// derive from this table so the two exports cannot drift.
|
|
2044
|
+
const CVE_ID_RE = /^CVE-\d{4}-\d{4,}$/;
|
|
2045
|
+
function advisoryAuthorityFor(id) {
|
|
2046
|
+
if (typeof id !== 'string' || !id) return { system_name: null, helpUri: null };
|
|
2047
|
+
if (CVE_ID_RE.test(id)) return { system_name: 'NVD', helpUri: `https://nvd.nist.gov/vuln/detail/${id}` };
|
|
2048
|
+
if (id.startsWith('GHSA-')) return { system_name: 'GHSA', helpUri: `https://github.com/advisories/${id}` };
|
|
2049
|
+
if (id.startsWith('OSV-')) return { system_name: 'OSV', helpUri: `https://osv.dev/vulnerability/${id}` };
|
|
2050
|
+
if (id.startsWith('RUSTSEC-')) return { system_name: 'RUSTSEC', helpUri: `https://rustsec.org/advisories/${id}.html` };
|
|
2051
|
+
if (id.startsWith('SNYK-')) return { system_name: 'Snyk', helpUri: `https://security.snyk.io/vuln/${id}` };
|
|
2052
|
+
// Malicious-package ids have no canonical per-id advisory page; label the
|
|
2053
|
+
// authority but emit no link rather than a fabricated NVD URL.
|
|
2054
|
+
if (id.startsWith('MAL-')) return { system_name: 'Malicious-Package', helpUri: null };
|
|
2055
|
+
return { system_name: 'exceptd-unknown', helpUri: null };
|
|
2056
|
+
}
|
|
2057
|
+
|
|
1986
2058
|
// Route a vulnerability identifier to its registry-specific URN namespace.
|
|
1987
2059
|
// CVE-/GHSA-/RUSTSEC-/MAL-* identifiers each have a registered URN namespace;
|
|
1988
2060
|
// unrecognised prefixes route to the `urn:exceptd:advisory:` private
|
|
1989
2061
|
// namespace so OpenVEX statements still carry a valid IRI per RFC 8141.
|
|
1990
2062
|
function vulnIdToUrn(id) {
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
if (/^
|
|
1997
|
-
return `urn:
|
|
2063
|
+
if (typeof id !== 'string' || id.length === 0) return `urn:exceptd:advisory:${urnSlug(id)}`;
|
|
2064
|
+
// Registered identifiers keep their canonical case in the NSS so the @id
|
|
2065
|
+
// matches the OpenVEX `name` / CSAF id exactly. The private advisory
|
|
2066
|
+
// namespace slugs arbitrary text, so it stays lowercase.
|
|
2067
|
+
const canonical = urnSlug(id, true);
|
|
2068
|
+
if (/^CVE-/i.test(id)) return `urn:cve:${canonical}`;
|
|
2069
|
+
if (/^GHSA-/i.test(id)) return `urn:ghsa:${canonical}`;
|
|
2070
|
+
if (/^RUSTSEC-/i.test(id)) return `urn:rustsec:${canonical}`;
|
|
2071
|
+
if (/^MAL-/i.test(id)) return `urn:malicious-package:${canonical}`;
|
|
2072
|
+
return `urn:exceptd:advisory:${urnSlug(id)}`;
|
|
1998
2073
|
}
|
|
1999
2074
|
|
|
2000
2075
|
// Build a CSAF product_tree.branches[] tree (vendor → product_name →
|
|
@@ -2020,6 +2095,12 @@ function buildCsafBranches(matchedCves, runOpts) {
|
|
|
2020
2095
|
products.get(product).add(version);
|
|
2021
2096
|
};
|
|
2022
2097
|
|
|
2098
|
+
// Comparison / range operators that appear between a package name and a
|
|
2099
|
+
// version in the catalog's dominant `package OP version` affected_versions
|
|
2100
|
+
// shape (e.g. "linux-kernel >= 4.14", "runc <= 1.1.11", "litellm < 1.83.7").
|
|
2101
|
+
// These are operators, never package names.
|
|
2102
|
+
const RANGE_OP_RE = /^(<=|>=|==|!=|~>|<|>|=|~|\^)$/;
|
|
2103
|
+
|
|
2023
2104
|
// Heuristic parser. Returns { vendor, product, version } or null.
|
|
2024
2105
|
const parseComponentString = (s) => {
|
|
2025
2106
|
if (typeof s !== 'string' || !s.trim()) return null;
|
|
@@ -2027,9 +2108,28 @@ function buildCsafBranches(matchedCves, runOpts) {
|
|
|
2027
2108
|
// `vendor/product@version`
|
|
2028
2109
|
let m = trimmed.match(/^([^/\s@]+)\/([^/\s@]+)@(.+)$/);
|
|
2029
2110
|
if (m) return { vendor: m[1], product: m[2], version: m[3].trim() };
|
|
2111
|
+
const parts = trimmed.split(/\s+/);
|
|
2112
|
+
// `package OP version` — the catalog's dominant shape. The token before
|
|
2113
|
+
// the version is a comparison/range operator, so the PACKAGE is the
|
|
2114
|
+
// product name and the operator belongs to the version qualifier, not the
|
|
2115
|
+
// product_name. Pre-fix this split named the product after the operator
|
|
2116
|
+
// ('>=', '<', '=='), corrupting the CSAF affected-product list. Carry the
|
|
2117
|
+
// operator into the version string ('>= 4.14') so the range qualifier
|
|
2118
|
+
// survives while the product_name stays the real package. Multiple leading
|
|
2119
|
+
// operator tokens (a compound range emitted as one string) collapse into
|
|
2120
|
+
// the version qualifier too.
|
|
2121
|
+
if (parts.length >= 3 && RANGE_OP_RE.test(parts[1])) {
|
|
2122
|
+
const product = parts[0];
|
|
2123
|
+
const versionTokens = parts.slice(1);
|
|
2124
|
+
// Only accept the shape when the trailing token is an actual version
|
|
2125
|
+
// (starts with a digit or v\d) — otherwise it isn't a `package OP version`.
|
|
2126
|
+
const lastTok = versionTokens[versionTokens.length - 1];
|
|
2127
|
+
if (/^v?\d/.test(lastTok)) {
|
|
2128
|
+
return { vendor: product, product, version: versionTokens.join(' ') };
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2030
2131
|
// `vendor product version` — exactly three whitespace-separated tokens
|
|
2031
2132
|
// where the last token starts with a digit or `v\d`.
|
|
2032
|
-
const parts = trimmed.split(/\s+/);
|
|
2033
2133
|
if (parts.length >= 3) {
|
|
2034
2134
|
const last = parts[parts.length - 1];
|
|
2035
2135
|
if (/^v?\d/.test(last)) {
|
|
@@ -2098,13 +2198,17 @@ function buildCsafBranches(matchedCves, runOpts) {
|
|
|
2098
2198
|
return { branches, productIds };
|
|
2099
2199
|
}
|
|
2100
2200
|
|
|
2101
|
-
// Slugify a string into a URN-safe segment (
|
|
2102
|
-
//
|
|
2103
|
-
|
|
2201
|
+
// Slugify a string into a URN-safe segment (RFC 8141 NSS). Empty input →
|
|
2202
|
+
// 'unknown' so we never emit zero-length segments. preserveCase keeps the
|
|
2203
|
+
// canonical case of registered identifiers (e.g. CVE-2026-43284) — the NSS is
|
|
2204
|
+
// case-sensitive per RFC 8141, and the OpenVEX `name`/CSAF id fields carry the
|
|
2205
|
+
// canonical case, so the URN @id must match rather than fold to lowercase.
|
|
2206
|
+
function urnSlug(s, preserveCase = false) {
|
|
2104
2207
|
if (s == null) return 'unknown';
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2208
|
+
let str = String(s);
|
|
2209
|
+
if (!preserveCase) str = str.toLowerCase();
|
|
2210
|
+
const slug = str
|
|
2211
|
+
.replace(preserveCase ? /[^A-Za-z0-9_-]+/g : /[^a-z0-9_-]+/g, '-')
|
|
2108
2212
|
.replace(/^-+|-+$/g, '');
|
|
2109
2213
|
return slug.length ? slug : 'unknown';
|
|
2110
2214
|
}
|
|
@@ -2810,12 +2914,25 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2810
2914
|
message: { text: `${g.framework}: ${g.claimed_control} — ${g.actual_gap}${g.required_control ? '. Required: ' + g.required_control : ''}` },
|
|
2811
2915
|
properties: stripNulls({ kind: 'framework_gap', framework: g.framework, control: g.claimed_control }),
|
|
2812
2916
|
}));
|
|
2813
|
-
const cveRules = analyze.matched_cves.map(c =>
|
|
2814
|
-
id
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2917
|
+
const cveRules = analyze.matched_cves.map(c => {
|
|
2918
|
+
// Resolve the issuing authority by id shape rather than hardcoding NVD.
|
|
2919
|
+
// A non-CVE matched id (MAL-/GHSA-/OSV-/RUSTSEC-/SNYK-) must NOT carry an
|
|
2920
|
+
// nvd.nist.gov URL — that link 404s and presents the id as an NVD CVE.
|
|
2921
|
+
const authority = advisoryAuthorityFor(c.cve_id);
|
|
2922
|
+
const isCve = CVE_ID_RE.test(typeof c.cve_id === 'string' ? c.cve_id : '');
|
|
2923
|
+
const rule = {
|
|
2924
|
+
id: `${rulePrefix}${c.cve_id}`,
|
|
2925
|
+
// For a non-CVE id, qualify the short description with its authority so
|
|
2926
|
+
// a SARIF viewer doesn't read e.g. a MAL- id as an NVD CVE.
|
|
2927
|
+
shortDescription: { text: isCve ? c.cve_id : `${c.cve_id} (${authority.system_name || 'non-CVE advisory'})` },
|
|
2928
|
+
fullDescription: { text: `RWEP ${c.rwep} · KEV=${c.cisa_kev} · active_exploitation=${c.active_exploitation}` },
|
|
2929
|
+
defaultConfiguration: { level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note' },
|
|
2930
|
+
};
|
|
2931
|
+
// helpUri is optional in SARIF 2.1.0; omit it entirely when the authority
|
|
2932
|
+
// has no canonical per-id advisory page rather than emit a broken link.
|
|
2933
|
+
if (authority.helpUri) rule.helpUri = authority.helpUri;
|
|
2934
|
+
return rule;
|
|
2935
|
+
});
|
|
2819
2936
|
const indicatorRules = indicatorHits.map(i => ({
|
|
2820
2937
|
id: `${rulePrefix}${i.id}`, shortDescription: { text: i.id },
|
|
2821
2938
|
fullDescription: { text: `Indicator from playbook ${playbook._meta.id}. Type: ${i.type}. Confidence: ${i.confidence}.` },
|
|
@@ -3877,6 +3994,51 @@ function stripOuterParens(expr) {
|
|
|
3877
3994
|
return expr;
|
|
3878
3995
|
}
|
|
3879
3996
|
|
|
3997
|
+
// Parse an operator-supplied clock_started_at_<event> timestamp into a valid
|
|
3998
|
+
// Date, host-timezone-independently. Two failure modes a raw `new Date(s)`
|
|
3999
|
+
// silently produces are guarded here:
|
|
4000
|
+
//
|
|
4001
|
+
// 1. Unparseable value ('not-a-date', '2026-13-99'). new Date() returns an
|
|
4002
|
+
// Invalid Date — a truthy object whose getTime() is NaN. Calling
|
|
4003
|
+
// .toISOString() on it later throws RangeError, which crashes the
|
|
4004
|
+
// deadline math in close() and propagates uncaught out of run(),
|
|
4005
|
+
// destroying the entire phase-7 notification/CSAF/deadline output. We
|
|
4006
|
+
// return { date: null } so the caller routes to the pending-clock branch
|
|
4007
|
+
// instead of crashing.
|
|
4008
|
+
//
|
|
4009
|
+
// 2. Zone-less ISO ('2026-06-12T10:00:00' or '2026-06-12 10:00:00'). new
|
|
4010
|
+
// Date() interprets a designator-less datetime in the HOST timezone, so
|
|
4011
|
+
// the published statutory deadline shifts by the host's UTC offset — a
|
|
4012
|
+
// 4h DORA window computed on a UTC-7 host lands 7h late. We normalize the
|
|
4013
|
+
// space separator to 'T' and append 'Z' so a zone-less value is read as
|
|
4014
|
+
// UTC deterministically on every host, and flag assumed_utc so the caller
|
|
4015
|
+
// can surface that the zone was assumed.
|
|
4016
|
+
//
|
|
4017
|
+
// Returns { date, assumed_utc } where date is a valid Date or null, and
|
|
4018
|
+
// assumed_utc is true only when a zone-less value was coerced to UTC.
|
|
4019
|
+
function parseOperatorClock(raw) {
|
|
4020
|
+
if (typeof raw !== 'string') {
|
|
4021
|
+
const d = new Date(raw);
|
|
4022
|
+
return { date: Number.isNaN(d.getTime()) ? null : d, assumed_utc: false };
|
|
4023
|
+
}
|
|
4024
|
+
const trimmed = raw.trim();
|
|
4025
|
+
if (!trimmed) return { date: null, assumed_utc: false };
|
|
4026
|
+
// A full ISO datetime whose only missing piece is the zone designator:
|
|
4027
|
+
// YYYY-MM-DD, a 'T' or single space separator, HH:MM(:SS(.ms)?)?, and NO
|
|
4028
|
+
// trailing 'Z' / [+-]HH:MM offset. Date-only values (YYYY-MM-DD) are already
|
|
4029
|
+
// parsed as UTC midnight by spec, so they are left untouched.
|
|
4030
|
+
let assumedUtc = false;
|
|
4031
|
+
let candidate = trimmed;
|
|
4032
|
+
const zonelessDateTime = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/;
|
|
4033
|
+
if (zonelessDateTime.test(trimmed)) {
|
|
4034
|
+
candidate = trimmed.replace(' ', 'T') + 'Z';
|
|
4035
|
+
assumedUtc = true;
|
|
4036
|
+
}
|
|
4037
|
+
const d = new Date(candidate);
|
|
4038
|
+
if (Number.isNaN(d.getTime())) return { date: null, assumed_utc: false };
|
|
4039
|
+
return { date: d, assumed_utc: assumedUtc };
|
|
4040
|
+
}
|
|
4041
|
+
|
|
3880
4042
|
/**
|
|
3881
4043
|
* Compute the start instant for a jurisdictional clock event. The agent
|
|
3882
4044
|
* submits clock_started_at_<event> ISO strings as it progresses through
|
|
@@ -3888,22 +4050,57 @@ function stripOuterParens(expr) {
|
|
|
3888
4050
|
* whenever the engine classifies as detected would be incorrect: the
|
|
3889
4051
|
* operator may not have seen the result yet. Semantics:
|
|
3890
4052
|
*
|
|
3891
|
-
* - If the agent explicitly submits clock_started_at_<event>: use it
|
|
3892
|
-
*
|
|
4053
|
+
* - If the agent explicitly submits clock_started_at_<event>: use it,
|
|
4054
|
+
* after validating it parses and normalizing a zone-less value to UTC.
|
|
4055
|
+
* An unparseable value returns null (clock stays pending) and surfaces
|
|
4056
|
+
* a runtime error naming the offending key, instead of crashing close().
|
|
4057
|
+
* - Otherwise, for 'detect_confirmed' with classification='detected', and
|
|
4058
|
+
* for 'analyze_complete' / 'validate_complete' once their phase has run:
|
|
3893
4059
|
* stamp `now` ONLY if runOpts.operator_consent?.explicit === true
|
|
3894
|
-
* (i.e. the operator passed --ack).
|
|
3895
|
-
* the
|
|
3896
|
-
*
|
|
3897
|
-
*
|
|
3898
|
-
*
|
|
4060
|
+
* (i.e. the operator passed --ack). The analyze/validate phases provably
|
|
4061
|
+
* complete inside the same synchronous run before close() computes these
|
|
4062
|
+
* clocks, so under --ack there is no operator-awareness gap. Without
|
|
4063
|
+
* --ack, return null and the caller (close()) surfaces clock_pending_ack:
|
|
4064
|
+
* true on the notification_actions entry so the operator sees that the
|
|
4065
|
+
* clock is waiting on acknowledgement.
|
|
4066
|
+
* - 'manual' and any other event without an explicit timestamp: return null.
|
|
4067
|
+
*
|
|
4068
|
+
* `phaseFlags` carries which engine phases completed in this run
|
|
4069
|
+
* ({ analyze_complete, validate_complete }) so the auto-stamp for those two
|
|
4070
|
+
* events only fires when the named phase actually ran.
|
|
3899
4071
|
*/
|
|
3900
|
-
function computeClockStart(eventName, agentSignals, runOpts = {}, engineClassification = null) {
|
|
4072
|
+
function computeClockStart(eventName, agentSignals, runOpts = {}, engineClassification = null, phaseFlags = {}, frozenEpoch = null) {
|
|
3901
4073
|
// The agent submits clock_started_at_<event> ISO strings as it progresses.
|
|
3902
4074
|
const key = `clock_started_at_${eventName}`;
|
|
3903
|
-
if (agentSignals && agentSignals[key])
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
4075
|
+
if (agentSignals && agentSignals[key]) {
|
|
4076
|
+
const { date, assumed_utc } = parseOperatorClock(agentSignals[key]);
|
|
4077
|
+
if (!date) {
|
|
4078
|
+
// A present-but-unparseable timestamp must not crash the close phase via
|
|
4079
|
+
// a downstream new Date(NaN).toISOString(). Null routes to the pending
|
|
4080
|
+
// branch; the runtime error tells the operator which field was bad.
|
|
4081
|
+
if (runOpts && Array.isArray(runOpts._runErrors)) {
|
|
4082
|
+
pushRunError(runOpts._runErrors, {
|
|
4083
|
+
kind: 'invalid_clock_value',
|
|
4084
|
+
clock_event: eventName,
|
|
4085
|
+
key,
|
|
4086
|
+
supplied: String(agentSignals[key]).slice(0, 80),
|
|
4087
|
+
message: `${key} is not a valid ISO instant; the jurisdictional clock did not start. Submit an ISO-8601 timestamp (e.g. 2026-06-12T10:00:00Z).`,
|
|
4088
|
+
}, { dedupeKey: e => e.key || '' });
|
|
4089
|
+
}
|
|
4090
|
+
return null;
|
|
4091
|
+
}
|
|
4092
|
+
if (assumed_utc && runOpts && Array.isArray(runOpts._runErrors)) {
|
|
4093
|
+
pushRunError(runOpts._runErrors, {
|
|
4094
|
+
kind: 'clock_timezone_assumed_utc',
|
|
4095
|
+
clock_event: eventName,
|
|
4096
|
+
key,
|
|
4097
|
+
supplied: String(agentSignals[key]).slice(0, 80),
|
|
4098
|
+
message: `${key} carries no timezone designator; interpreted as UTC. Append 'Z' or an offset to make the regulatory deadline unambiguous.`,
|
|
4099
|
+
}, { dedupeKey: e => e.key || '' });
|
|
4100
|
+
}
|
|
4101
|
+
return date;
|
|
4102
|
+
}
|
|
4103
|
+
// Auto-stamp gate. Detection is "confirmed" when EITHER the agent submitted
|
|
3907
4104
|
// detection_classification:'detected' OR the engine itself classified the
|
|
3908
4105
|
// detect phase as 'detected'. Pre-fix only the agent-submitted signal was
|
|
3909
4106
|
// honored, so an engine-confirmed detection (indicators fired from
|
|
@@ -3911,10 +4108,22 @@ function computeClockStart(eventName, agentSignals, runOpts = {}, engineClassifi
|
|
|
3911
4108
|
// started the regulatory clock — notification deadlines silently stalled.
|
|
3912
4109
|
const detected = agentSignals?.detection_classification === 'detected'
|
|
3913
4110
|
|| engineClassification === 'detected';
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
4111
|
+
const ack = !!(runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true);
|
|
4112
|
+
if (!ack) return null;
|
|
4113
|
+
// detect_confirmed auto-starts on a confirmed detection. analyze_complete /
|
|
4114
|
+
// validate_complete auto-start once their engine phase has run in this same
|
|
4115
|
+
// pass — the event literally names a phase that completed synchronously
|
|
4116
|
+
// before close(), so --ack closes the awareness gap exactly as it does for
|
|
4117
|
+
// detect_confirmed.
|
|
4118
|
+
// Deterministic bundle mode roots every auto-started clock in the single
|
|
4119
|
+
// frozen epoch so two runs over the same evidence emit identical
|
|
4120
|
+
// clock_started_at and deadline values; otherwise the clock starts at
|
|
4121
|
+
// wall-clock now. Operator-supplied clock timestamps (handled above) are
|
|
4122
|
+
// never overridden — they are the explicit input.
|
|
4123
|
+
const autoNow = () => (frozenEpoch ? new Date(frozenEpoch) : new Date());
|
|
4124
|
+
if (eventName === 'detect_confirmed' && detected) return autoNow();
|
|
4125
|
+
if (eventName === 'analyze_complete' && phaseFlags && phaseFlags.analyze_complete === true) return autoNow();
|
|
4126
|
+
if (eventName === 'validate_complete' && phaseFlags && phaseFlags.validate_complete === true) return autoNow();
|
|
3918
4127
|
return null;
|
|
3919
4128
|
}
|
|
3920
4129
|
|
|
@@ -4012,6 +4221,9 @@ module.exports = {
|
|
|
4012
4221
|
_releaseLock: releaseLock,
|
|
4013
4222
|
_lockFilePath: lockFilePath,
|
|
4014
4223
|
_vulnIdToUrn: vulnIdToUrn,
|
|
4224
|
+
_buildCsafBranches: buildCsafBranches,
|
|
4225
|
+
_advisoryAuthorityFor: advisoryAuthorityFor,
|
|
4226
|
+
_computeClockStart: computeClockStart,
|
|
4015
4227
|
_worstActiveExploitation: worstActiveExploitation,
|
|
4016
4228
|
// Re-exported from scoring so parity between the catalog scorer and the
|
|
4017
4229
|
// runtime evaluator is checkable (and enforced by a test) at the seam.
|
package/lib/prefetch.js
CHANGED
|
@@ -107,6 +107,13 @@ const SOURCES = {
|
|
|
107
107
|
},
|
|
108
108
|
};
|
|
109
109
|
|
|
110
|
+
// Sources the refresh orchestrator knows but that have no prefetch cache
|
|
111
|
+
// layer: they resolve advisories by live id lookup, so there is nothing to
|
|
112
|
+
// warm. Named here so an operator who scopes a cache-warm to one of them gets
|
|
113
|
+
// "no prefetch cache layer (live id lookup only)" rather than a misleading
|
|
114
|
+
// "unknown source" — the source is real, just not cacheable.
|
|
115
|
+
const LIVE_ONLY_REFRESH_SOURCES = new Set(["ghsa", "osv", "advisories", "cve-regression-watcher"]);
|
|
116
|
+
|
|
110
117
|
function parseArgs(argv) {
|
|
111
118
|
const out = { maxAgeMs: 24 * 3600 * 1000, source: null, force: false, noNetwork: false, cacheDir: DEFAULT_CACHE, quiet: false, help: false, maxErrors: 0 };
|
|
112
119
|
for (let i = 2; i < argv.length; i++) {
|
|
@@ -115,11 +122,19 @@ function parseArgs(argv) {
|
|
|
115
122
|
else if (a === "--no-network" || a === "--dry-run" || a === "--air-gap") out.noNetwork = true;
|
|
116
123
|
else if (a === "--quiet") out.quiet = true;
|
|
117
124
|
else if (a === "--help" || a === "-h") out.help = true;
|
|
118
|
-
|
|
125
|
+
// The space-separated forms of --source / --max-age / --cache-dir consume
|
|
126
|
+
// the next token. A trailing flag (e.g. `prefetch --cache-dir` with no
|
|
127
|
+
// following value) would otherwise pass `undefined` into path.resolve /
|
|
128
|
+
// parseDuration — path.resolve(undefined) throws an uncaught TypeError,
|
|
129
|
+
// and parseDuration(undefined) silently returns 0 (which flips --max-age
|
|
130
|
+
// into "everything is stale, refetch all"). A bare --source likewise flips
|
|
131
|
+
// the scope to all sources. Treat a missing value (next token absent or
|
|
132
|
+
// itself a --flag) as a usage error so main() refuses with exit 2 instead.
|
|
133
|
+
else if (a === "--source") { const v = takesValue(argv, ++i); if (v === undefined) out._argError = "prefetch: --source requires a value"; else out.source = v; }
|
|
119
134
|
else if (a.startsWith("--source=")) out.source = a.slice("--source=".length);
|
|
120
|
-
else if (a === "--max-age") out.maxAgeMs = parseDuration(
|
|
135
|
+
else if (a === "--max-age") { const v = takesValue(argv, ++i); if (v === undefined) out._argError = "prefetch: --max-age requires a value"; else out.maxAgeMs = parseDuration(v); }
|
|
121
136
|
else if (a.startsWith("--max-age=")) out.maxAgeMs = parseDuration(a.slice("--max-age=".length));
|
|
122
|
-
else if (a === "--cache-dir") out.cacheDir = path.resolve(
|
|
137
|
+
else if (a === "--cache-dir") { const v = takesValue(argv, ++i); if (v === undefined) out._argError = "prefetch: --cache-dir requires a value"; else out.cacheDir = path.resolve(v); }
|
|
123
138
|
else if (a.startsWith("--cache-dir=")) out.cacheDir = path.resolve(a.slice("--cache-dir=".length));
|
|
124
139
|
// Per-entry fetch-error tolerance. An integer is an absolute budget; an
|
|
125
140
|
// "<N>%" string is a fraction of the planned fetch count. A malformed
|
|
@@ -134,6 +149,19 @@ function parseArgs(argv) {
|
|
|
134
149
|
(out._unknownFlags || (out._unknownFlags = [])).push(base);
|
|
135
150
|
}
|
|
136
151
|
}
|
|
152
|
+
// A supplied-but-empty --source (`--source ""`, `--source=`, or a comma-only
|
|
153
|
+
// value like `--source ,`) resolves to no source names. Left unguarded, the
|
|
154
|
+
// empty string is falsy and silently warms ALL sources, while a comma-only
|
|
155
|
+
// value silently warms none — both reporting success. Treat either as a
|
|
156
|
+
// usage error so main() refuses with exit 2, matching the unknown-source
|
|
157
|
+
// contract. Only fire when --source was actually supplied (out.source != null)
|
|
158
|
+
// so the omitted-flag default (warm all) is preserved.
|
|
159
|
+
if (!out._argError && out.source != null) {
|
|
160
|
+
const names = String(out.source).split(",").map((s) => s.trim()).filter(Boolean);
|
|
161
|
+
if (names.length === 0) {
|
|
162
|
+
out._argError = "prefetch: --source given but resolved to no source names (empty or comma-only value)";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
137
165
|
// The global air-gap switch implies a report-only / no-egress run: treat
|
|
138
166
|
// EXCEPTD_AIR_GAP=1 the same as --no-network so prefetch never plans live
|
|
139
167
|
// fetches under air-gap.
|
|
@@ -141,6 +169,18 @@ function parseArgs(argv) {
|
|
|
141
169
|
return out;
|
|
142
170
|
}
|
|
143
171
|
|
|
172
|
+
// Read the value token a space-separated value-flag expects. Returns the
|
|
173
|
+
// token, or `undefined` when the operator left the flag trailing (no token
|
|
174
|
+
// follows) or the next token is itself a --flag (a swallowed missing value,
|
|
175
|
+
// e.g. `--max-age --no-network`). Callers convert undefined into a usage
|
|
176
|
+
// error rather than consuming a bad value.
|
|
177
|
+
function takesValue(argv, i) {
|
|
178
|
+
const v = argv[i];
|
|
179
|
+
if (v === undefined) return undefined;
|
|
180
|
+
if (typeof v === "string" && v.startsWith("--")) return undefined;
|
|
181
|
+
return v;
|
|
182
|
+
}
|
|
183
|
+
|
|
144
184
|
function parseDuration(s) {
|
|
145
185
|
if (!s) return 0;
|
|
146
186
|
const m = String(s).match(/^(\d+)\s*([smhd])?$/);
|
|
@@ -235,6 +275,9 @@ Options:
|
|
|
235
275
|
--no-network report-only; list what would be fetched.
|
|
236
276
|
--cache-dir <path> override cache root (default .cache/upstream).
|
|
237
277
|
--quiet suppress per-entry log lines.
|
|
278
|
+
--max-errors <n|n%> tolerate up to n (or n% of planned) per-entry fetch
|
|
279
|
+
errors before exit 1. Default: 0 (any error exits 1).
|
|
280
|
+
A fully-dead source still exits 1 regardless of budget.
|
|
238
281
|
|
|
239
282
|
Use NVD_API_KEY / GITHUB_TOKEN env vars to lift rate limits.
|
|
240
283
|
|
|
@@ -508,7 +551,15 @@ function isFresh(idx, source, id, maxAgeMs) {
|
|
|
508
551
|
const e = idx.entries[entryKey(source, id)];
|
|
509
552
|
if (!e) return false;
|
|
510
553
|
if (!e.fetched_at) return false;
|
|
511
|
-
|
|
554
|
+
const ageMs = Date.now() - new Date(e.fetched_at).getTime();
|
|
555
|
+
// A non-finite or negative age means the entry's provenance is untrustworthy:
|
|
556
|
+
// an unparseable fetched_at, or a future-dated one (clock skew or a poisoned
|
|
557
|
+
// index inflating apparent freshness past the maxAge gate). Either way, treat
|
|
558
|
+
// it as stale and force a re-fetch — re-fetching restores trustworthy
|
|
559
|
+
// provenance. This mirrors readCached()'s lower-bound guard so the planning
|
|
560
|
+
// side and read side cannot diverge on the same poisoned entry.
|
|
561
|
+
if (!Number.isFinite(ageMs) || ageMs < 0) return false;
|
|
562
|
+
return ageMs < maxAgeMs;
|
|
512
563
|
}
|
|
513
564
|
|
|
514
565
|
function authHeadersForSource(source) {
|
|
@@ -529,11 +580,32 @@ function authHeadersForSource(source) {
|
|
|
529
580
|
async function prefetch(options = {}) {
|
|
530
581
|
const opts = { maxAgeMs: 24 * 3600 * 1000, source: null, force: false, noNetwork: false, cacheDir: DEFAULT_CACHE, quiet: false, ...options };
|
|
531
582
|
const ctx = loadCtx();
|
|
532
|
-
|
|
583
|
+
// Distinguish "operator omitted --source" (resolve to all sources, the
|
|
584
|
+
// documented default) from "operator passed --source but it resolved to
|
|
585
|
+
// nothing" (empty string or a comma-only value). The latter is a usage
|
|
586
|
+
// error, not a silent run-everything / run-nothing: an empty value would
|
|
587
|
+
// otherwise warm ALL sources and a comma-only value would warm NONE, both
|
|
588
|
+
// reporting success. Refuse so the typo surfaces. (main() maps the throw to
|
|
589
|
+
// exit 2, matching the existing unknown-source contract.)
|
|
590
|
+
const sourceSupplied = opts.source != null;
|
|
591
|
+
const chosen = sourceSupplied
|
|
533
592
|
? opts.source.split(",").map((s) => s.trim()).filter(Boolean)
|
|
534
593
|
: Object.keys(SOURCES);
|
|
594
|
+
if (sourceSupplied && chosen.length === 0) {
|
|
595
|
+
throw new Error('prefetch: --source given but resolved to no source names (empty or comma-only value)');
|
|
596
|
+
}
|
|
535
597
|
for (const n of chosen) {
|
|
536
|
-
if (!SOURCES[n])
|
|
598
|
+
if (!SOURCES[n]) {
|
|
599
|
+
// The refresh orchestrator exposes additional sources (ghsa, osv,
|
|
600
|
+
// advisories, cve-regression-watcher) that resolve advisories by live
|
|
601
|
+
// id lookup and have no prefetch cache layer. When the operator scopes
|
|
602
|
+
// a cache-warm to one of those, name the prefetchable subset rather than
|
|
603
|
+
// a bare "unknown source" — the source is real, it just isn't cacheable.
|
|
604
|
+
if (LIVE_ONLY_REFRESH_SOURCES.has(n)) {
|
|
605
|
+
throw new Error(`prefetch: source "${n}" has no prefetch cache layer (live id lookup only); prefetchable sources: ${Object.keys(SOURCES).join(",")}`);
|
|
606
|
+
}
|
|
607
|
+
throw new Error(`prefetch: unknown source "${n}"; prefetchable sources: ${Object.keys(SOURCES).join(",")}`);
|
|
608
|
+
}
|
|
537
609
|
}
|
|
538
610
|
|
|
539
611
|
// Build the queue with per-source budgets. NVD / GitHub upgrade if env-key
|