@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.
@@ -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
- let factorCve = matchedCves[0] || null;
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 / resolved / false_positive. They remain in cve-catalog.json; the disposition lives in the VEX file.`
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
- // When the clock event is detect_confirmed AND detection was confirmed
1677
- // (by the agent OR the engine) AND the operator did NOT pass --ack,
1678
- // surface clock_pending_ack so the notification record is visibly waiting
1679
- // on acknowledgement.
1680
- const clockPendingAck = !clockStart
1681
- && obligation?.clock_starts === 'detect_confirmed'
1682
- && (agentSignals?.detection_classification === 'detected' || engineClassification === 'detected')
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 && clockStart
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
- clock_started_at: clockStart?.toISOString() || null,
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
- active_exploitation: worstActiveExploitation(matched),
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
- const slug = urnSlug(id);
1992
- if (typeof id !== 'string' || id.length === 0) return `urn:exceptd:advisory:${slug}`;
1993
- if (/^CVE-/i.test(id)) return `urn:cve:${slug}`;
1994
- if (/^GHSA-/i.test(id)) return `urn:ghsa:${slug}`;
1995
- if (/^RUSTSEC-/i.test(id)) return `urn:rustsec:${slug}`;
1996
- if (/^MAL-/i.test(id)) return `urn:malicious-package:${slug}`;
1997
- return `urn:exceptd:advisory:${slug}`;
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 ([a-z0-9_-]+ per RFC 8141 NSS).
2102
- // Empty input → 'unknown' so we never emit zero-length segments.
2103
- function urnSlug(s) {
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
- const slug = String(s)
2106
- .toLowerCase()
2107
- .replace(/[^a-z0-9_-]+/g, '-')
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: `${rulePrefix}${c.cve_id}`, shortDescription: { text: c.cve_id },
2815
- fullDescription: { text: `RWEP ${c.rwep} · KEV=${c.cisa_kev} · active_exploitation=${c.active_exploitation}` },
2816
- defaultConfiguration: { level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note' },
2817
- helpUri: `https://nvd.nist.gov/vuln/detail/${c.cve_id}`,
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
- * - Otherwise, for 'detect_confirmed' with classification='detected':
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). Without --ack, return null and
3895
- * the caller (close()) surfaces clock_pending_ack: true on the
3896
- * notification_actions entry so the operator sees that the clock is
3897
- * waiting on acknowledgement.
3898
- * - All other events without an explicit timestamp: return null.
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]) return new Date(agentSignals[key]);
3904
- // For detect_confirmed: only auto-stamp when the operator has explicitly
3905
- // acknowledged the result via --ack. Otherwise leave the clock pending.
3906
- // Detection is "confirmed" when EITHER the agent submitted
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
- if (eventName === 'detect_confirmed' && detected
3915
- && runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true) {
3916
- return new Date();
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
- else if (a === "--source") out.source = argv[++i];
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(argv[++i]);
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(argv[++i]);
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
- return Date.now() - new Date(e.fetched_at).getTime() < maxAgeMs;
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
- const chosen = opts.source
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]) throw new Error(`prefetch: unknown source "${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