@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.
@@ -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 / resolved / false_positive. They remain in cve-catalog.json; the disposition lives in the VEX file.`
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
- // When the clock event is detect_confirmed AND detection was confirmed
1688
- // (by the agent OR the engine) AND the operator did NOT pass --ack,
1689
- // surface clock_pending_ack so the notification record is visibly waiting
1690
- // on acknowledgement.
1691
- const clockPendingAck = !clockStart
1692
- && obligation?.clock_starts === 'detect_confirmed'
1693
- && (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
1694
1723
  && !(runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true);
1695
- const deadline = obligation && clockStart
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
- 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,
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
- const slug = urnSlug(id);
2005
- if (typeof id !== 'string' || id.length === 0) return `urn:exceptd:advisory:${slug}`;
2006
- if (/^CVE-/i.test(id)) return `urn:cve:${slug}`;
2007
- if (/^GHSA-/i.test(id)) return `urn:ghsa:${slug}`;
2008
- if (/^RUSTSEC-/i.test(id)) return `urn:rustsec:${slug}`;
2009
- if (/^MAL-/i.test(id)) return `urn:malicious-package:${slug}`;
2010
- 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)}`;
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 ([a-z0-9_-]+ per RFC 8141 NSS).
2115
- // Empty input → 'unknown' so we never emit zero-length segments.
2116
- 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) {
2117
2207
  if (s == null) return 'unknown';
2118
- const slug = String(s)
2119
- .toLowerCase()
2120
- .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, '-')
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: `${rulePrefix}${c.cve_id}`, shortDescription: { text: c.cve_id },
2828
- fullDescription: { text: `RWEP ${c.rwep} · KEV=${c.cisa_kev} · active_exploitation=${c.active_exploitation}` },
2829
- defaultConfiguration: { level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note' },
2830
- helpUri: `https://nvd.nist.gov/vuln/detail/${c.cve_id}`,
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
- * - 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:
3906
4059
  * stamp `now` ONLY if runOpts.operator_consent?.explicit === true
3907
- * (i.e. the operator passed --ack). Without --ack, return null and
3908
- * the caller (close()) surfaces clock_pending_ack: true on the
3909
- * notification_actions entry so the operator sees that the clock is
3910
- * waiting on acknowledgement.
3911
- * - 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.
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]) return new Date(agentSignals[key]);
3917
- // For detect_confirmed: only auto-stamp when the operator has explicitly
3918
- // acknowledged the result via --ack. Otherwise leave the clock pending.
3919
- // 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
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
- if (eventName === 'detect_confirmed' && detected
3928
- && runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true) {
3929
- return new Date();
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
- 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