@blamejs/exceptd-skills 0.18.20 → 0.18.22
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/AGENTS.md +3 -3
- package/ARCHITECTURE.md +4 -4
- package/CHANGELOG.md +12 -0
- package/CONTEXT.md +2 -2
- package/README.md +5 -5
- package/agents/threat-researcher.md +2 -2
- package/bin/exceptd.js +4 -1
- package/data/_indexes/_meta.json +43 -43
- package/data/_indexes/activity-feed.json +26 -26
- package/data/_indexes/catalog-summaries.json +12 -12
- package/data/_indexes/chains.json +67238 -589
- package/data/_indexes/frequency.json +10 -0
- package/data/_indexes/recipes.json +1 -1
- package/data/_indexes/section-offsets.json +82 -82
- package/data/_indexes/summary-cards.json +1 -1
- package/data/_indexes/token-budget.json +19 -19
- package/data/atlas-ttps.json +7 -7
- package/data/attack-techniques.json +295 -8
- package/data/cve-catalog.json +11443 -2
- package/data/cwe-catalog.json +366 -11
- package/data/framework-control-gaps.json +622 -15
- package/data/zeroday-lessons.json +5017 -2
- package/lib/auto-discovery.js +54 -67
- package/lib/cve-batch.js +216 -0
- package/lib/cve-curation.js +4 -0
- package/lib/cve-enrich.js +172 -0
- package/lib/prefetch.js +189 -110
- package/lib/schemas/skill-frontmatter.schema.json +1 -1
- package/lib/scoring.js +24 -0
- package/lib/version-pins.js +1 -1
- package/manifest-snapshot.json +2 -2
- package/manifest-snapshot.sha256 +1 -1
- package/manifest.json +88 -88
- package/package.json +2 -2
- package/sbom.cdx.json +149 -119
- package/scripts/builders/catalog-summaries.js +1 -1
- package/scripts/builders/recipes.js +1 -1
- package/skills/age-gates-child-safety/skill.md +2 -2
- package/skills/ai-attack-surface/skill.md +3 -3
- package/skills/ai-c2-detection/skill.md +4 -4
- package/skills/api-security/skill.md +1 -1
- package/skills/attack-surface-pentest/skill.md +3 -3
- package/skills/cloud-security/skill.md +2 -2
- package/skills/compliance-theater/skill.md +2 -2
- package/skills/container-runtime-security/skill.md +2 -2
- package/skills/coordinated-vuln-disclosure/skill.md +1 -1
- package/skills/dlp-gap-analysis/skill.md +4 -4
- package/skills/exploit-scoring/skill.md +1 -1
- package/skills/framework-gap-analysis/skill.md +3 -3
- package/skills/fuzz-testing-strategy/skill.md +1 -1
- package/skills/incident-response-playbook/skill.md +1 -1
- package/skills/mcp-agent-trust/skill.md +1 -1
- package/skills/mlops-security/skill.md +2 -2
- package/skills/ot-ics-security/skill.md +2 -2
- package/skills/policy-exception-gen/skill.md +2 -2
- package/skills/rag-pipeline-security/skill.md +3 -3
- package/skills/ransomware-response/skill.md +1 -1
- package/skills/sector-energy/skill.md +1 -1
- package/skills/sector-federal-government/skill.md +1 -1
- package/skills/sector-financial/skill.md +3 -3
- package/skills/sector-healthcare/skill.md +2 -2
- package/skills/security-maturity-tiers/skill.md +1 -1
- package/skills/skill-update-loop/skill.md +4 -4
- package/skills/supply-chain-integrity/skill.md +1 -1
- package/skills/threat-model-currency/skill.md +7 -7
- package/skills/threat-modeling-methodology/skill.md +1 -1
- package/skills/webapp-security/skill.md +1 -1
- package/skills/zeroday-gap-learn/skill.md +2 -2
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
.
|
|
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
|
|
689
|
-
log(`\nprefetch — ${opts.noNetwork ? "DRY-RUN" : "fetching"} ${
|
|
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
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"type": "string",
|
|
53
53
|
"pattern": "^AML\\.T[0-9]{4}(\\.[0-9]{3})?$"
|
|
54
54
|
},
|
|
55
|
-
"description": "MITRE ATLAS TTP IDs at the pinned version (currently v2026.
|
|
55
|
+
"description": "MITRE ATLAS TTP IDs at the pinned version (currently v2026.06)."
|
|
56
56
|
},
|
|
57
57
|
"attack_refs": {
|
|
58
58
|
"type": "array",
|
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,
|
package/lib/version-pins.js
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* operator must read.
|
|
31
31
|
*
|
|
32
32
|
* API:
|
|
33
|
-
* getAtlasVersion() → "2026.
|
|
33
|
+
* getAtlasVersion() → "2026.06"
|
|
34
34
|
* getAttackVersion() → "19.1"
|
|
35
35
|
* getAtlasReleaseDate() → "2026-05-27"
|
|
36
36
|
* getAllPins() → { atlas_version, atlas_release_date, attack_version, ... }
|
package/manifest-snapshot.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Auto-generated by scripts/refresh-manifest-snapshot.js — do not hand-edit. Public skill surface used by check-manifest-snapshot.js to detect breaking removals.",
|
|
3
|
-
"_generated_at": "2026-
|
|
4
|
-
"atlas_version": "2026.
|
|
3
|
+
"_generated_at": "2026-07-03T23:19:50.905Z",
|
|
4
|
+
"atlas_version": "2026.06",
|
|
5
5
|
"skill_count": 51,
|
|
6
6
|
"skills": [
|
|
7
7
|
{
|
package/manifest-snapshot.sha256
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
40e7936868a38317067bbb0fef5ec1d7b3b09b7bbbdf0af236fffb95bcf1d3fd manifest-snapshot.json
|