@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,328 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Framework gap analysis and lag scoring library.
5
+ * Operates on data/framework-control-gaps.json and data/global-frameworks.json.
6
+ */
7
+
8
+ const THEATER_PATTERNS = {
9
+ patch_management: {
10
+ name: 'Patch Management Theater',
11
+ description: 'Organization meets framework patch SLA but remains exposed to active exploitation.',
12
+ detection_test: (control, cveData) => {
13
+ if (!control.real_requirement) return false;
14
+ const hasExploitedCVE = control.evidence_cves?.some(id => {
15
+ const cve = cveData[id];
16
+ return cve && (cve.cisa_kev || cve.active_exploitation === 'confirmed');
17
+ });
18
+ return hasExploitedCVE && control.status === 'open';
19
+ }
20
+ },
21
+ access_control_ai: {
22
+ name: 'AI Access Control Theater',
23
+ description: 'Service account authorization is compliant; prompt injection bypasses it entirely.',
24
+ detection_test: (control) => {
25
+ return control.control_id === 'AC-2' && control.status === 'open' &&
26
+ control.misses?.some(m => m.toLowerCase().includes('prompt injection'));
27
+ }
28
+ },
29
+ vendor_management_ai: {
30
+ name: 'Vendor Management Theater',
31
+ description: 'Vendor management controls pass audit but do not reach AI tool plugins (MCP servers).',
32
+ detection_test: (control) => {
33
+ return (control.control_id === 'SA-12' || control.control_id === 'CC9') &&
34
+ control.status === 'open' &&
35
+ control.misses?.some(m => m.toLowerCase().includes('mcp'));
36
+ }
37
+ },
38
+ malware_protection_ai: {
39
+ name: 'Malware Protection Theater',
40
+ description: 'Signature-based malware protection is compliant; AI-generated novel code evades all signatures.',
41
+ detection_test: (control) => {
42
+ return control.control_id === 'SI-3' && control.status === 'open' &&
43
+ control.misses?.some(m => m.toLowerCase().includes('promptflux') || m.toLowerCase().includes('ai-generated'));
44
+ }
45
+ },
46
+ supply_chain_ai: {
47
+ name: 'Supply Chain Theater',
48
+ description: 'Software supply chain controls pass audit; developer-installed AI plugins are outside scope.',
49
+ detection_test: (control) => {
50
+ return control.status === 'open' &&
51
+ control.misses?.some(m => m.toLowerCase().includes('mcp server') && m.toLowerCase().includes('supply chain'));
52
+ }
53
+ },
54
+ encryption_pqc: {
55
+ name: 'Encryption Theater',
56
+ description: 'Encryption controls are compliant with classical algorithms; HNDL exposure is unaddressed.',
57
+ detection_test: (control) => {
58
+ return control.status === 'open' &&
59
+ control.misses?.some(m => m.toLowerCase().includes('quantum') || m.toLowerCase().includes('pqc'));
60
+ }
61
+ },
62
+ detection_ai: {
63
+ name: 'Detection Theater',
64
+ description: 'Security monitoring is compliant; AI C2 (SesameOp) and AI-querying malware are not detected.',
65
+ detection_test: (control) => {
66
+ return control.status === 'open' &&
67
+ control.misses?.some(m => m.toLowerCase().includes('ai api') || m.toLowerCase().includes('c2'));
68
+ }
69
+ }
70
+ };
71
+
72
+ const LAG_WEIGHTS = {
73
+ patch_sla_days_vs_optimal: 0.30,
74
+ universal_gaps_count: 0.25,
75
+ ai_framework_coverage: 0.20,
76
+ pqc_requirement: 0.10,
77
+ notification_sla_hours: 0.15
78
+ };
79
+
80
+ const OPTIMAL = {
81
+ patch_sla_days: 2,
82
+ notification_sla_hours: 4,
83
+ universal_gaps: 0
84
+ };
85
+
86
+ /**
87
+ * Score how much a given framework lags behind current threat reality.
88
+ * Returns 0 (no lag) to 100 (complete theater).
89
+ *
90
+ * @param {string} frameworkId - Key in framework-control-gaps.json
91
+ * @param {object} controlGaps - Parsed framework-control-gaps.json
92
+ * @param {object} globalFrameworks - Parsed global-frameworks.json
93
+ * @returns {{ score: number, breakdown: object, label: string }}
94
+ */
95
+ function lagScore(frameworkId, controlGaps, globalFrameworks) {
96
+ const gaps = Object.values(controlGaps).filter(g =>
97
+ g.framework?.includes(frameworkId) && g.status === 'open'
98
+ );
99
+
100
+ const universalGaps = Object.values(controlGaps).filter(g =>
101
+ g.framework === 'ALL' && g.status === 'open'
102
+ );
103
+
104
+ const frameworkData = _findFrameworkData(frameworkId, globalFrameworks);
105
+
106
+ const patchSlaScore = _scorePatchSla(frameworkData?.patch_sla);
107
+ const notifSlaScore = _scoreNotifSla(frameworkData?.notification_sla);
108
+ const aiCoverageScore = _scoreAiCoverage(frameworkData?.ai_coverage);
109
+ const pqcScore = frameworkData?.pqc_coverage === 'None' ? 100 : 30;
110
+ const universalGapScore = Math.min(100, universalGaps.length * 20);
111
+
112
+ const weighted =
113
+ patchSlaScore * LAG_WEIGHTS.patch_sla_days_vs_optimal +
114
+ universalGapScore * LAG_WEIGHTS.universal_gaps_count +
115
+ aiCoverageScore * LAG_WEIGHTS.ai_framework_coverage +
116
+ pqcScore * LAG_WEIGHTS.pqc_requirement +
117
+ notifSlaScore * LAG_WEIGHTS.notification_sla_hours;
118
+
119
+ const score = Math.round(Math.min(100, weighted));
120
+
121
+ return {
122
+ score,
123
+ label: _lagLabel(score),
124
+ breakdown: {
125
+ patch_sla: { raw_days: frameworkData?.patch_sla ?? null, score: patchSlaScore },
126
+ notification_sla: { raw_hours: frameworkData?.notification_sla ?? null, score: notifSlaScore },
127
+ ai_coverage: { coverage: frameworkData?.ai_coverage ?? 'unknown', score: aiCoverageScore },
128
+ pqc_coverage: { coverage: frameworkData?.pqc_coverage ?? 'unknown', score: pqcScore },
129
+ universal_gaps: { count: universalGaps.length, score: universalGapScore },
130
+ framework_specific_gaps: gaps.length
131
+ }
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Generate a gap report for one or more frameworks vs. a threat scenario.
137
+ *
138
+ * @param {string[]} frameworkIds - Framework identifiers
139
+ * @param {string} threatScenario - Description of the threat or CVE ID
140
+ * @param {object} controlGaps - Parsed framework-control-gaps.json
141
+ * @param {object} cveCatalog - Parsed cve-catalog.json (optional)
142
+ * @returns {{ frameworks: object, universal_gaps: object[], theater_risks: object[] }}
143
+ */
144
+ function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}) {
145
+ const scenario = threatScenario.toLowerCase();
146
+
147
+ const relevantGaps = Object.entries(controlGaps).filter(([, gap]) => {
148
+ const misses = gap.misses?.join(' ').toLowerCase() ?? '';
149
+ const real = gap.real_requirement?.toLowerCase() ?? '';
150
+ return (
151
+ misses.includes(scenario) ||
152
+ real.includes(scenario) ||
153
+ gap.evidence_cves?.some(id => id.toLowerCase() === scenario)
154
+ );
155
+ });
156
+
157
+ const universalGaps = Object.values(controlGaps).filter(g =>
158
+ g.framework === 'ALL' && g.status === 'open'
159
+ );
160
+
161
+ const frameworkResults = {};
162
+ for (const id of frameworkIds) {
163
+ const frameworkGaps = relevantGaps.filter(([, g]) => g.framework?.includes(id));
164
+ frameworkResults[id] = {
165
+ gap_count: frameworkGaps.length,
166
+ gaps: frameworkGaps.map(([key, g]) => ({
167
+ id: key,
168
+ control: g.control_name,
169
+ real_requirement: g.real_requirement,
170
+ status: g.status
171
+ })),
172
+ theater_exposure: frameworkGaps.some(([, g]) => g.status === 'open')
173
+ };
174
+ }
175
+
176
+ const theaterRisks = relevantGaps
177
+ .filter(([, g]) => g.status === 'open' && g.theater_pattern)
178
+ .map(([key, g]) => ({
179
+ control: key,
180
+ pattern: g.theater_pattern,
181
+ framework: g.framework
182
+ }));
183
+
184
+ return {
185
+ threat_scenario: threatScenario,
186
+ frameworks: frameworkResults,
187
+ universal_gaps: universalGaps.map(g => ({
188
+ id: g.control_id,
189
+ name: g.control_name,
190
+ real_requirement: g.real_requirement
191
+ })),
192
+ theater_risks: theaterRisks,
193
+ summary: {
194
+ total_gaps: relevantGaps.length,
195
+ universal_gaps: universalGaps.length,
196
+ theater_risk_controls: theaterRisks.length
197
+ }
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Run all seven theater pattern checks against an organization's control inventory.
203
+ *
204
+ * @param {object} controlGaps - Parsed framework-control-gaps.json
205
+ * @param {object} cveCatalog - Parsed cve-catalog.json
206
+ * @returns {{ findings: object[], theater_score: number, compliant_but_exposed: boolean }}
207
+ */
208
+ function theaterCheck(controlGaps, cveCatalog = {}) {
209
+ const findings = [];
210
+
211
+ for (const [patternId, pattern] of Object.entries(THEATER_PATTERNS)) {
212
+ const matchingControls = Object.values(controlGaps).filter(control =>
213
+ pattern.detection_test(control, cveCatalog)
214
+ );
215
+
216
+ if (matchingControls.length > 0) {
217
+ findings.push({
218
+ pattern_id: patternId,
219
+ pattern_name: pattern.name,
220
+ description: pattern.description,
221
+ affected_controls: matchingControls.map(c => c.control_id || c.control_name),
222
+ severity: _theaterSeverity(patternId)
223
+ });
224
+ }
225
+ }
226
+
227
+ const theaterScore = Math.min(100, findings.length * 15);
228
+
229
+ return {
230
+ findings,
231
+ theater_score: theaterScore,
232
+ theater_label: _theaterLabel(theaterScore),
233
+ compliant_but_exposed: findings.length > 0,
234
+ recommendation: findings.length > 0
235
+ ? 'Compliance audit would pass. Real-world exposure exists. Address highest-severity theater patterns first.'
236
+ : 'No theater patterns detected in scanned controls.'
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Compare multiple frameworks by lag score for a dashboard view.
242
+ *
243
+ * @param {object} controlGaps - Parsed framework-control-gaps.json
244
+ * @param {object} globalFrameworks - Parsed global-frameworks.json
245
+ * @returns {Array} Sorted by lag score descending
246
+ */
247
+ function compareFrameworks(controlGaps, globalFrameworks) {
248
+ const results = [];
249
+ const frameworkIds = _extractFrameworkIds(globalFrameworks);
250
+
251
+ for (const id of frameworkIds) {
252
+ const lag = lagScore(id, controlGaps, globalFrameworks);
253
+ results.push({ framework: id, ...lag });
254
+ }
255
+
256
+ return results.sort((a, b) => b.score - a.score);
257
+ }
258
+
259
+ // --- private helpers ---
260
+
261
+ function _findFrameworkData(frameworkId, globalFrameworks) {
262
+ for (const jurisdiction of Object.values(globalFrameworks)) {
263
+ if (!jurisdiction.frameworks) continue;
264
+ for (const [key, fw] of Object.entries(jurisdiction.frameworks)) {
265
+ if (key === frameworkId || fw.full_name?.includes(frameworkId)) return fw;
266
+ }
267
+ }
268
+ return null;
269
+ }
270
+
271
+ function _scorePatchSla(sla_hours) {
272
+ if (sla_hours === null || sla_hours === undefined) return 60;
273
+ const days = sla_hours / 24;
274
+ if (days <= 2) return 10;
275
+ if (days <= 7) return 30;
276
+ if (days <= 14) return 55;
277
+ if (days <= 30) return 80;
278
+ return 95;
279
+ }
280
+
281
+ function _scoreNotifSla(sla_hours) {
282
+ if (sla_hours === null || sla_hours === undefined) return 50;
283
+ if (sla_hours <= 4) return 10;
284
+ if (sla_hours <= 24) return 30;
285
+ if (sla_hours <= 72) return 60;
286
+ return 80;
287
+ }
288
+
289
+ function _scoreAiCoverage(coverage) {
290
+ if (!coverage || coverage === 'None') return 100;
291
+ if (coverage.toLowerCase().includes('no ai-specific')) return 80;
292
+ if (coverage.toLowerCase().includes('partial')) return 50;
293
+ return 20;
294
+ }
295
+
296
+ function _lagLabel(score) {
297
+ if (score >= 80) return 'critical_lag';
298
+ if (score >= 60) return 'significant_lag';
299
+ if (score >= 40) return 'moderate_lag';
300
+ if (score >= 20) return 'minor_lag';
301
+ return 'current';
302
+ }
303
+
304
+ function _theaterSeverity(patternId) {
305
+ const high = ['access_control_ai', 'malware_protection_ai', 'vendor_management_ai'];
306
+ const critical = ['patch_management'];
307
+ if (critical.includes(patternId)) return 'critical';
308
+ if (high.includes(patternId)) return 'high';
309
+ return 'medium';
310
+ }
311
+
312
+ function _theaterLabel(score) {
313
+ if (score >= 75) return 'systemic_theater';
314
+ if (score >= 45) return 'significant_theater';
315
+ if (score >= 15) return 'partial_theater';
316
+ return 'minimal_theater';
317
+ }
318
+
319
+ function _extractFrameworkIds(globalFrameworks) {
320
+ const ids = [];
321
+ for (const jurisdiction of Object.values(globalFrameworks)) {
322
+ if (!jurisdiction.frameworks) continue;
323
+ ids.push(...Object.keys(jurisdiction.frameworks));
324
+ }
325
+ return ids;
326
+ }
327
+
328
+ module.exports = { lagScore, gapReport, theaterCheck, compareFrameworks };
@@ -0,0 +1,195 @@
1
+ "use strict";
2
+ /**
3
+ * lib/job-queue.js
4
+ *
5
+ * Async job queue specialized for exceptd's upstream-fetch workloads. The
6
+ * generic retry semantics (HTTP + Node net classifier, exponential
7
+ * backoff with crypto jitter, AbortSignal-aware sleep) live in the
8
+ * vendored blamejs retry primitive — this module wraps it with the
9
+ * per-source concurrency caps + token-bucket rate limits + priority
10
+ * ordering specific to the KEV / EPSS / NVD / IETF / GitHub workloads.
11
+ *
12
+ * - per-source concurrency caps (max in-flight)
13
+ * - token-bucket rate limiting (max requests per window)
14
+ * - priority ordering (higher number = sooner)
15
+ * - retry classification + backoff delegated to vendor/blamejs/retry.js
16
+ * - per-source stats (queued / in_flight / completed / failed / retried)
17
+ *
18
+ * No npm deps. Node 24 stdlib + vendored retry.js.
19
+ */
20
+
21
+ const retry = require("../vendor/blamejs/retry");
22
+
23
+ class TokenBucket {
24
+ constructor({ tokens, windowMs }) {
25
+ this.capacity = tokens;
26
+ this.tokens = tokens;
27
+ this.windowMs = windowMs;
28
+ this.refillIntervalMs = windowMs / tokens;
29
+ this.lastRefill = Date.now();
30
+ }
31
+ refill() {
32
+ const now = Date.now();
33
+ const elapsed = now - this.lastRefill;
34
+ if (elapsed <= 0) return;
35
+ const add = elapsed / this.refillIntervalMs;
36
+ if (add >= 1) {
37
+ this.tokens = Math.min(this.capacity, this.tokens + Math.floor(add));
38
+ this.lastRefill = now;
39
+ }
40
+ }
41
+ tryTake() {
42
+ this.refill();
43
+ if (this.tokens >= 1) {
44
+ this.tokens -= 1;
45
+ return 0;
46
+ }
47
+ return Math.max(1, Math.ceil(this.refillIntervalMs - (Date.now() - this.lastRefill)));
48
+ }
49
+ }
50
+
51
+ class JobQueue {
52
+ constructor(options = {}) {
53
+ const defaults = { concurrency: 4 };
54
+ this.sources = options.sources || { default: defaults };
55
+ // Retry options forwarded to vendored withRetry. Defaults are tighter
56
+ // than withRetry's own DEFAULT_RETRY so a network blip on KEV/EPSS
57
+ // doesn't stall the whole refresh by 80s.
58
+ this.retry = {
59
+ maxAttempts: 3,
60
+ baseDelayMs: 200,
61
+ maxDelayMs: 5000,
62
+ jitterFactor: 0.5,
63
+ ...options.retry,
64
+ };
65
+ // Caller may override the retry classifier. Default is the vendored
66
+ // blamejs classifier (HTTP 408/425/429/5xx + Node net codes), which
67
+ // matches everything the upstream sources emit.
68
+ this.isRetryable = options.isRetryable || retry.isRetryable;
69
+ this._perSource = {};
70
+ for (const [name, cfg] of Object.entries(this.sources)) {
71
+ this._perSource[name] = {
72
+ cfg: { concurrency: defaults.concurrency, ...cfg },
73
+ pending: [],
74
+ inFlight: 0,
75
+ bucket: cfg.rate ? new TokenBucket(cfg.rate) : null,
76
+ stats: { queued: 0, in_flight: 0, completed: 0, failed: 0, retried: 0 },
77
+ };
78
+ }
79
+ this._drainResolvers = [];
80
+ this._closed = false;
81
+ }
82
+
83
+ /**
84
+ * Enqueue a job.
85
+ * @param {object} job
86
+ * - source: source key from the sources map (default: "default")
87
+ * - priority: integer; higher runs sooner within a source
88
+ * - run: async () => result
89
+ * - signal: optional AbortSignal forwarded to retry.withRetry sleep
90
+ * - retry: per-job retry override (merged onto queue defaults)
91
+ * - meta: freeform metadata attached to stats / errors
92
+ */
93
+ add(job) {
94
+ if (this._closed) return Promise.reject(new Error("JobQueue is closed"));
95
+ const source = job.source || "default";
96
+ const bucket = this._perSource[source] || this._perSource.default;
97
+ if (!bucket) {
98
+ return Promise.reject(new Error(`JobQueue: unknown source "${source}" and no default configured`));
99
+ }
100
+ return new Promise((resolve, reject) => {
101
+ const entry = {
102
+ source,
103
+ priority: typeof job.priority === "number" ? job.priority : 0,
104
+ run: job.run,
105
+ retry: { ...this.retry, ...(job.retry || {}) },
106
+ signal: job.signal,
107
+ meta: job.meta || {},
108
+ resolve,
109
+ reject,
110
+ };
111
+ bucket.pending.push(entry);
112
+ bucket.pending.sort((a, b) => b.priority - a.priority);
113
+ bucket.stats.queued++;
114
+ this._tick(source);
115
+ });
116
+ }
117
+
118
+ _tick(source) {
119
+ const bucket = this._perSource[source];
120
+ if (!bucket) return;
121
+ while (bucket.inFlight < bucket.cfg.concurrency && bucket.pending.length > 0) {
122
+ if (bucket.bucket) {
123
+ const waitMs = bucket.bucket.tryTake();
124
+ if (waitMs > 0) {
125
+ setTimeout(() => this._tick(source), waitMs);
126
+ break;
127
+ }
128
+ }
129
+ const entry = bucket.pending.shift();
130
+ bucket.stats.queued--;
131
+ bucket.inFlight++;
132
+ bucket.stats.in_flight = bucket.inFlight;
133
+ this._run(entry).finally(() => {
134
+ bucket.inFlight--;
135
+ bucket.stats.in_flight = bucket.inFlight;
136
+ this._tick(source);
137
+ if (this._allIdle()) this._notifyDrain();
138
+ });
139
+ }
140
+ }
141
+
142
+ async _run(entry) {
143
+ const bucket = this._perSource[entry.source];
144
+ const onRetry = () => { bucket.stats.retried++; };
145
+ try {
146
+ const result = await retry.withRetry(
147
+ () => entry.run(),
148
+ {
149
+ maxAttempts: entry.retry.maxAttempts,
150
+ baseDelayMs: entry.retry.baseDelayMs,
151
+ maxDelayMs: entry.retry.maxDelayMs,
152
+ jitterFactor: entry.retry.jitterFactor,
153
+ isRetryable: this.isRetryable,
154
+ onRetry,
155
+ signal: entry.signal,
156
+ }
157
+ );
158
+ bucket.stats.completed++;
159
+ entry.resolve(result);
160
+ } catch (err) {
161
+ bucket.stats.failed++;
162
+ try { err.queue_meta = { source: entry.source, ...entry.meta }; } catch { /* readonly */ }
163
+ entry.reject(err);
164
+ }
165
+ }
166
+
167
+ _allIdle() {
168
+ for (const b of Object.values(this._perSource)) {
169
+ if (b.inFlight > 0 || b.pending.length > 0) return false;
170
+ }
171
+ return true;
172
+ }
173
+
174
+ _notifyDrain() {
175
+ for (const r of this._drainResolvers) r();
176
+ this._drainResolvers = [];
177
+ }
178
+
179
+ drain() {
180
+ if (this._allIdle()) return Promise.resolve();
181
+ return new Promise((resolve) => this._drainResolvers.push(resolve));
182
+ }
183
+
184
+ stats() {
185
+ const out = {};
186
+ for (const [name, b] of Object.entries(this._perSource)) out[name] = { ...b.stats };
187
+ return out;
188
+ }
189
+
190
+ close() {
191
+ this._closed = true;
192
+ }
193
+ }
194
+
195
+ module.exports = { JobQueue, TokenBucket, isRetryable: retry.isRetryable };