@blamejs/exceptd-skills 0.16.29 → 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 +14 -0
- package/bin/exceptd.js +212 -12
- package/data/_indexes/_meta.json +2 -2
- package/data/playbooks/crypto.json +6 -0
- package/lib/collectors/README.md +3 -2
- package/lib/cross-ref-api.js +96 -31
- package/lib/playbook-runner.js +247 -48
- package/lib/prefetch.js +78 -6
- package/lib/refresh-external.js +103 -3
- package/lib/scoring.js +49 -5
- package/lib/validate-cve-catalog.js +14 -2
- package/lib/validate-playbooks.js +133 -38
- package/manifest.json +53 -53
- package/package.json +1 -1
- package/sbom.cdx.json +34 -34
- package/scripts/run-e2e-scenarios.js +41 -11
package/lib/playbook-runner.js
CHANGED
|
@@ -1368,9 +1368,15 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1368
1368
|
filter_applied: true,
|
|
1369
1369
|
dropped_cve_count: vexDropped.length,
|
|
1370
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,
|
|
1371
1377
|
note: vexDropped.length
|
|
1372
|
-
? `${vexDropped.length} CVE(s) dropped from analyze because the operator-supplied VEX statement marks them not_affected /
|
|
1373
|
-
: "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)."
|
|
1374
1380
|
} : null,
|
|
1375
1381
|
// Regex-eval failures surfaced here so operators can see WHICH
|
|
1376
1382
|
// condition expression crashed without the runner dying. Only present
|
|
@@ -1672,6 +1678,15 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1672
1678
|
// upstream `govern.jurisdiction_obligations` has the real data — carry it
|
|
1673
1679
|
// forward. `notification_deadline` is published as an alias for `deadline`
|
|
1674
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
|
+
};
|
|
1675
1690
|
const enrichNotification = (na) => {
|
|
1676
1691
|
const obligation = (g.jurisdiction_obligations || []).find(o =>
|
|
1677
1692
|
`${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
|
|
@@ -1682,17 +1697,31 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1682
1697
|
// starts the clock even without a separately-submitted classification.
|
|
1683
1698
|
const engineClassification = analyzeResult?._detect_classification || null;
|
|
1684
1699
|
const clockStart = obligation
|
|
1685
|
-
? computeClockStart(obligation.clock_starts, agentSignals, runOpts, engineClassification)
|
|
1700
|
+
? computeClockStart(obligation.clock_starts, agentSignals, runOpts, engineClassification, phaseFlags, frozenEpoch)
|
|
1686
1701
|
: null;
|
|
1687
|
-
//
|
|
1688
|
-
//
|
|
1689
|
-
//
|
|
1690
|
-
//
|
|
1691
|
-
const
|
|
1692
|
-
|
|
1693
|
-
|
|
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
|
|
1694
1723
|
&& !(runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true);
|
|
1695
|
-
const deadline = obligation &&
|
|
1724
|
+
const deadline = obligation && clockValid
|
|
1696
1725
|
? new Date(clockStart.getTime() + obligation.window_hours * 3600 * 1000).toISOString()
|
|
1697
1726
|
: 'pending_clock_start_event';
|
|
1698
1727
|
return {
|
|
@@ -1705,7 +1734,11 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1705
1734
|
obligation_type: obligation?.obligation || null,
|
|
1706
1735
|
window_hours: obligation?.window_hours ?? null,
|
|
1707
1736
|
clock_start_event: obligation?.clock_starts || null,
|
|
1708
|
-
|
|
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,
|
|
1709
1742
|
...(clockPendingAck ? { clock_pending_ack: true } : {}),
|
|
1710
1743
|
deadline,
|
|
1711
1744
|
// Alias matching compliance-team vocabulary.
|
|
@@ -1996,18 +2029,47 @@ function analyzeFindingShape(a) {
|
|
|
1996
2029
|
};
|
|
1997
2030
|
}
|
|
1998
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
|
+
|
|
1999
2058
|
// Route a vulnerability identifier to its registry-specific URN namespace.
|
|
2000
2059
|
// CVE-/GHSA-/RUSTSEC-/MAL-* identifiers each have a registered URN namespace;
|
|
2001
2060
|
// unrecognised prefixes route to the `urn:exceptd:advisory:` private
|
|
2002
2061
|
// namespace so OpenVEX statements still carry a valid IRI per RFC 8141.
|
|
2003
2062
|
function vulnIdToUrn(id) {
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
if (/^
|
|
2010
|
-
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)}`;
|
|
2011
2073
|
}
|
|
2012
2074
|
|
|
2013
2075
|
// Build a CSAF product_tree.branches[] tree (vendor → product_name →
|
|
@@ -2033,6 +2095,12 @@ function buildCsafBranches(matchedCves, runOpts) {
|
|
|
2033
2095
|
products.get(product).add(version);
|
|
2034
2096
|
};
|
|
2035
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
|
+
|
|
2036
2104
|
// Heuristic parser. Returns { vendor, product, version } or null.
|
|
2037
2105
|
const parseComponentString = (s) => {
|
|
2038
2106
|
if (typeof s !== 'string' || !s.trim()) return null;
|
|
@@ -2040,9 +2108,28 @@ function buildCsafBranches(matchedCves, runOpts) {
|
|
|
2040
2108
|
// `vendor/product@version`
|
|
2041
2109
|
let m = trimmed.match(/^([^/\s@]+)\/([^/\s@]+)@(.+)$/);
|
|
2042
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
|
+
}
|
|
2043
2131
|
// `vendor product version` — exactly three whitespace-separated tokens
|
|
2044
2132
|
// where the last token starts with a digit or `v\d`.
|
|
2045
|
-
const parts = trimmed.split(/\s+/);
|
|
2046
2133
|
if (parts.length >= 3) {
|
|
2047
2134
|
const last = parts[parts.length - 1];
|
|
2048
2135
|
if (/^v?\d/.test(last)) {
|
|
@@ -2111,13 +2198,17 @@ function buildCsafBranches(matchedCves, runOpts) {
|
|
|
2111
2198
|
return { branches, productIds };
|
|
2112
2199
|
}
|
|
2113
2200
|
|
|
2114
|
-
// Slugify a string into a URN-safe segment (
|
|
2115
|
-
//
|
|
2116
|
-
|
|
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) {
|
|
2117
2207
|
if (s == null) return 'unknown';
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
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, '-')
|
|
2121
2212
|
.replace(/^-+|-+$/g, '');
|
|
2122
2213
|
return slug.length ? slug : 'unknown';
|
|
2123
2214
|
}
|
|
@@ -2823,12 +2914,25 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2823
2914
|
message: { text: `${g.framework}: ${g.claimed_control} — ${g.actual_gap}${g.required_control ? '. Required: ' + g.required_control : ''}` },
|
|
2824
2915
|
properties: stripNulls({ kind: 'framework_gap', framework: g.framework, control: g.claimed_control }),
|
|
2825
2916
|
}));
|
|
2826
|
-
const cveRules = analyze.matched_cves.map(c =>
|
|
2827
|
-
id
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
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
|
+
});
|
|
2832
2936
|
const indicatorRules = indicatorHits.map(i => ({
|
|
2833
2937
|
id: `${rulePrefix}${i.id}`, shortDescription: { text: i.id },
|
|
2834
2938
|
fullDescription: { text: `Indicator from playbook ${playbook._meta.id}. Type: ${i.type}. Confidence: ${i.confidence}.` },
|
|
@@ -3890,6 +3994,51 @@ function stripOuterParens(expr) {
|
|
|
3890
3994
|
return expr;
|
|
3891
3995
|
}
|
|
3892
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
|
+
|
|
3893
4042
|
/**
|
|
3894
4043
|
* Compute the start instant for a jurisdictional clock event. The agent
|
|
3895
4044
|
* submits clock_started_at_<event> ISO strings as it progresses through
|
|
@@ -3901,22 +4050,57 @@ function stripOuterParens(expr) {
|
|
|
3901
4050
|
* whenever the engine classifies as detected would be incorrect: the
|
|
3902
4051
|
* operator may not have seen the result yet. Semantics:
|
|
3903
4052
|
*
|
|
3904
|
-
* - If the agent explicitly submits clock_started_at_<event>: use it
|
|
3905
|
-
*
|
|
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:
|
|
3906
4059
|
* stamp `now` ONLY if runOpts.operator_consent?.explicit === true
|
|
3907
|
-
* (i.e. the operator passed --ack).
|
|
3908
|
-
* the
|
|
3909
|
-
*
|
|
3910
|
-
*
|
|
3911
|
-
*
|
|
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.
|
|
3912
4071
|
*/
|
|
3913
|
-
function computeClockStart(eventName, agentSignals, runOpts = {}, engineClassification = null) {
|
|
4072
|
+
function computeClockStart(eventName, agentSignals, runOpts = {}, engineClassification = null, phaseFlags = {}, frozenEpoch = null) {
|
|
3914
4073
|
// The agent submits clock_started_at_<event> ISO strings as it progresses.
|
|
3915
4074
|
const key = `clock_started_at_${eventName}`;
|
|
3916
|
-
if (agentSignals && agentSignals[key])
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
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
|
|
3920
4104
|
// detection_classification:'detected' OR the engine itself classified the
|
|
3921
4105
|
// detect phase as 'detected'. Pre-fix only the agent-submitted signal was
|
|
3922
4106
|
// honored, so an engine-confirmed detection (indicators fired from
|
|
@@ -3924,10 +4108,22 @@ function computeClockStart(eventName, agentSignals, runOpts = {}, engineClassifi
|
|
|
3924
4108
|
// started the regulatory clock — notification deadlines silently stalled.
|
|
3925
4109
|
const detected = agentSignals?.detection_classification === 'detected'
|
|
3926
4110
|
|| engineClassification === 'detected';
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
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();
|
|
3931
4127
|
return null;
|
|
3932
4128
|
}
|
|
3933
4129
|
|
|
@@ -4025,6 +4221,9 @@ module.exports = {
|
|
|
4025
4221
|
_releaseLock: releaseLock,
|
|
4026
4222
|
_lockFilePath: lockFilePath,
|
|
4027
4223
|
_vulnIdToUrn: vulnIdToUrn,
|
|
4224
|
+
_buildCsafBranches: buildCsafBranches,
|
|
4225
|
+
_advisoryAuthorityFor: advisoryAuthorityFor,
|
|
4226
|
+
_computeClockStart: computeClockStart,
|
|
4028
4227
|
_worstActiveExploitation: worstActiveExploitation,
|
|
4029
4228
|
// Re-exported from scoring so parity between the catalog scorer and the
|
|
4030
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
|