@blamejs/exceptd-skills 0.9.1

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.
Files changed (136) hide show
  1. package/AGENTS.md +232 -0
  2. package/ARCHITECTURE.md +267 -0
  3. package/CHANGELOG.md +616 -0
  4. package/CONTEXT.md +203 -0
  5. package/LICENSE +200 -0
  6. package/NOTICE +82 -0
  7. package/README.md +307 -0
  8. package/SECURITY.md +73 -0
  9. package/agents/README.md +81 -0
  10. package/agents/report-generator.md +156 -0
  11. package/agents/skill-updater.md +102 -0
  12. package/agents/source-validator.md +119 -0
  13. package/agents/threat-researcher.md +149 -0
  14. package/bin/exceptd.js +183 -0
  15. package/data/_indexes/_meta.json +88 -0
  16. package/data/_indexes/activity-feed.json +362 -0
  17. package/data/_indexes/catalog-summaries.json +229 -0
  18. package/data/_indexes/chains.json +7135 -0
  19. package/data/_indexes/currency.json +359 -0
  20. package/data/_indexes/did-ladders.json +451 -0
  21. package/data/_indexes/frequency.json +2072 -0
  22. package/data/_indexes/handoff-dag.json +476 -0
  23. package/data/_indexes/jurisdiction-clocks.json +967 -0
  24. package/data/_indexes/jurisdiction-map.json +536 -0
  25. package/data/_indexes/recipes.json +319 -0
  26. package/data/_indexes/section-offsets.json +3656 -0
  27. package/data/_indexes/stale-content.json +14 -0
  28. package/data/_indexes/summary-cards.json +1736 -0
  29. package/data/_indexes/theater-fingerprints.json +381 -0
  30. package/data/_indexes/token-budget.json +2137 -0
  31. package/data/_indexes/trigger-table.json +1374 -0
  32. package/data/_indexes/xref.json +818 -0
  33. package/data/atlas-ttps.json +282 -0
  34. package/data/cve-catalog.json +496 -0
  35. package/data/cwe-catalog.json +1017 -0
  36. package/data/d3fend-catalog.json +738 -0
  37. package/data/dlp-controls.json +1039 -0
  38. package/data/exploit-availability.json +67 -0
  39. package/data/framework-control-gaps.json +1255 -0
  40. package/data/global-frameworks.json +2913 -0
  41. package/data/rfc-references.json +324 -0
  42. package/data/zeroday-lessons.json +377 -0
  43. package/keys/public.pem +3 -0
  44. package/lib/framework-gap.js +328 -0
  45. package/lib/job-queue.js +195 -0
  46. package/lib/lint-skills.js +536 -0
  47. package/lib/prefetch.js +372 -0
  48. package/lib/refresh-external.js +713 -0
  49. package/lib/schemas/cve-catalog.schema.json +151 -0
  50. package/lib/schemas/manifest.schema.json +106 -0
  51. package/lib/schemas/skill-frontmatter.schema.json +113 -0
  52. package/lib/scoring.js +149 -0
  53. package/lib/sign.js +197 -0
  54. package/lib/ttp-mapper.js +80 -0
  55. package/lib/validate-catalog-meta.js +198 -0
  56. package/lib/validate-cve-catalog.js +213 -0
  57. package/lib/validate-indexes.js +83 -0
  58. package/lib/validate-package.js +162 -0
  59. package/lib/validate-vendor.js +85 -0
  60. package/lib/verify.js +216 -0
  61. package/lib/worker-pool.js +84 -0
  62. package/manifest-snapshot.json +1833 -0
  63. package/manifest.json +2108 -0
  64. package/orchestrator/README.md +124 -0
  65. package/orchestrator/dispatcher.js +140 -0
  66. package/orchestrator/event-bus.js +146 -0
  67. package/orchestrator/index.js +874 -0
  68. package/orchestrator/pipeline.js +201 -0
  69. package/orchestrator/scanner.js +327 -0
  70. package/orchestrator/scheduler.js +137 -0
  71. package/package.json +113 -0
  72. package/sbom.cdx.json +158 -0
  73. package/scripts/audit-cross-skill.js +261 -0
  74. package/scripts/audit-perf.js +160 -0
  75. package/scripts/bootstrap.js +205 -0
  76. package/scripts/build-indexes.js +721 -0
  77. package/scripts/builders/activity-feed.js +79 -0
  78. package/scripts/builders/catalog-summaries.js +67 -0
  79. package/scripts/builders/currency.js +109 -0
  80. package/scripts/builders/cwe-chains.js +105 -0
  81. package/scripts/builders/did-ladders.js +149 -0
  82. package/scripts/builders/frequency.js +89 -0
  83. package/scripts/builders/jurisdiction-clocks.js +126 -0
  84. package/scripts/builders/recipes.js +159 -0
  85. package/scripts/builders/section-offsets.js +162 -0
  86. package/scripts/builders/stale-content.js +171 -0
  87. package/scripts/builders/summary-cards.js +166 -0
  88. package/scripts/builders/theater-fingerprints.js +198 -0
  89. package/scripts/builders/token-budget.js +96 -0
  90. package/scripts/check-manifest-snapshot.js +217 -0
  91. package/scripts/predeploy.js +267 -0
  92. package/scripts/refresh-manifest-snapshot.js +57 -0
  93. package/scripts/refresh-sbom.js +222 -0
  94. package/skills/age-gates-child-safety/skill.md +456 -0
  95. package/skills/ai-attack-surface/skill.md +282 -0
  96. package/skills/ai-c2-detection/skill.md +440 -0
  97. package/skills/ai-risk-management/skill.md +311 -0
  98. package/skills/api-security/skill.md +287 -0
  99. package/skills/attack-surface-pentest/skill.md +381 -0
  100. package/skills/cloud-security/skill.md +384 -0
  101. package/skills/compliance-theater/skill.md +365 -0
  102. package/skills/container-runtime-security/skill.md +379 -0
  103. package/skills/coordinated-vuln-disclosure/skill.md +473 -0
  104. package/skills/defensive-countermeasure-mapping/skill.md +300 -0
  105. package/skills/dlp-gap-analysis/skill.md +337 -0
  106. package/skills/email-security-anti-phishing/skill.md +206 -0
  107. package/skills/exploit-scoring/skill.md +331 -0
  108. package/skills/framework-gap-analysis/skill.md +374 -0
  109. package/skills/fuzz-testing-strategy/skill.md +313 -0
  110. package/skills/global-grc/skill.md +564 -0
  111. package/skills/identity-assurance/skill.md +272 -0
  112. package/skills/incident-response-playbook/skill.md +546 -0
  113. package/skills/kernel-lpe-triage/skill.md +303 -0
  114. package/skills/mcp-agent-trust/skill.md +326 -0
  115. package/skills/mlops-security/skill.md +325 -0
  116. package/skills/ot-ics-security/skill.md +340 -0
  117. package/skills/policy-exception-gen/skill.md +437 -0
  118. package/skills/pqc-first/skill.md +546 -0
  119. package/skills/rag-pipeline-security/skill.md +294 -0
  120. package/skills/researcher/skill.md +310 -0
  121. package/skills/sector-energy/skill.md +409 -0
  122. package/skills/sector-federal-government/skill.md +302 -0
  123. package/skills/sector-financial/skill.md +398 -0
  124. package/skills/sector-healthcare/skill.md +373 -0
  125. package/skills/security-maturity-tiers/skill.md +464 -0
  126. package/skills/skill-update-loop/skill.md +463 -0
  127. package/skills/supply-chain-integrity/skill.md +318 -0
  128. package/skills/threat-model-currency/skill.md +404 -0
  129. package/skills/threat-modeling-methodology/skill.md +312 -0
  130. package/skills/webapp-security/skill.md +281 -0
  131. package/skills/zeroday-gap-learn/skill.md +350 -0
  132. package/vendor/blamejs/LICENSE +201 -0
  133. package/vendor/blamejs/README.md +54 -0
  134. package/vendor/blamejs/_PROVENANCE.json +54 -0
  135. package/vendor/blamejs/retry.js +335 -0
  136. package/vendor/blamejs/worker-pool.js +418 -0
