@blamejs/exceptd-skills 0.18.21 → 0.18.23

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/lib/prefetch.js CHANGED
@@ -46,6 +46,23 @@ const DEFAULT_CACHE = path.join(ROOT, ".cache", "upstream");
46
46
  const REQUEST_TIMEOUT_MS = 10_000;
47
47
  const USER_AGENT = "exceptd-security/prefetch (+https://exceptd.com)";
48
48
 
49
+ // CVE ids the CISA KEV feed lists that are NOT yet in the local catalog.
50
+ // Pure — no fs/network. `kevFeed` is the parsed known_exploited_vulnerabilities
51
+ // JSON (or null/undefined if unavailable); `cveCatalog` is data/cve-catalog.json
52
+ // (or a synthetic subset in tests). Consumed by nvd/epss expand() below so a
53
+ // CVE CISA newly added to KEV gets its NVD/EPSS sidecar prefetched in the SAME
54
+ // run auto-discovery will later read via lib/auto-discovery.js:readCachedJson
55
+ // — without this, buildKevDraftEntry's nvd/epss payloads stayed null forever
56
+ // for anything auto-discovery itself found, because nothing had ever asked
57
+ // prefetch to warm a sidecar for an id absent from the local catalog.
58
+ function newKevIds(kevFeed, cveCatalog) {
59
+ const have = new Set(Object.keys(cveCatalog || {}));
60
+ const vulns = Array.isArray(kevFeed && kevFeed.vulnerabilities) ? kevFeed.vulnerabilities : [];
61
+ return vulns
62
+ .map((v) => v.cveID)
63
+ .filter((id) => /^CVE-\d{4}-\d{4,7}$/.test(id) && !have.has(id));
64
+ }
65
+
49
66
  const SOURCES = {
50
67
  kev: {
51
68
  description: "CISA Known Exploited Vulnerabilities (single feed)",
@@ -58,17 +75,27 @@ const SOURCES = {
58
75
  rate: { tokens: 5, windowMs: 30_000 }, // anon budget; NVD_API_KEY lifts to 50
59
76
  rate_with_key: { tokens: 50, windowMs: 30_000 },
60
77
  concurrency: 4,
61
- expand: (ctx) => Object.keys(ctx.cveCatalog)
62
- .filter((k) => /^CVE-\d{4}-\d{4,7}$/.test(k))
63
- .map((id) => ({ id, url: `https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=${encodeURIComponent(id)}` })),
78
+ // Union the local catalog with any newly-KEV-listed id (ctx.kevFeed, when
79
+ // the run loop resolved it — see prefetch()'s KEV pre-fetch step below).
80
+ // ctx.kevFeed absent/null is the documented fallback (KEV out of scope
81
+ // this run, or --no-network): newKevIds returns [], so this degrades to
82
+ // the pre-Task-11 catalog-only expansion, not a crash.
83
+ expand: (ctx) => {
84
+ const ids = new Set(Object.keys(ctx.cveCatalog).filter((k) => /^CVE-\d{4}-\d{4,7}$/.test(k)));
85
+ for (const id of newKevIds(ctx.kevFeed, ctx.cveCatalog)) ids.add(id);
86
+ return [...ids].map((id) => ({ id, url: `https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=${encodeURIComponent(id)}` }));
87
+ },
64
88
  },
65
89
  epss: {
66
90
  description: "FIRST.org EPSS per-CVE responses",
67
91
  rate: { tokens: 30, windowMs: 60_000 },
68
92
  concurrency: 4,
69
- expand: (ctx) => Object.keys(ctx.cveCatalog)
70
- .filter((k) => /^CVE-\d{4}-\d{4,7}$/.test(k))
71
- .map((id) => ({ id, url: `https://api.first.org/data/v1/epss?cve=${encodeURIComponent(id)}` })),
93
+ // Same new-KEV union as nvd above.
94
+ expand: (ctx) => {
95
+ const ids = new Set(Object.keys(ctx.cveCatalog).filter((k) => /^CVE-\d{4}-\d{4,7}$/.test(k)));
96
+ for (const id of newKevIds(ctx.kevFeed, ctx.cveCatalog)) ids.add(id);
97
+ return [...ids].map((id) => ({ id, url: `https://api.first.org/data/v1/epss?cve=${encodeURIComponent(id)}` }));
98
+ },
72
99
  },
73
100
  rfc: {
74
101
  description: "IETF Datatracker per-RFC/doc records",
@@ -675,8 +702,156 @@ async function prefetch(options = {}) {
675
702
  const idx = loadIndex(opts.cacheDir);
676
703
  if (!fs.existsSync(opts.cacheDir)) fs.mkdirSync(opts.cacheDir, { recursive: true });
677
704
 
705
+ const log = (s) => opts.quiet || console.log(s);
706
+ const result = { fetched: 0, skipped_fresh: 0, errors: 0, errors_transient: 0, errors_hard: 0, by_source: {} };
707
+ for (const s of chosen) result.by_source[s] = { fetched: 0, skipped_fresh: 0, errors: 0, errors_transient: 0, errors_hard: 0 };
708
+
709
+ // Fetch (or fresh-skip) a single plan item: on a live fetch, writes the
710
+ // payload + updates _index.json under lock and mirrors it into the
711
+ // in-memory `idx` snapshot; on a fresh-skip, just counts it. Updates
712
+ // `result` bookkeeping identically either way. Factored out of the main
713
+ // per-item loop below so the KEV pre-fetch step (Task 11, next) can reuse
714
+ // the exact same fetch/write/index/error-classification contract instead
715
+ // of duplicating it.
716
+ //
717
+ // Returns the entry's parsed JSON body when `needData` is true — a
718
+ // freshly-fetched item returns it for free (already in memory from the
719
+ // fetch); a fresh-skipped item costs one extra cache read via
720
+ // `readCached`, so `needData` defaults to false and the main plan loop
721
+ // (hundreds to ~9.7k items on the Monday RFC sweep) never pays it. Only
722
+ // the one-off KEV pre-fetch below opts in. Returns null on error, or when
723
+ // `needData` is false.
724
+ async function fetchEntry(item, { needData = false } = {}) {
725
+ if (item.fresh) {
726
+ result.skipped_fresh++;
727
+ result.by_source[item.source].skipped_fresh++;
728
+ if (!needData) return null;
729
+ const cached = readCached(opts.cacheDir, item.source, item.id, { maxAgeMs: opts.maxAgeMs });
730
+ return cached ? cached.data : null;
731
+ }
732
+ const headers = authHeadersForSource(item.source);
733
+ // NVD takes its key in a custom header.
734
+ const reqHeaders = item.source === "nvd" && headers.apiKey ? { apiKey: headers.apiKey } : (item.source === "pins" ? headers : {});
735
+ try {
736
+ const res = await queue.add({
737
+ source: item.source,
738
+ priority: priorityFor(item.source),
739
+ run: () => timedFetch(item.url, reqHeaders),
740
+ meta: { id: item.id },
741
+ });
742
+ const targetPath = entryPath(opts.cacheDir, item.source, item.id);
743
+ const dir = path.dirname(targetPath);
744
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
745
+ const body = JSON.stringify(res.json, null, 2) + "\n";
746
+ // Stage the payload to a same-volume tmp file BEFORE attempting
747
+ // to acquire the index lock. If withIndexLock fails (timeout
748
+ // after MAX_RETRIES), the partially-completed download must be
749
+ // discarded — not left on disk as an orphan payload with no
750
+ // index entry. Air-gap operators feed off `readCached`, which
751
+ // consults the index; an unindexed payload silently becomes junk
752
+ // taking cache space. Pattern: stage → lock → rename+index →
753
+ // release. The rename is atomic same-volume; if it fails inside
754
+ // the lock we clean up the tmp file. If we never reach the rename
755
+ // (lock acquisition throws), the tmp file is unlinked in the
756
+ // catch block below.
757
+ const tmpPath = `${targetPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
758
+ fs.writeFileSync(tmpPath, body);
759
+ const meta = {
760
+ fetched_at: new Date().toISOString(),
761
+ etag: res.etag,
762
+ last_modified: res.lastModified,
763
+ url: item.url,
764
+ sha256: crypto.createHash("sha256").update(JSON.stringify(res.json)).digest("hex"),
765
+ };
766
+ try {
767
+ // v0.12.12 C2: persist this entry's metadata to _index.json under
768
+ // lock immediately, merging with whatever the on-disk index has
769
+ // (another concurrent prefetch may have written sibling entries).
770
+ // Inside the lock we also rename the staged tmp → final path so
771
+ // a concurrent reader sees the new payload + new index entry as
772
+ // an atomic pair.
773
+ await withIndexLock(opts.cacheDir, (current) => {
774
+ try {
775
+ fs.renameSync(tmpPath, targetPath);
776
+ } catch (renameErr) {
777
+ // Surface as a failure to mutator: throwing here aborts the
778
+ // lock's write step. We re-throw to the outer catch which
779
+ // will increment errors.
780
+ throw renameErr;
781
+ }
782
+ current.entries[entryKey(item.source, item.id)] = meta;
783
+ return current;
784
+ });
785
+ // Mirror the entry into the in-memory idx snapshot so any
786
+ // later in-run freshness check sees this entry as fresh. The
787
+ // authoritative on-disk write already happened under the lock
788
+ // above; this is the in-memory copy only.
789
+ idx.entries[entryKey(item.source, item.id)] = meta;
790
+ } catch (lockErr) {
791
+ // Lock failure OR rename-inside-lock failure — unlink the staged
792
+ // tmp so the cache directory does not accumulate orphans.
793
+ try { fs.unlinkSync(tmpPath); } catch {}
794
+ throw lockErr;
795
+ }
796
+ result.fetched++;
797
+ result.by_source[item.source].fetched++;
798
+ log(` [${item.source}] ${item.id} — ok`);
799
+ return needData ? res.json : null;
800
+ } catch (err) {
801
+ result.errors++;
802
+ result.by_source[item.source].errors++;
803
+ // Classify the post-retry error. Transient iff the job queue would
804
+ // have retried it (the same isRetryable classifier the queue used):
805
+ // HTTP 408/425/429/5xx + ETIMEDOUT/ECONNRESET et al — the upstream
806
+ // throttling/timing-out, not a data fault. Anything else (404/410/
807
+ // parse failure) is hard. Only hard errors gate the exit code; a
808
+ // best-effort warm tolerates transient throttling and retries it on
809
+ // the next run. The split is surfaced in the summary so nothing hides.
810
+ const transient = isRetryable(err);
811
+ if (transient) {
812
+ result.errors_transient++;
813
+ result.by_source[item.source].errors_transient++;
814
+ } else {
815
+ result.errors_hard++;
816
+ result.by_source[item.source].errors_hard++;
817
+ }
818
+ // Errors go to stderr unconditionally — they are diagnostics, not the
819
+ // per-entry success chatter --quiet suppresses. A CI run with --quiet
820
+ // still surfaces which source/id failed and whether it was transient.
821
+ console.error(` [${item.source}] ${item.id} — ${transient ? "transient" : "hard"} error: ${err.message}`);
822
+ return null;
823
+ }
824
+ }
825
+
826
+ // Task 11: resolve the KEV feed BEFORE nvd/epss build their expansion
827
+ // list, so a CVE CISA added to KEV today gets an NVD/EPSS sidecar
828
+ // prefetched in THIS run. Reading a previously-persisted cache file would
829
+ // not do this reliably — the scheduled workflow's cache directory is not
830
+ // persisted across runs (each job warms an empty `.cache/upstream` from
831
+ // scratch), so by the time a later run's plan is built, "today's" KEV
832
+ // addition would already be a day old. Fetching (or fresh-skipping) KEV
833
+ // synchronously here, before the main plan is built, guarantees ctx.kevFeed
834
+ // reflects this run's own KEV data.
835
+ //
836
+ // Only fires when "kev" is actually in scope and network fetches are
837
+ // enabled. Under --no-network (dry-run/--air-gap) or a --source scope that
838
+ // excludes kev, ctx.kevFeed stays unset and nvd/epss's expand() falls back
839
+ // to catalog-only expansion via newKevIds' null-safe default — no crash,
840
+ // and no behavior change from before this task.
841
+ let kevPrefetched = false;
842
+ if (chosen.includes("kev") && !opts.noNetwork) {
843
+ const [kevEntry] = SOURCES.kev.expand();
844
+ if (kevEntry) {
845
+ const fresh = !opts.force && isFresh(idx, "kev", kevEntry.id, opts.maxAgeMs);
846
+ const data = await fetchEntry({ source: "kev", id: kevEntry.id, url: kevEntry.url, fresh }, { needData: true });
847
+ if (data) ctx.kevFeed = data;
848
+ kevPrefetched = true;
849
+ }
850
+ }
851
+
678
852
  const plan = [];
679
853
  for (const sourceName of chosen) {
854
+ if (sourceName === "kev" && kevPrefetched) continue; // already resolved above
680
855
  const cfg = SOURCES[sourceName];
681
856
  const entries = cfg.expand(ctx);
682
857
  for (const e of entries) {
@@ -685,14 +860,11 @@ async function prefetch(options = {}) {
685
860
  }
686
861
  }
687
862
 
688
- const log = (s) => opts.quiet || console.log(s);
689
- log(`\nprefetch — ${opts.noNetwork ? "DRY-RUN" : "fetching"} ${plan.length} item(s) across ${chosen.length} source(s)`);
863
+ const totalItems = plan.length + (kevPrefetched ? 1 : 0);
864
+ log(`\nprefetch — ${opts.noNetwork ? "DRY-RUN" : "fetching"} ${totalItems} item(s) across ${chosen.length} source(s)`);
690
865
  log(`Cache dir: ${path.relative(ROOT, opts.cacheDir)}`);
691
866
  log(`Max age: ${(opts.maxAgeMs / 3_600_000).toFixed(1)}h${opts.force ? " (forced)" : ""}`);
692
867
 
693
- const result = { fetched: 0, skipped_fresh: 0, errors: 0, errors_transient: 0, errors_hard: 0, by_source: {} };
694
- for (const s of chosen) result.by_source[s] = { fetched: 0, skipped_fresh: 0, errors: 0, errors_transient: 0, errors_hard: 0 };
695
-
696
868
  if (opts.noNetwork) {
697
869
  for (const item of plan) {
698
870
  const tag = item.fresh ? "FRESH (skip)" : "STALE (would fetch)";
@@ -709,105 +881,7 @@ async function prefetch(options = {}) {
709
881
  return result;
710
882
  }
711
883
 
712
- const jobPromises = plan.map((item) => {
713
- if (item.fresh) {
714
- result.skipped_fresh++;
715
- result.by_source[item.source].skipped_fresh++;
716
- return Promise.resolve();
717
- }
718
- const headers = authHeadersForSource(item.source);
719
- // NVD takes its key in a custom header.
720
- const reqHeaders = item.source === "nvd" && headers.apiKey ? { apiKey: headers.apiKey } : (item.source === "pins" ? headers : {});
721
- return queue
722
- .add({
723
- source: item.source,
724
- priority: priorityFor(item.source),
725
- run: () => timedFetch(item.url, reqHeaders),
726
- meta: { id: item.id },
727
- })
728
- .then(async (res) => {
729
- const targetPath = entryPath(opts.cacheDir, item.source, item.id);
730
- const dir = path.dirname(targetPath);
731
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
732
- const body = JSON.stringify(res.json, null, 2) + "\n";
733
- // Stage the payload to a same-volume tmp file BEFORE attempting
734
- // to acquire the index lock. If withIndexLock fails (timeout
735
- // after MAX_RETRIES), the partially-completed download must be
736
- // discarded — not left on disk as an orphan payload with no
737
- // index entry. Air-gap operators feed off `readCached`, which
738
- // consults the index; an unindexed payload silently becomes junk
739
- // taking cache space. Pattern: stage → lock → rename+index →
740
- // release. The rename is atomic same-volume; if it fails inside
741
- // the lock we clean up the tmp file. If we never reach the rename
742
- // (lock acquisition throws), the tmp file is unlinked in the
743
- // catch block below.
744
- const tmpPath = `${targetPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
745
- fs.writeFileSync(tmpPath, body);
746
- const meta = {
747
- fetched_at: new Date().toISOString(),
748
- etag: res.etag,
749
- last_modified: res.lastModified,
750
- url: item.url,
751
- sha256: crypto.createHash("sha256").update(JSON.stringify(res.json)).digest("hex"),
752
- };
753
- try {
754
- // v0.12.12 C2: persist this entry's metadata to _index.json under
755
- // lock immediately, merging with whatever the on-disk index has
756
- // (another concurrent prefetch may have written sibling entries).
757
- // Inside the lock we also rename the staged tmp → final path so
758
- // a concurrent reader sees the new payload + new index entry as
759
- // an atomic pair.
760
- await withIndexLock(opts.cacheDir, (current) => {
761
- try {
762
- fs.renameSync(tmpPath, targetPath);
763
- } catch (renameErr) {
764
- // Surface as a failure to mutator: throwing here aborts the
765
- // lock's write step. We re-throw to the outer catch which
766
- // will increment errors.
767
- throw renameErr;
768
- }
769
- current.entries[entryKey(item.source, item.id)] = meta;
770
- return current;
771
- });
772
- // Mirror the entry into the in-memory idx snapshot so any
773
- // later in-run freshness check sees this entry as fresh. The
774
- // authoritative on-disk write already happened under the lock
775
- // above; this is the in-memory copy only.
776
- idx.entries[entryKey(item.source, item.id)] = meta;
777
- } catch (lockErr) {
778
- // Lock failure OR rename-inside-lock failure — unlink the staged
779
- // tmp so the cache directory does not accumulate orphans.
780
- try { fs.unlinkSync(tmpPath); } catch {}
781
- throw lockErr;
782
- }
783
- result.fetched++;
784
- result.by_source[item.source].fetched++;
785
- log(` [${item.source}] ${item.id} — ok`);
786
- })
787
- .catch((err) => {
788
- result.errors++;
789
- result.by_source[item.source].errors++;
790
- // Classify the post-retry error. Transient iff the job queue would
791
- // have retried it (the same isRetryable classifier the queue used):
792
- // HTTP 408/425/429/5xx + ETIMEDOUT/ECONNRESET et al — the upstream
793
- // throttling/timing-out, not a data fault. Anything else (404/410/
794
- // parse failure) is hard. Only hard errors gate the exit code; a
795
- // best-effort warm tolerates transient throttling and retries it on
796
- // the next run. The split is surfaced in the summary so nothing hides.
797
- const transient = isRetryable(err);
798
- if (transient) {
799
- result.errors_transient++;
800
- result.by_source[item.source].errors_transient++;
801
- } else {
802
- result.errors_hard++;
803
- result.by_source[item.source].errors_hard++;
804
- }
805
- // Errors go to stderr unconditionally — they are diagnostics, not the
806
- // per-entry success chatter --quiet suppresses. A CI run with --quiet
807
- // still surfaces which source/id failed and whether it was transient.
808
- console.error(` [${item.source}] ${item.id} — ${transient ? "transient" : "hard"} error: ${err.message}`);
809
- });
810
- });
884
+ const jobPromises = plan.map((item) => fetchEntry(item));
811
885
 
812
886
  await Promise.all(jobPromises);
813
887
  await queue.drain();
@@ -976,6 +1050,11 @@ module.exports = {
976
1050
  formatSummary,
977
1051
  SOURCES,
978
1052
  DEFAULT_CACHE,
1053
+ // Task 11: pure helper (KEV feed minus local catalog) consumed by
1054
+ // nvd/epss expand() above. Exported for direct unit testing and so other
1055
+ // callers (e.g. a future report-only "what's new in KEV" surface) don't
1056
+ // have to re-derive the same set.
1057
+ newKevIds,
979
1058
  // Ed25519 _index.json signing + verification. Exported so
980
1059
  // lib/refresh-external.js (which consumes --from-cache) can verify the
981
1060
  // sidecar before trusting any cached entry, and so test harnesses can
package/lib/scoring.js CHANGED
@@ -440,6 +440,29 @@ function deriveRwepFromFactors(factors) {
440
440
  return Math.max(0, Math.min(100, sum));
441
441
  }
442
442
 
443
+ // Post-weight (Shape-B) factor object required by cve-catalog.schema.json.
444
+ // Canonical home for the math auto-discovery.js/cve-enrich.js both consume,
445
+ // so Σ Object.values(...) === scoreCustom(inputs) (pre-clamp) by construction.
446
+ function postWeightFactors(inputs) {
447
+ const i = inputs || {};
448
+ const aeMultiplier = activeExploitationMultiplier(i.active_exploitation);
449
+ const reboot = (i.reboot_required === true) || (i.patch_required_reboot === true);
450
+ let blastRaw = 0;
451
+ if (typeof i.blast_radius === 'number' && Number.isFinite(i.blast_radius)) blastRaw = i.blast_radius;
452
+ else if (typeof i.blast_radius === 'string' && i.blast_radius.trim() !== '' && Number.isFinite(Number(i.blast_radius))) blastRaw = Number(i.blast_radius);
453
+ const blast = Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, blastRaw));
454
+ return {
455
+ cisa_kev: i.cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0,
456
+ poc_available: i.poc_available ? RWEP_WEIGHTS.poc_available : 0,
457
+ ai_factor: (i.ai_assisted_weapon || i.ai_assisted_weaponization || i.ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0,
458
+ active_exploitation: RWEP_WEIGHTS.active_exploitation * aeMultiplier,
459
+ blast_radius: blast,
460
+ patch_available: i.patch_available ? RWEP_WEIGHTS.patch_available : 0,
461
+ live_patch_available: i.live_patch_available ? RWEP_WEIGHTS.live_patch_available : 0,
462
+ reboot_required: reboot ? RWEP_WEIGHTS.reboot_required : 0,
463
+ };
464
+ }
465
+
443
466
  function timeline(rwepScore) {
444
467
  if (rwepScore >= 90) return { hours: 4, label: 'Immediate — live patch or isolate within 4 hours' };
445
468
  if (rwepScore >= 75) return { hours: 24, label: 'Urgent — patch or compensating controls within 24 hours' };
@@ -804,6 +827,7 @@ function packageConfidence(inputs) {
804
827
  module.exports = {
805
828
  score,
806
829
  scoreCustom,
830
+ postWeightFactors,
807
831
  timeline,
808
832
  compare,
809
833
  packageConfidence,