@@ -0,0 +1,713 @@
1
+ "use strict";
2
+ /**
3
+ * lib/refresh-external.js
4
+ *
5
+ * External-data refresh orchestrator. Pulls the latest from the canonical
6
+ * upstream sources and either reports drift (dry-run, default) or applies
7
+ * the drift as an upsert into the local catalog (--apply).
8
+ *
9
+ * Sources (each is independently pluggable):
10
+ *
11
+ * KEV — CISA Known Exploited Vulnerabilities (per-CVE upsert of
12
+ * cisa_kev / cisa_kev_date)
13
+ * EPSS — FIRST EPSS (per-CVE upsert of epss_score / epss_percentile /
14
+ * epss_date)
15
+ * NVD — NIST NVD 2.0 (per-CVE upsert of cvss_score / cvss_vector)
16
+ * RFC — IETF Datatracker (per-RFC upsert of status)
17
+ * PINS — MITRE ATLAS / ATT&CK / D3FEND / CWE upstream releases
18
+ * (REPORT-ONLY — version bumps need audit per AGENTS.md
19
+ * Hard Rule #12, so they surface as findings, not auto-applied)
20
+ *
21
+ * Usage:
22
+ * node lib/refresh-external.js # dry-run all sources
23
+ * node lib/refresh-external.js --apply # apply all sources
24
+ * node lib/refresh-external.js --source kev # one source, dry-run
25
+ * node lib/refresh-external.js --apply --source kev,epss
26
+ * node lib/refresh-external.js --from-fixture <path> # use frozen fixture
27
+ * payloads (offline)
28
+ *
29
+ * Exit codes:
30
+ * 0 — dry-run completed (regardless of whether drift was found)
31
+ * 1 — apply mode AND a downstream gate (validate-indexes, lint) failed
32
+ * 2 — unrecoverable runner error
33
+ *
34
+ * The refresh-report.json artifact lives at the repo root and is
35
+ * gitignored. CI uploads it as a workflow artifact.
36
+ */
37
+
38
+ const fs = require("fs");
39
+ const path = require("path");
40
+ const { execFileSync } = require("child_process");
41
+
42
+ const ROOT = path.join(__dirname, "..");
43
+ const ABS = (p) => path.join(ROOT, p);
44
+ const TODAY = new Date().toISOString().slice(0, 10);
45
+
46
+ function parseArgs(argv) {
47
+ const out = {
48
+ apply: false,
49
+ source: null, // comma-separated list or null = all
50
+ fromFixture: null, // path to fixture dir
51
+ fromCache: null, // path to .cache/upstream dir (or default if --from-cache passed bare)
52
+ swarm: false, // fan-out sources across worker threads
53
+ help: false,
54
+ quiet: false,
55
+ };
56
+ for (let i = 2; i < argv.length; i++) {
57
+ const a = argv[i];
58
+ if (a === "--apply") out.apply = true;
59
+ else if (a === "--quiet") out.quiet = true;
60
+ else if (a === "--swarm") out.swarm = true;
61
+ else if (a === "--help" || a === "-h") out.help = true;
62
+ else if (a === "--from-cache") {
63
+ // accept either --from-cache <path> or --from-cache (default path)
64
+ const next = argv[i + 1];
65
+ if (next && !next.startsWith("--")) { out.fromCache = next; i++; }
66
+ else out.fromCache = ".cache/upstream";
67
+ }
68
+ else if (a.startsWith("--from-cache=")) out.fromCache = a.slice("--from-cache=".length);
69
+ else if (a === "--source") out.source = argv[++i];
70
+ else if (a.startsWith("--source=")) out.source = a.slice("--source=".length);
71
+ else if (a === "--from-fixture") out.fromFixture = argv[++i];
72
+ else if (a.startsWith("--from-fixture=")) out.fromFixture = a.slice("--from-fixture=".length);
73
+ else if (a === "--report-out") out.reportOut = argv[++i];
74
+ else if (a.startsWith("--report-out=")) out.reportOut = a.slice("--report-out=".length);
75
+ }
76
+ return out;
77
+ }
78
+
79
+ function printHelp() {
80
+ console.log(`refresh-external — pull latest upstream data, optionally upsert into local catalogs.
81
+
82
+ Modes:
83
+ (default) dry-run all sources, write refresh-report.json
84
+ --apply apply diffs and rebuild indexes
85
+ --source kev,epss scope to a comma-separated list (kev|epss|nvd|rfc|pins)
86
+ --from-fixture <p> use frozen fixture payloads (tests use this path)
87
+ --from-cache [<p>] read from prefetch cache (default .cache/upstream).
88
+ Combine with --apply to upsert against cached data
89
+ entirely offline.
90
+ --swarm fan out sources across worker threads. Source fetches
91
+ run in parallel rather than sequentially. Best when
92
+ paired with --from-cache (no rate-limit contention).
93
+
94
+ Outputs:
95
+ refresh-report.json (gitignored) — summary of every diff + per-source status.
96
+
97
+ This module never auto-applies version-pin bumps — those require audit per
98
+ AGENTS.md Hard Rule #12 and are surfaced as report-only findings.
99
+ `);
100
+ }
101
+
102
+ // --- Source modules ----------------------------------------------------
103
+
104
+ /**
105
+ * Each source module exposes:
106
+ * name: string
107
+ * fetchDiff(ctx, opts) -> Promise<{ status, diffs, errors, summary }>
108
+ * status: "ok" | "unreachable" | "partial"
109
+ * diffs: array of { id, field, before, after, severity? }
110
+ * summary: brief string
111
+ * applyDiff(ctx, diffs) -> Promise<{ updated, errors }>
112
+ * mutates local catalog and writes it
113
+ */
114
+
115
+ const KEV_SOURCE = {
116
+ name: "kev",
117
+ description: "CISA Known Exploited Vulnerabilities",
118
+ applies_to: "data/cve-catalog.json",
119
+ async fetchDiff(ctx) {
120
+ if (ctx.fixtures?.kev) return synthesizeFromFixture(ctx, "kev");
121
+ if (ctx.cacheDir) return kevDiffFromCache(ctx);
122
+ const { validateAllCves } = require("../sources/validators");
123
+ const report = await validateAllCves(ctx.cveCatalog, { concurrency: 4 });
124
+ const diffs = [];
125
+ let errors = 0;
126
+ for (const r of report.results) {
127
+ if (r.status === "unreachable") errors++;
128
+ for (const d of r.discrepancies || []) {
129
+ if (d.field === "cisa_kev" || d.field === "cisa_kev_date") {
130
+ diffs.push({ id: r.cve_id, field: d.field, before: d.local, after: d.fetched, severity: d.severity });
131
+ }
132
+ }
133
+ }
134
+ return {
135
+ status: errors === 0 ? "ok" : errors === report.results.length ? "unreachable" : "partial",
136
+ diffs,
137
+ errors,
138
+ summary: `${diffs.length} KEV diffs; ${errors} unreachable / ${report.total} total`,
139
+ };
140
+ },
141
+ async applyDiff(ctx, diffs) {
142
+ let updated = 0;
143
+ const errors = [];
144
+ for (const d of diffs) {
145
+ if (!ctx.cveCatalog[d.id]) {
146
+ errors.push(`KEV: no local entry for ${d.id}`);
147
+ continue;
148
+ }
149
+ ctx.cveCatalog[d.id][d.field] = d.after;
150
+ ctx.cveCatalog[d.id].last_verified = TODAY;
151
+ updated++;
152
+ }
153
+ ctx.cveCatalog._meta = ctx.cveCatalog._meta || {};
154
+ ctx.cveCatalog._meta.last_updated = TODAY;
155
+ writeJson(ABS("data/cve-catalog.json"), ctx.cveCatalog);
156
+ return { updated, errors };
157
+ },
158
+ };
159
+
160
+ const EPSS_SOURCE = {
161
+ name: "epss",
162
+ description: "FIRST.org EPSS scores",
163
+ applies_to: "data/cve-catalog.json",
164
+ async fetchDiff(ctx) {
165
+ if (ctx.fixtures?.epss) return synthesizeFromFixture(ctx, "epss");
166
+ if (ctx.cacheDir) return epssDiffFromCache(ctx);
167
+ const { validateAllCves } = require("../sources/validators");
168
+ const report = await validateAllCves(ctx.cveCatalog, { concurrency: 4 });
169
+ const diffs = [];
170
+ let errors = 0;
171
+ for (const r of report.results) {
172
+ if (r.status === "unreachable") errors++;
173
+ for (const d of r.discrepancies || []) {
174
+ if (d.field === "epss_score" || d.field === "epss_percentile") {
175
+ diffs.push({ id: r.cve_id, field: d.field, before: d.local, after: d.fetched, severity: d.severity });
176
+ }
177
+ }
178
+ // epss_date refreshes when score does.
179
+ if (r.fetched?.epss?.date && r.local) {
180
+ diffs.push({ id: r.cve_id, field: "epss_date", before: r.local.epss_date, after: r.fetched.epss.date, severity: "low" });
181
+ }
182
+ }
183
+ // Collapse duplicates: epss_date should appear once per CVE only when
184
+ // an epss field actually moved.
185
+ const epssCves = new Set(diffs.filter((d) => d.field === "epss_score" || d.field === "epss_percentile").map((d) => d.id));
186
+ const filtered = diffs.filter((d) => d.field !== "epss_date" || epssCves.has(d.id));
187
+ return {
188
+ status: errors === 0 ? "ok" : errors === report.results.length ? "unreachable" : "partial",
189
+ diffs: filtered,
190
+ errors,
191
+ summary: `${filtered.length} EPSS diffs; ${errors} unreachable / ${report.total} total`,
192
+ };
193
+ },
194
+ async applyDiff(ctx, diffs) {
195
+ let updated = 0;
196
+ const errors = [];
197
+ for (const d of diffs) {
198
+ if (!ctx.cveCatalog[d.id]) {
199
+ errors.push(`EPSS: no local entry for ${d.id}`);
200
+ continue;
201
+ }
202
+ ctx.cveCatalog[d.id][d.field] = d.after;
203
+ ctx.cveCatalog[d.id].last_verified = TODAY;
204
+ updated++;
205
+ }
206
+ ctx.cveCatalog._meta = ctx.cveCatalog._meta || {};
207
+ ctx.cveCatalog._meta.last_updated = TODAY;
208
+ writeJson(ABS("data/cve-catalog.json"), ctx.cveCatalog);
209
+ return { updated, errors };
210
+ },
211
+ };
212
+
213
+ const NVD_SOURCE = {
214
+ name: "nvd",
215
+ description: "NIST NVD 2.0 CVSS metrics",
216
+ applies_to: "data/cve-catalog.json",
217
+ async fetchDiff(ctx) {
218
+ if (ctx.fixtures?.nvd) return synthesizeFromFixture(ctx, "nvd");
219
+ if (ctx.cacheDir) return nvdDiffFromCache(ctx);
220
+ const { validateAllCves } = require("../sources/validators");
221
+ const report = await validateAllCves(ctx.cveCatalog, { concurrency: 4 });
222
+ const diffs = [];
223
+ let errors = 0;
224
+ for (const r of report.results) {
225
+ if (r.status === "unreachable") errors++;
226
+ for (const d of r.discrepancies || []) {
227
+ if (d.field === "cvss_score" || d.field === "cvss_vector") {
228
+ diffs.push({ id: r.cve_id, field: d.field, before: d.local, after: d.fetched, severity: d.severity });
229
+ }
230
+ }
231
+ }
232
+ return {
233
+ status: errors === 0 ? "ok" : errors === report.results.length ? "unreachable" : "partial",
234
+ diffs,
235
+ errors,
236
+ summary: `${diffs.length} NVD CVSS diffs; ${errors} unreachable / ${report.total} total`,
237
+ };
238
+ },
239
+ async applyDiff(ctx, diffs) {
240
+ let updated = 0;
241
+ const errors = [];
242
+ for (const d of diffs) {
243
+ if (!ctx.cveCatalog[d.id]) {
244
+ errors.push(`NVD: no local entry for ${d.id}`);
245
+ continue;
246
+ }
247
+ ctx.cveCatalog[d.id][d.field] = d.after;
248
+ ctx.cveCatalog[d.id].last_verified = TODAY;
249
+ updated++;
250
+ }
251
+ ctx.cveCatalog._meta = ctx.cveCatalog._meta || {};
252
+ ctx.cveCatalog._meta.last_updated = TODAY;
253
+ writeJson(ABS("data/cve-catalog.json"), ctx.cveCatalog);
254
+ return { updated, errors };
255
+ },
256
+ };
257
+
258
+ const RFC_SOURCE = {
259
+ name: "rfc",
260
+ description: "IETF Datatracker RFC status",
261
+ applies_to: "data/rfc-references.json",
262
+ async fetchDiff(ctx) {
263
+ if (ctx.fixtures?.rfc) return synthesizeFromFixture(ctx, "rfc");
264
+ if (ctx.cacheDir) return rfcDiffFromCache(ctx);
265
+ const { validateAllRfcs } = require("../sources/validators");
266
+ const results = await validateAllRfcs(ctx.rfcCatalog, { concurrency: 4 });
267
+ const diffs = [];
268
+ let errors = 0;
269
+ for (const r of results) {
270
+ if (r.status === "unreachable") {
271
+ errors++;
272
+ continue;
273
+ }
274
+ if (r.status === "drift" && r.discrepancies) {
275
+ for (const msg of r.discrepancies) {
276
+ // The current rfc-validator returns discrepancies as strings. We
277
+ // attempt to parse a status drift; fall back to the raw message.
278
+ const m = msg.match(/local "([^"]+)" vs Datatracker "([^"]+)"/);
279
+ if (m) {
280
+ diffs.push({ id: r.id, field: "status", before: m[1], after: m[2], severity: "medium" });
281
+ } else {
282
+ diffs.push({ id: r.id, field: "note", before: null, after: msg, severity: "low" });
283
+ }
284
+ }
285
+ }
286
+ }
287
+ return {
288
+ status: errors === 0 ? "ok" : errors === results.length ? "unreachable" : "partial",
289
+ diffs,
290
+ errors,
291
+ summary: `${diffs.length} RFC drifts; ${errors} unreachable / ${results.length} total`,
292
+ };
293
+ },
294
+ async applyDiff(ctx, diffs) {
295
+ let updated = 0;
296
+ const errors = [];
297
+ for (const d of diffs) {
298
+ if (d.field !== "status") continue; // notes are informational
299
+ const entry = ctx.rfcCatalog[d.id];
300
+ if (!entry) {
301
+ errors.push(`RFC: no local entry for ${d.id}`);
302
+ continue;
303
+ }
304
+ entry.status = d.after;
305
+ entry.last_verified = TODAY;
306
+ updated++;
307
+ }
308
+ ctx.rfcCatalog._meta = ctx.rfcCatalog._meta || {};
309
+ ctx.rfcCatalog._meta.last_updated = TODAY;
310
+ writeJson(ABS("data/rfc-references.json"), ctx.rfcCatalog);
311
+ return { updated, errors };
312
+ },
313
+ };
314
+
315
+ const PINS_SOURCE = {
316
+ name: "pins",
317
+ description: "MITRE ATLAS / ATT&CK / D3FEND / CWE upstream release pins",
318
+ applies_to: "manifest.json + data/cwe-catalog.json + data/d3fend-catalog.json",
319
+ report_only: true,
320
+ async fetchDiff(ctx) {
321
+ if (ctx.fixtures?.pins) return synthesizeFromFixture(ctx, "pins");
322
+ if (ctx.cacheDir) return pinsDiffFromCache(ctx);
323
+ const { checkAllPins } = require("../sources/validators/version-pin-validator");
324
+ const results = await checkAllPins({
325
+ manifest: ctx.manifest,
326
+ cweCatalog: ctx.cweCatalog,
327
+ d3fendCatalog: ctx.d3fendCatalog,
328
+ });
329
+ const diffs = [];
330
+ let errors = 0;
331
+ for (const r of results) {
332
+ if (r.unreachable) {
333
+ errors++;
334
+ continue;
335
+ }
336
+ if (r.drift) {
337
+ diffs.push({
338
+ id: r.pin_name,
339
+ field: "version",
340
+ before: r.local_version,
341
+ after: r.latest_version,
342
+ severity: "medium",
343
+ source_url: r.source_url,
344
+ local_path_hint: r.local_path_hint,
345
+ note: "Version-pin bump requires audit per AGENTS.md Hard Rule #12. Surface as GitHub issue, do not auto-apply.",
346
+ });
347
+ }
348
+ }
349
+ return {
350
+ status: errors === 0 ? "ok" : errors === results.length ? "unreachable" : "partial",
351
+ diffs,
352
+ errors,
353
+ summary: `${diffs.length} pin drifts; ${errors} unreachable / ${results.length} total`,
354
+ };
355
+ },
356
+ async applyDiff() {
357
+ // Version pins are intentionally not auto-applied.
358
+ return { updated: 0, errors: ["pin bumps are report-only — see Hard Rule #12"] };
359
+ },
360
+ };
361
+
362
+ const ALL_SOURCES = {
363
+ kev: KEV_SOURCE,
364
+ epss: EPSS_SOURCE,
365
+ nvd: NVD_SOURCE,
366
+ rfc: RFC_SOURCE,
367
+ pins: PINS_SOURCE,
368
+ };
369
+
370
+ // --- Cache-mode helpers ------------------------------------------------
371
+ // When `--from-cache <dir>` is set, the source modules read their inputs
372
+ // from the prefetch cache instead of hitting upstream. The cache layout
373
+ // is fixed by lib/prefetch.js:
374
+ // <cacheDir>/kev/known_exploited_vulnerabilities.json
375
+ // <cacheDir>/nvd/<cve>.json
376
+ // <cacheDir>/epss/<cve>.json
377
+ // <cacheDir>/rfc/<doc-name>.json
378
+ // <cacheDir>/pins/<owner__repo__releases>.json
379
+ //
380
+ // readCachedJson returns null on miss; callers report it as "unreachable"
381
+ // for that entry rather than failing the whole source.
382
+
383
+ function readCachedJson(cacheDir, source, id) {
384
+ const safe = id.replace(/[^A-Za-z0-9._-]/g, "_");
385
+ const p = path.join(cacheDir, source, `${safe}.json`);
386
+ if (!fs.existsSync(p)) return null;
387
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); }
388
+ catch { return null; }
389
+ }
390
+
391
+ function kevDiffFromCache(ctx) {
392
+ const feed = readCachedJson(ctx.cacheDir, "kev", "known_exploited_vulnerabilities");
393
+ if (!feed) {
394
+ return { status: "unreachable", diffs: [], errors: 1, summary: "KEV: no cached feed" };
395
+ }
396
+ const kevSet = new Set();
397
+ const kevDates = new Map();
398
+ for (const v of feed.vulnerabilities || []) {
399
+ if (v && v.cveID) {
400
+ kevSet.add(v.cveID);
401
+ if (v.dateAdded) kevDates.set(v.cveID, v.dateAdded);
402
+ }
403
+ }
404
+ const diffs = [];
405
+ for (const [id, entry] of Object.entries(ctx.cveCatalog)) {
406
+ if (!/^CVE-\d{4}-\d{4,7}$/.test(id)) continue;
407
+ const upstream = kevSet.has(id);
408
+ if (typeof entry.cisa_kev === "boolean" && entry.cisa_kev !== upstream) {
409
+ diffs.push({ id, field: "cisa_kev", before: entry.cisa_kev, after: upstream, severity: "high" });
410
+ }
411
+ const upDate = kevDates.get(id) || null;
412
+ if (upDate && entry.cisa_kev_date && entry.cisa_kev_date !== upDate) {
413
+ diffs.push({ id, field: "cisa_kev_date", before: entry.cisa_kev_date, after: upDate, severity: "low" });
414
+ }
415
+ }
416
+ return { status: "ok", diffs, errors: 0, summary: `${diffs.length} KEV diffs (from cache)` };
417
+ }
418
+
419
+ function epssDiffFromCache(ctx) {
420
+ const cves = Object.keys(ctx.cveCatalog).filter((k) => /^CVE-\d{4}-\d{4,7}$/.test(k));
421
+ const diffs = [];
422
+ let errors = 0;
423
+ const drift = 0.05;
424
+ for (const id of cves) {
425
+ const payload = readCachedJson(ctx.cacheDir, "epss", id);
426
+ if (!payload) { errors++; continue; }
427
+ const row = (payload.data || []).find((r) => r?.cve === id) || (payload.data || [])[0];
428
+ if (!row) continue;
429
+ const score = row.epss != null ? Number(row.epss) : null;
430
+ const pct = row.percentile != null ? Number(row.percentile) : null;
431
+ const local = ctx.cveCatalog[id];
432
+ if (score != null && local.epss_score != null && Math.abs(score - local.epss_score) > drift) {
433
+ diffs.push({ id, field: "epss_score", before: local.epss_score, after: score, severity: "medium" });
434
+ }
435
+ if (pct != null && local.epss_percentile != null && Math.abs(pct - local.epss_percentile) > drift) {
436
+ diffs.push({ id, field: "epss_percentile", before: local.epss_percentile, after: pct, severity: "medium" });
437
+ }
438
+ if (row.date && local.epss_date && row.date !== local.epss_date) {
439
+ // Only emit a date diff when we also emitted a score/percentile diff for this CVE.
440
+ const moved = diffs.some((d) => d.id === id && (d.field === "epss_score" || d.field === "epss_percentile"));
441
+ if (moved) diffs.push({ id, field: "epss_date", before: local.epss_date, after: row.date, severity: "low" });
442
+ }
443
+ }
444
+ const status = errors === 0 ? "ok" : errors === cves.length ? "unreachable" : "partial";
445
+ return { status, diffs, errors, summary: `${diffs.length} EPSS diffs (from cache); ${errors} missing entries` };
446
+ }
447
+
448
+ function nvdDiffFromCache(ctx) {
449
+ const cves = Object.keys(ctx.cveCatalog).filter((k) => /^CVE-\d{4}-\d{4,7}$/.test(k));
450
+ const diffs = [];
451
+ let errors = 0;
452
+ for (const id of cves) {
453
+ const payload = readCachedJson(ctx.cacheDir, "nvd", id);
454
+ if (!payload) { errors++; continue; }
455
+ const vuln = payload.vulnerabilities?.[0]?.cve;
456
+ if (!vuln) continue;
457
+ const m = vuln.metrics || {};
458
+ const ordered = [...(m.cvssMetricV31 || []), ...(m.cvssMetricV30 || []), ...(m.cvssMetricV2 || [])];
459
+ const primary = ordered.find((x) => x.type === "Primary") || ordered[0];
460
+ const upScore = typeof primary?.cvssData?.baseScore === "number" ? primary.cvssData.baseScore : null;
461
+ const upVector = primary?.cvssData?.vectorString || null;
462
+ const local = ctx.cveCatalog[id];
463
+ if (upScore != null && local.cvss_score != null && Math.abs(upScore - local.cvss_score) > 0.05) {
464
+ diffs.push({ id, field: "cvss_score", before: local.cvss_score, after: upScore, severity: "high" });
465
+ }
466
+ if (upVector && local.cvss_vector && upVector !== local.cvss_vector) {
467
+ diffs.push({ id, field: "cvss_vector", before: local.cvss_vector, after: upVector, severity: "medium" });
468
+ }
469
+ }
470
+ const status = errors === 0 ? "ok" : errors === cves.length ? "unreachable" : "partial";
471
+ return { status, diffs, errors, summary: `${diffs.length} NVD CVSS diffs (from cache); ${errors} missing entries` };
472
+ }
473
+
474
+ function rfcDiffFromCache(ctx) {
475
+ const STATUS_MAP = {
476
+ std: "Internet Standard", ps: "Proposed Standard", ds: "Draft Standard",
477
+ bcp: "Best Current Practice", inf: "Informational", exp: "Experimental",
478
+ his: "Historic", unkn: "Unknown",
479
+ };
480
+ const ids = Object.keys(ctx.rfcCatalog).filter((k) => !k.startsWith("_"));
481
+ const diffs = [];
482
+ let errors = 0;
483
+ for (const id of ids) {
484
+ let docName;
485
+ if (id.startsWith("RFC-")) docName = `rfc${id.slice(4)}`;
486
+ else if (id.startsWith("DRAFT-")) docName = `draft-${id.slice(6).toLowerCase()}`;
487
+ if (!docName) continue;
488
+ const payload = readCachedJson(ctx.cacheDir, "rfc", docName);
489
+ if (!payload) { errors++; continue; }
490
+ const obj = payload.objects?.[0];
491
+ if (!obj) continue;
492
+ const upStatus = STATUS_MAP[obj.std_level] || null;
493
+ const local = ctx.rfcCatalog[id];
494
+ if (upStatus && local.status && upStatus !== local.status) {
495
+ diffs.push({ id, field: "status", before: local.status, after: upStatus, severity: "medium" });
496
+ }
497
+ }
498
+ const status = errors === 0 ? "ok" : errors === ids.length ? "unreachable" : "partial";
499
+ return { status, diffs, errors, summary: `${diffs.length} RFC drifts (from cache); ${errors} missing entries` };
500
+ }
501
+
502
+ function pinsDiffFromCache(ctx) {
503
+ // Cache layout under pins/: <owner>__<repo>__releases.json arrays.
504
+ const PIN_REPOS = {
505
+ atlas_version: "mitre-atlas__atlas-data__releases",
506
+ attack_version: "mitre-attack__attack-stix-data__releases",
507
+ d3fend_version: "d3fend__d3fend-data__releases",
508
+ cwe_version: "mitre__cwe__releases",
509
+ };
510
+ const localOf = {
511
+ atlas_version: ctx.manifest.atlas_version,
512
+ attack_version: ctx.manifest.attack_version,
513
+ d3fend_version: ctx.d3fendCatalog?._meta?.version || ctx.d3fendCatalog?._meta?.d3fend_version || null,
514
+ cwe_version: ctx.cweCatalog?._meta?.version || ctx.cweCatalog?._meta?.cwe_version || null,
515
+ };
516
+ const diffs = [];
517
+ let errors = 0;
518
+ for (const [pinName, file] of Object.entries(PIN_REPOS)) {
519
+ const payload = readCachedJson(ctx.cacheDir, "pins", file);
520
+ if (!payload || !Array.isArray(payload)) { errors++; continue; }
521
+ const stable = payload.find((r) => !r.draft && !r.prerelease);
522
+ if (!stable) { errors++; continue; }
523
+ const latest = String(stable.tag_name || "").replace(/^v/, "");
524
+ const local = localOf[pinName] != null ? String(localOf[pinName]).replace(/^v/, "") : null;
525
+ if (local && latest && local !== latest) {
526
+ diffs.push({
527
+ id: pinName,
528
+ field: "version",
529
+ before: local,
530
+ after: latest,
531
+ severity: "medium",
532
+ source_url: stable.html_url,
533
+ local_path_hint: pinName === "cwe_version" ? "data/cwe-catalog.json _meta.version"
534
+ : pinName === "d3fend_version" ? "data/d3fend-catalog.json _meta.version"
535
+ : `manifest.json — ${pinName}`,
536
+ note: "Version-pin bump requires audit per AGENTS.md Hard Rule #12. Surface as GitHub issue, do not auto-apply.",
537
+ });
538
+ }
539
+ }
540
+ const status = errors === 0 ? "ok" : errors === Object.keys(PIN_REPOS).length ? "unreachable" : "partial";
541
+ return { status, diffs, errors, summary: `${diffs.length} pin drifts (from cache); ${errors} missing entries` };
542
+ }
543
+
544
+ // --- Fixture-mode helper ----------------------------------------------
545
+
546
+ function synthesizeFromFixture(ctx, sourceName) {
547
+ // The frozen fixture payloads are JSON files that look like:
548
+ // { diffs: [...], errors: 0, summary: "..." }
549
+ // tests/fixtures/refresh/<sourceName>.json drives this path.
550
+ const fp = path.join(ctx.fixtures.dir, `${sourceName}.json`);
551
+ if (!fs.existsSync(fp)) {
552
+ return { status: "ok", diffs: [], errors: 0, summary: `${sourceName}: no fixture` };
553
+ }
554
+ const fx = JSON.parse(fs.readFileSync(fp, "utf8"));
555
+ return {
556
+ status: fx.status || "ok",
557
+ diffs: fx.diffs || [],
558
+ errors: fx.errors || 0,
559
+ summary: fx.summary || `${sourceName}: ${(fx.diffs || []).length} diffs (fixture)`,
560
+ };
561
+ }
562
+
563
+ // --- IO helpers --------------------------------------------------------
564
+
565
+ function loadCtx(opts) {
566
+ const ctx = {
567
+ manifest: JSON.parse(fs.readFileSync(ABS("manifest.json"), "utf8")),
568
+ cveCatalog: JSON.parse(fs.readFileSync(ABS("data/cve-catalog.json"), "utf8")),
569
+ rfcCatalog: JSON.parse(fs.readFileSync(ABS("data/rfc-references.json"), "utf8")),
570
+ cweCatalog: JSON.parse(fs.readFileSync(ABS("data/cwe-catalog.json"), "utf8")),
571
+ d3fendCatalog: JSON.parse(fs.readFileSync(ABS("data/d3fend-catalog.json"), "utf8")),
572
+ fixtures: null,
573
+ cacheDir: null,
574
+ };
575
+ if (opts.fromFixture) {
576
+ ctx.fixtures = { dir: path.resolve(opts.fromFixture), kev: true, epss: true, nvd: true, rfc: true, pins: true };
577
+ } else if (opts.fromCache) {
578
+ const abs = path.resolve(opts.fromCache);
579
+ ctx.cacheDir = abs;
580
+ if (!fs.existsSync(abs)) {
581
+ throw new Error(`refresh-external: --from-cache path does not exist: ${abs}`);
582
+ }
583
+ }
584
+ return ctx;
585
+ }
586
+
587
+ function writeJson(p, obj) {
588
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2) + "\n", "utf8");
589
+ }
590
+
591
+ function chosenSources(opts) {
592
+ if (!opts.source) return Object.values(ALL_SOURCES);
593
+ const names = opts.source.split(",").map((s) => s.trim()).filter(Boolean);
594
+ const out = [];
595
+ for (const n of names) {
596
+ if (!ALL_SOURCES[n]) {
597
+ console.error(`refresh-external: unknown source "${n}". Valid: ${Object.keys(ALL_SOURCES).join(", ")}`);
598
+ process.exit(2);
599
+ }
600
+ out.push(ALL_SOURCES[n]);
601
+ }
602
+ return out;
603
+ }
604
+
605
+ async function main() {
606
+ const opts = parseArgs(process.argv);
607
+ if (opts.help) {
608
+ printHelp();
609
+ process.exit(0);
610
+ }
611
+
612
+ const ctx = loadCtx(opts);
613
+ const sources = chosenSources(opts);
614
+ const log = (s) => opts.quiet || console.log(s);
615
+
616
+ log(`\nrefresh-external — ${opts.apply ? "APPLY" : "dry-run"} mode${opts.swarm ? " (swarm)" : ""}`);
617
+ log(`Sources: ${sources.map((s) => s.name).join(", ")}`);
618
+ if (opts.fromFixture) log(`Fixture mode: ${opts.fromFixture}`);
619
+ if (opts.fromCache) log(`Cache mode: ${opts.fromCache}`);
620
+
621
+ const report = {
622
+ generated_at: new Date().toISOString(),
623
+ mode: opts.apply ? "apply" : "dry-run",
624
+ fixture_mode: !!opts.fromFixture,
625
+ cache_mode: !!opts.fromCache,
626
+ swarm: !!opts.swarm,
627
+ sources: {},
628
+ };
629
+
630
+ let hadFailure = false;
631
+
632
+ // Source fetches are independently I/O-bound. In normal mode we run them
633
+ // sequentially so log output is interleaved cleanly. --swarm fans them
634
+ // out via Promise.all() — each source already has its own per-source
635
+ // queue with its own rate budget, so parallel sources don't compete
636
+ // against each other for tokens. The two modes produce the same report
637
+ // structure; only wall-clock differs.
638
+ const runOne = async (src) => {
639
+ let diff;
640
+ try {
641
+ diff = await src.fetchDiff(ctx);
642
+ } catch (err) {
643
+ return { src, error: err };
644
+ }
645
+ return { src, diff };
646
+ };
647
+
648
+ const outcomes = opts.swarm
649
+ ? await Promise.all(sources.map(runOne))
650
+ : await sequential(sources, runOne);
651
+
652
+ for (const { src, diff, error } of outcomes) {
653
+ if (error) {
654
+ log(`\n [${src.name}] ${src.description}`);
655
+ log(` error: ${error.message}`);
656
+ report.sources[src.name] = { status: "error", error: error.message };
657
+ hadFailure = true;
658
+ continue;
659
+ }
660
+ log(`\n [${src.name}] ${src.description}`);
661
+ log(` ${diff.summary}`);
662
+ report.sources[src.name] = {
663
+ status: diff.status,
664
+ summary: diff.summary,
665
+ diff_count: diff.diffs.length,
666
+ errors: diff.errors,
667
+ diffs: diff.diffs,
668
+ applies_to: src.applies_to,
669
+ report_only: !!src.report_only,
670
+ };
671
+ if (opts.apply && diff.diffs.length > 0 && !src.report_only) {
672
+ const r = await src.applyDiff(ctx, diff.diffs);
673
+ report.sources[src.name].applied = r.updated;
674
+ report.sources[src.name].apply_errors = r.errors;
675
+ log(` applied: ${r.updated} update(s)`);
676
+ if (r.errors.length) log(` apply errors: ${r.errors.join("; ")}`);
677
+ }
678
+ }
679
+
680
+ // Persist report — tests can redirect via --report-out so concurrent
681
+ // suites don't race on a shared refresh-report.json at the repo root.
682
+ const reportPath = opts.reportOut ? path.resolve(opts.reportOut) : ABS("refresh-report.json");
683
+ writeJson(reportPath, report);
684
+ log(`\nWrote ${path.relative(ROOT, reportPath)}`);
685
+
686
+ if (opts.apply) {
687
+ // Always regenerate indexes after an apply so validate-indexes passes.
688
+ log(`\nRebuilding indexes (npm run build-indexes)`);
689
+ try {
690
+ execFileSync(process.execPath, [ABS("scripts/build-indexes.js")], { stdio: "inherit", cwd: ROOT });
691
+ } catch (err) {
692
+ console.error(`refresh-external: build-indexes failed: ${err.message}`);
693
+ hadFailure = true;
694
+ }
695
+ }
696
+
697
+ process.exit(hadFailure ? 1 : 0);
698
+ }
699
+
700
+ async function sequential(items, fn) {
701
+ const out = [];
702
+ for (const it of items) out.push(await fn(it));
703
+ return out;
704
+ }
705
+
706
+ if (require.main === module) {
707
+ main().catch((err) => {
708
+ console.error(`refresh-external: fatal: ${err && err.stack ? err.stack : err}`);
709
+ process.exit(2);
710
+ });
711
+ }
712
+
713
+ module.exports = { ALL_SOURCES, loadCtx, parseArgs };