@lincoln504/pi-research 0.1.13 → 1.0.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 (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +55 -138
  3. package/dist/cli.mjs +23375 -0
  4. package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
  5. package/dist/prompts/research-knowledge-search-extractor.md +41 -0
  6. package/dist/prompts/research-tool-usage.md +92 -0
  7. package/dist/prompts/researcher.md +89 -0
  8. package/dist/prompts/system-coordinator.md +75 -0
  9. package/dist/prompts/system-lead-evaluator.md +118 -0
  10. package/dist/thread-worker.mjs +2611 -0
  11. package/dist/webgpu-probe.mjs +80 -0
  12. package/docs/AGENT-SKILL.md +130 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +211 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +122 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +66 -0
  19. package/package.json +102 -36
  20. package/scripts/build.cjs +140 -0
  21. package/scripts/cleanup.cjs +149 -0
  22. package/scripts/prepare.cjs +28 -0
  23. package/scripts/setup.cjs +134 -0
  24. package/skills/pi-research/README.md +90 -0
  25. package/skills/pi-research/SKILL.md +102 -0
  26. package/skills/pi-research/references/configuration.md +77 -0
  27. package/skills/pi-research/scripts/run.mjs +237 -0
  28. package/src/cleanup/research-cleanup.ts +140 -0
  29. package/src/cli.ts +1195 -0
  30. package/src/config.ts +1032 -155
  31. package/src/constants.ts +115 -84
  32. package/src/core/health-check-service.ts +265 -0
  33. package/src/core/interfaces/health-check-interfaces.ts +50 -0
  34. package/src/core/interfaces/knowledge-interfaces.ts +190 -0
  35. package/src/core/interfaces/observer-interfaces.ts +67 -0
  36. package/src/core/interfaces/orchestration-interfaces.ts +86 -0
  37. package/src/core/interfaces/planning-interfaces.ts +100 -0
  38. package/src/core/interfaces/process-interfaces.ts +37 -0
  39. package/src/core/interfaces/research-plan-types.ts +52 -0
  40. package/src/core/interfaces/scheduler-interfaces.ts +91 -0
  41. package/src/core/interfaces/service-names.ts +39 -0
  42. package/src/core/interfaces/state-manager-interfaces.ts +29 -0
  43. package/src/core/interfaces/steering-interfaces.ts +31 -0
  44. package/src/core/llm/agentic-repair.ts +169 -0
  45. package/src/core/llm/inject-date.ts +52 -0
  46. package/src/core/llm/llm-timeout.ts +64 -0
  47. package/src/core/llm/llm-utils.ts +86 -0
  48. package/src/core/llm/model-registry-factory.ts +287 -0
  49. package/src/core/llm/pi-ai-completion.ts +29 -0
  50. package/src/core/llm/prompts.ts +50 -0
  51. package/src/core/llm/research-model-resolver.ts +91 -0
  52. package/src/core/planning-constants.ts +13 -0
  53. package/src/core/planning-service.ts +649 -0
  54. package/src/core/planning-utils.ts +299 -0
  55. package/src/core/scheduler-factory.ts +48 -0
  56. package/src/core/scheduler-service.ts +123 -0
  57. package/src/core/service-initialization.ts +194 -0
  58. package/src/core/service-interfaces.ts +48 -0
  59. package/src/core/service-registry.ts +659 -0
  60. package/src/healthcheck/index.ts +271 -265
  61. package/src/healthcheck/registry.ts +130 -0
  62. package/src/index.ts +639 -50
  63. package/src/infrastructure/browser/browser-cleanup.ts +309 -0
  64. package/src/infrastructure/browser/browser-client.ts +199 -0
  65. package/src/infrastructure/browser/browser-error-utils.ts +160 -0
  66. package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
  67. package/src/infrastructure/browser/browser-server.ts +174 -0
  68. package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
  69. package/src/infrastructure/browser/cleanup-utils.ts +155 -0
  70. package/src/infrastructure/browser/client-agent.ts +31 -0
  71. package/src/infrastructure/browser/config.ts +288 -0
  72. package/src/infrastructure/browser/ensure-browser.ts +205 -0
  73. package/src/infrastructure/browser/index.ts +37 -0
  74. package/src/infrastructure/browser/priority-task-queue.ts +226 -0
  75. package/src/infrastructure/browser/scheduler-factory.ts +354 -0
  76. package/src/infrastructure/browser/task-execution-service.ts +209 -0
  77. package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
  78. package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
  79. package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
  80. package/src/infrastructure/browser/thread-worker-types.ts +25 -0
  81. package/src/infrastructure/browser/thread-worker.mjs +2611 -0
  82. package/src/infrastructure/browser/thread-worker.ts +168 -0
  83. package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
  84. package/src/infrastructure/embedding/embedding-client.ts +205 -0
  85. package/src/infrastructure/embedding/embedding-factory.ts +324 -0
  86. package/src/infrastructure/embedding/embedding-server.ts +535 -0
  87. package/src/infrastructure/file-lock-service.ts +592 -0
  88. package/src/infrastructure/gpu-resource-service.ts +191 -0
  89. package/src/infrastructure/knowledge-store-service.ts +475 -0
  90. package/src/infrastructure/metrics-service.ts +129 -0
  91. package/src/infrastructure/process-lifecycle-service.ts +253 -0
  92. package/src/infrastructure/scheduler-factory-service.ts +58 -0
  93. package/src/infrastructure/service-initialization.ts +292 -0
  94. package/src/infrastructure/state/state-backup-manager.ts +290 -0
  95. package/src/infrastructure/state/state-browser-api.ts +62 -0
  96. package/src/infrastructure/state/state-browser-manager.ts +70 -0
  97. package/src/infrastructure/state/state-manager-service.ts +264 -0
  98. package/src/infrastructure/state/state-manager.ts +547 -0
  99. package/src/infrastructure/state/state-metrics.ts +92 -0
  100. package/src/infrastructure/state/state-migration.ts +103 -0
  101. package/src/infrastructure/state/state-path-configuration.ts +119 -0
  102. package/src/infrastructure/state/state-session-api.ts +94 -0
  103. package/src/infrastructure/state/state-session-manager.ts +127 -0
  104. package/src/infrastructure/state/state-validator.ts +93 -0
  105. package/src/infrastructure/types/state-types.ts +85 -0
  106. package/src/knowledge/chunker.ts +157 -0
  107. package/src/knowledge/embedder-init.ts +413 -0
  108. package/src/knowledge/embedder-utils.ts +296 -0
  109. package/src/knowledge/embedder.ts +592 -0
  110. package/src/knowledge/index.ts +126 -0
  111. package/src/knowledge/lancedb-loader.ts +35 -0
  112. package/src/knowledge/migration.ts +17 -0
  113. package/src/knowledge/model-config.ts +132 -0
  114. package/src/knowledge/onnx-env.ts +48 -0
  115. package/src/knowledge/store-operations.ts +291 -0
  116. package/src/knowledge/store-schema.ts +79 -0
  117. package/src/knowledge/store.ts +1251 -0
  118. package/src/knowledge/transformers-loader.ts +31 -0
  119. package/src/knowledge/webgpu-probe.mjs +80 -0
  120. package/src/knowledge/webgpu-viability.ts +255 -0
  121. package/src/knowledge/writer-queue.ts +252 -0
  122. package/src/logger.ts +316 -122
  123. package/src/observers/research-observer-impl.ts +459 -0
  124. package/src/orchestration/deep-research-orchestrator.ts +524 -773
  125. package/src/orchestration/deep-research-types.ts +47 -35
  126. package/src/orchestration/headless-observer.ts +114 -0
  127. package/src/orchestration/orchestration-types.ts +33 -0
  128. package/src/orchestration/quick-research-orchestrator.ts +463 -0
  129. package/src/orchestration/research-orchestration-service.ts +549 -0
  130. package/src/orchestration/research-session-service.ts +178 -0
  131. package/src/orchestration/research-synthesis-service.ts +348 -0
  132. package/src/orchestration/researcher-executor.ts +394 -0
  133. package/src/orchestration/researcher.ts +115 -42
  134. package/src/orchestration/service-initialization.ts +109 -0
  135. package/src/orchestration/session-context.ts +7 -3
  136. package/src/orchestration/session-state.ts +672 -0
  137. package/src/prompts/research-knowledge-relevance-triage.md +35 -0
  138. package/src/prompts/research-knowledge-search-extractor.md +41 -0
  139. package/src/prompts/research-tool-usage.md +63 -16
  140. package/src/prompts/researcher.md +59 -136
  141. package/src/prompts/system-coordinator.md +64 -13
  142. package/src/prompts/system-lead-evaluator.md +82 -189
  143. package/src/research-config.ts +872 -0
  144. package/src/sdk.ts +847 -0
  145. package/src/security/cisa-kev.ts +72 -9
  146. package/src/security/cvss.ts +92 -0
  147. package/src/security/ecosystem.ts +45 -0
  148. package/src/security/github-advisories.ts +168 -428
  149. package/src/security/github-advisory-types.ts +282 -0
  150. package/src/security/index.ts +63 -177
  151. package/src/security/nvd-client.ts +550 -0
  152. package/src/security/nvd-types.ts +179 -0
  153. package/src/security/osv-client.ts +246 -0
  154. package/src/security/osv-types.ts +285 -0
  155. package/src/security/types.ts +67 -53
  156. package/src/skill-install/skill-installer.ts +519 -0
  157. package/src/stackexchange/index.ts +81 -27
  158. package/src/stackexchange/output/compact.ts +7 -6
  159. package/src/stackexchange/output/table.ts +11 -7
  160. package/src/stackexchange/queries.ts +0 -10
  161. package/src/stackexchange/rest-client.ts +141 -66
  162. package/src/stackexchange/types.ts +0 -17
  163. package/src/tool.ts +15 -423
  164. package/src/tools/grep.ts +32 -310
  165. package/src/tools/health-tool-definition.ts +101 -0
  166. package/src/tools/index.ts +38 -9
  167. package/src/tools/research-knowledge-search.ts +974 -0
  168. package/src/tools/research-knowledge-types.ts +77 -0
  169. package/src/tools/research-tool-definition.ts +494 -0
  170. package/src/tools/scrape.ts +286 -388
  171. package/src/tools/search.ts +98 -153
  172. package/src/tools/security.ts +107 -80
  173. package/src/tools/stackexchange.ts +58 -35
  174. package/src/tools/youtube-transcript.ts +163 -0
  175. package/src/tui/knowledge-search-panel.ts +86 -0
  176. package/src/tui/pulse.ts +86 -0
  177. package/src/tui/research-health.ts +124 -0
  178. package/src/tui/research-panel-color-utils.ts +340 -0
  179. package/src/tui/research-panel-format.ts +54 -0
  180. package/src/tui/research-panel-state.ts +404 -0
  181. package/src/tui/research-panel-wave.ts +280 -0
  182. package/src/tui/research-panel.ts +339 -435
  183. package/src/tui/research-tui-manager.ts +233 -0
  184. package/src/tui/tui-controller.ts +137 -0
  185. package/src/types/extension-context.ts +18 -49
  186. package/src/types/index.ts +61 -0
  187. package/src/types/llm.ts +68 -29
  188. package/src/types/research-panel-types.ts +61 -0
  189. package/src/utils/circuit-breaker.ts +146 -0
  190. package/src/utils/citation-utils.ts +97 -0
  191. package/src/utils/disk-space-checker.ts +55 -0
  192. package/src/utils/error-tracker.ts +265 -0
  193. package/src/utils/host-config.ts +45 -0
  194. package/src/utils/input-validation.ts +8 -10
  195. package/src/utils/json-utils.ts +150 -16
  196. package/src/utils/llm-usage.ts +54 -0
  197. package/src/utils/log-rotation.ts +125 -0
  198. package/src/utils/log-utils.ts +159 -0
  199. package/src/utils/make-resource-loader.ts +3 -1
  200. package/src/utils/metrics-summary.ts +459 -0
  201. package/src/utils/metrics.ts +302 -0
  202. package/src/utils/research-export.ts +28 -12
  203. package/src/utils/safe-unref.ts +31 -0
  204. package/src/utils/shared-links.ts +195 -90
  205. package/src/utils/shutdown-manager.ts +65 -0
  206. package/src/utils/stdio-capture.ts +407 -0
  207. package/src/utils/text-utils.ts +258 -11
  208. package/src/utils/tool-usage-tracker.ts +83 -16
  209. package/src/utils/url-utils.ts +255 -0
  210. package/src/utils/user-agent.ts +28 -0
  211. package/src/web-research/browser-search.ts +179 -0
  212. package/src/web-research/retry-utils.ts +173 -77
  213. package/src/web-research/scraper-types.ts +72 -0
  214. package/src/web-research/scraper-utils.ts +434 -0
  215. package/src/web-research/search.ts +78 -218
  216. package/src/web-research/types.ts +9 -24
  217. package/src/web-research/utils.ts +2 -143
  218. package/src/web-research/web-scraper.ts +477 -0
  219. package/src/youtube/index.ts +154 -0
  220. package/src/youtube/potoken.ts +259 -0
  221. package/src/youtube/transcript-client.ts +390 -0
  222. package/src/youtube/video-id.ts +118 -0
  223. package/config/searxng/default-settings.yml +0 -189
  224. package/config/searxng/limiter.toml +0 -34
  225. package/config/tooling/eslint.config.js +0 -73
  226. package/config/tooling/vitest.config.integration.ts +0 -17
  227. package/config/tooling/vitest.config.ts +0 -40
  228. package/config/tooling/vitest.config.unit.ts +0 -12
  229. package/docs/assets/README-banner.jpg +0 -0
  230. package/scripts/setup.js +0 -212
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -644
  232. package/src/infrastructure/searxng-manager.ts +0 -1183
  233. package/src/infrastructure/state-manager.ts +0 -918
  234. package/src/orchestration/deep-research-reducer.ts +0 -129
  235. package/src/orchestration/id-utils.ts +0 -63
  236. package/src/security/nvd.ts +0 -532
  237. package/src/security/osv.ts +0 -487
  238. package/src/stackexchange/cache.ts +0 -111
  239. package/src/stackexchange/output/index.ts +0 -7
  240. package/src/stackexchange/output/json.ts +0 -21
  241. package/src/tui/searxng-status.ts +0 -80
  242. package/src/utils/inject-date.ts +0 -29
  243. package/src/utils/research-error.ts +0 -205
  244. package/src/utils/searxng-config.ts +0 -94
  245. package/src/utils/session-state.ts +0 -295
  246. package/src/web-research/dependencies.ts +0 -20
  247. package/src/web-research/scrapers.ts +0 -935
  248. package/tsconfig.json +0 -40
@@ -0,0 +1,550 @@
1
+ /**
2
+ * NVD (National Vulnerability Database) API Client
3
+ *
4
+ * NIST National Vulnerability Database
5
+ * API: https://services.nvd.nist.gov/rest/json/cves/2.0
6
+ * Free public API, no authentication required
7
+ *
8
+ * Rate Limits (Free Tier):
9
+ * - ~5 requests per 30 seconds
10
+ * - 50 requests per rolling 30 seconds
11
+ *
12
+ * This module includes a rate limiter to respect these limits.
13
+ */
14
+
15
+ import type { Vulnerability, NVDResult } from './types.ts';
16
+ import type {
17
+ Metrics,
18
+ CVE,
19
+ NVDEntry,
20
+ SearchOptions,
21
+ RetryOptions,
22
+ } from './nvd-types.ts';
23
+ import { createTimeoutSignal, retryWithBackoff, isTransientError } from '../web-research/retry-utils.ts';
24
+ import { CircuitBreaker } from '../utils/circuit-breaker.ts';
25
+ import { safeUnref } from '../utils/safe-unref.ts';
26
+ import { metrics } from '../utils/metrics.ts';
27
+ import {
28
+ isWeaknessDescription,
29
+ isWeakness,
30
+ isReference,
31
+ isCPEMatch,
32
+ isNode,
33
+ isConfiguration,
34
+ isDescription,
35
+ isNVDEntry,
36
+ isNVDApiResponse,
37
+ } from './nvd-types.ts';
38
+
39
+ const nvdCircuitBreaker = new CircuitBreaker({
40
+ failureThreshold: 3,
41
+ resetTimeoutMs: 10000,
42
+ name: 'NVD API',
43
+ isTransientError: isTransientError
44
+ });
45
+
46
+ const NVD_BASE_URL = 'https://services.nvd.nist.gov/rest/json/cves/2.0';
47
+ const DEFAULT_MAX_RESULTS = 20;
48
+ const MAX_RESULTS_PER_PAGE = 2000;
49
+
50
+ // ============================================================================
51
+ // NVD Rate Limiter
52
+ // ============================================================================
53
+
54
+ class NVDRateLimiter {
55
+ private lastRequest: number = 0;
56
+ // Without an API key NVD allows ~5 requests per 30s (→ 6s spacing). With a key
57
+ // the ceiling rises ~10x, so the spacing can safely drop to ~0.6s. Read as a
58
+ // getter (not a frozen field initializer) so a key set after module import is
59
+ // still honored — and stays consistent with the per-request header in
60
+ // createFetchOptions, which also reads the env each call.
61
+ private get minInterval(): number {
62
+ return process.env['NVD_API_KEY'] ? 600 : 6000;
63
+ }
64
+
65
+ async acquire(signal?: AbortSignal): Promise<void> {
66
+ const now = Date.now();
67
+ const waitTime = Math.max(0, this.minInterval - (now - this.lastRequest));
68
+
69
+ this.lastRequest = now + waitTime;
70
+
71
+ if (waitTime > 0) {
72
+ // Abortable: the spacing is up to 6s (no API key), so a caller cancel must
73
+ // not be stuck behind it. On abort, throw so the caller stops paginating.
74
+ await new Promise<void>((resolve, reject) => {
75
+ const timeoutId = setTimeout(() => {
76
+ signal?.removeEventListener('abort', onAbort);
77
+ resolve();
78
+ }, waitTime);
79
+ safeUnref(timeoutId);
80
+ const onAbort = () => {
81
+ clearTimeout(timeoutId as unknown as ReturnType<typeof setTimeout>);
82
+ reject(Object.assign(new Error('NVD search aborted'), { name: 'AbortError' }));
83
+ };
84
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
85
+ });
86
+
87
+ metrics.increment('nvd_ratelimiter_wait_total', 1);
88
+ metrics.observe('nvd_ratelimiter_wait_duration_ms', waitTime);
89
+ }
90
+ }
91
+ }
92
+
93
+ const nvdRateLimiter = new NVDRateLimiter();
94
+
95
+ // ============================================================================
96
+ // Helper Functions
97
+ // ============================================================================
98
+
99
+ function extractCVSSScore(metrics: Metrics | undefined): {
100
+ readonly score: number | undefined;
101
+ readonly vector: string | undefined;
102
+ readonly severity: string;
103
+ } {
104
+ let cvssScore: number | undefined;
105
+ let cvssVector: string | undefined;
106
+ let severity: string = 'UNKNOWN';
107
+
108
+ // Prefer v3.1 → v3.0 (unchanged behavior for the common case), then fall back to v4.0 and finally
109
+ // v2 so CVEs carrying only a newer (v4) or older (v2) metric no longer report UNKNOWN/no-score.
110
+ if (metrics?.cvssMetricV31 && metrics.cvssMetricV31.length > 0) {
111
+ const cvssData = metrics.cvssMetricV31[0]?.cvssData;
112
+ cvssScore = cvssData?.baseScore;
113
+ cvssVector = cvssData?.vectorString;
114
+ severity = cvssData?.baseSeverity ?? 'UNKNOWN';
115
+ } else if (metrics?.cvssMetricV30 && metrics.cvssMetricV30.length > 0) {
116
+ const cvssData = metrics.cvssMetricV30[0]?.cvssData;
117
+ cvssScore = cvssData?.baseScore;
118
+ cvssVector = cvssData?.vectorString;
119
+ severity = cvssData?.baseSeverity ?? 'UNKNOWN';
120
+ } else if (metrics?.cvssMetricV40 && metrics.cvssMetricV40.length > 0) {
121
+ const cvssData = metrics.cvssMetricV40[0]?.cvssData;
122
+ cvssScore = cvssData?.baseScore;
123
+ cvssVector = cvssData?.vectorString;
124
+ severity = cvssData?.baseSeverity ?? 'UNKNOWN';
125
+ } else if (metrics?.cvssMetricV2 && metrics.cvssMetricV2.length > 0) {
126
+ const m = metrics.cvssMetricV2[0];
127
+ cvssScore = m?.cvssData?.baseScore;
128
+ cvssVector = m?.cvssData?.vectorString;
129
+ // v2 has no qualitative rating inside cvssData; use the NVD metric-level baseSeverity, and if
130
+ // that is absent derive it from the score using CVSS v2 bands (v2 has no CRITICAL, caps at HIGH).
131
+ severity = m?.baseSeverity ?? deriveV2Severity(cvssScore);
132
+ }
133
+
134
+ return { score: cvssScore, vector: cvssVector, severity };
135
+ }
136
+
137
+ /** Map a CVSS v2 base score (0-10) to its qualitative band. v2 has no CRITICAL rating. */
138
+ function deriveV2Severity(score: number | undefined): string {
139
+ if (score === undefined) return 'UNKNOWN';
140
+ if (score >= 7.0) return 'HIGH';
141
+ if (score >= 4.0) return 'MEDIUM';
142
+ if (score >= 0) return 'LOW';
143
+ return 'UNKNOWN';
144
+ }
145
+
146
+ function extractCWEs(cve: CVE): string[] {
147
+ const cwes: string[] = [];
148
+
149
+ if (cve.weaknesses) {
150
+ for (const weakness of cve.weaknesses) {
151
+ if (isWeakness(weakness) && weakness.description) {
152
+ for (const desc of weakness.description) {
153
+ if (isWeaknessDescription(desc) && typeof desc.value === 'string' && desc.value.startsWith('CWE-')) {
154
+ cwes.push(desc.value);
155
+ }
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ return cwes;
162
+ }
163
+
164
+ function extractReferences(cve: CVE): string[] {
165
+ const references: string[] = [];
166
+
167
+ if (cve.references) {
168
+ for (const ref of cve.references) {
169
+ if (isReference(ref) && typeof ref.url === 'string') {
170
+ references.push(ref.url);
171
+ }
172
+ }
173
+ }
174
+
175
+ return references;
176
+ }
177
+
178
+ function extractAffectedProducts(cve: CVE): string[] {
179
+ const affectedProducts: string[] = [];
180
+
181
+ if (cve.configurations) {
182
+ for (const config of cve.configurations) {
183
+ if (isConfiguration(config) && config.nodes) {
184
+ for (const node of config.nodes) {
185
+ if (isNode(node) && node.cpeMatch) {
186
+ for (const match of node.cpeMatch) {
187
+ if (isCPEMatch(match) && typeof match.criteria === 'string') {
188
+ affectedProducts.push(match.criteria);
189
+ }
190
+ }
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }
196
+
197
+ return affectedProducts;
198
+ }
199
+
200
+ function getCVEDescription(cve: CVE): string {
201
+ if (cve.descriptions && cve.descriptions.length > 0) {
202
+ const firstDesc = cve.descriptions[0];
203
+ if (isDescription(firstDesc) && typeof firstDesc.value === 'string' && firstDesc.value.length > 0) {
204
+ return firstDesc.value;
205
+ }
206
+ }
207
+ return 'No description available';
208
+ }
209
+
210
+ function parseNVDEntry(nvdEntry: NVDEntry, options: SearchOptions | undefined): Vulnerability {
211
+ const cve = nvdEntry.cve;
212
+ const metrics = cve.metrics;
213
+
214
+ const { score: cvssScore, vector: cvssVector, severity } = extractCVSSScore(metrics);
215
+ const cwes = extractCWEs(cve);
216
+ const references = extractReferences(cve);
217
+ const affectedProducts = extractAffectedProducts(cve);
218
+ const knownExploited = options?.includeExploited === true;
219
+
220
+ return {
221
+ id: cve.id ?? 'UNKNOWN',
222
+ source: 'nvd',
223
+ severity,
224
+ description: getCVEDescription(cve),
225
+ published: cve.published,
226
+ modified: cve.lastModified,
227
+ cvssScore,
228
+ cvssVector,
229
+ cwes,
230
+ references,
231
+ affectedProducts,
232
+ fixes: [],
233
+ knownExploited,
234
+ };
235
+ }
236
+
237
+ function extractNVDEntries(data: unknown): NVDEntry[] {
238
+ if (!isNVDApiResponse(data) || !Array.isArray(data.vulnerabilities)) {
239
+ return [];
240
+ }
241
+ return data.vulnerabilities.filter(isNVDEntry);
242
+ }
243
+
244
+ /**
245
+ * True if a CVE carries a v3.1, v3.0, or v4.0 score. The v2 supplemental query
246
+ * admits ONLY entries where this is false — the genuinely v2-only population that
247
+ * the primary cvssV3Severity query cannot see — so a dual-rated CVE is never
248
+ * re-admitted under a possibly-different v2 band than its authoritative v3/v4 one.
249
+ */
250
+ function hasNewerCvssMetric(metrics: Metrics | undefined): boolean {
251
+ return (
252
+ (metrics?.cvssMetricV31?.length ?? 0) > 0 ||
253
+ (metrics?.cvssMetricV30?.length ?? 0) > 0 ||
254
+ (metrics?.cvssMetricV40?.length ?? 0) > 0
255
+ );
256
+ }
257
+
258
+ /**
259
+ * Which CVSS-severity query parameter to filter on. NVD 2.0's cvssV2Severity and
260
+ * cvssV3Severity params only match a CVE that carries a metric of that specific
261
+ * version — a CVE with no v3 score structurally cannot satisfy cvssV3Severity=X.
262
+ * So a severity-filtered search must issue separate v3- and v2-filtered requests
263
+ * and merge client-side (see searchSingleTerm) to also see CVEs that carry only a
264
+ * v2 score (mostly pre-~2021). Default 'v3' preserves the original behavior.
265
+ *
266
+ * No 'v4' arm: today every v4-rated CVE also carries a v3.1 score, so a
267
+ * cvssV4Severity pass would add a full extra request (tripling latency under the
268
+ * shared rate limiter) for zero net coverage — and cvssV4Severity may not even be
269
+ * a supported NVD 2.0 query param. Add a 'v4' arm here if that ever changes.
270
+ */
271
+ type SeverityQueryVersion = 'v3' | 'v2';
272
+
273
+ function buildURL(
274
+ term: string,
275
+ options: SearchOptions | undefined,
276
+ maxResults: number,
277
+ startIndex: number = 0,
278
+ severityVersion: SeverityQueryVersion = 'v3',
279
+ ): string {
280
+ const params = new URLSearchParams();
281
+
282
+ params.append('keywordSearch', term);
283
+
284
+ if (options?.severity) {
285
+ params.append(severityVersion === 'v2' ? 'cvssV2Severity' : 'cvssV3Severity', options.severity);
286
+ }
287
+ if (options?.includeExploited) {
288
+ params.append('hasKev', '');
289
+ }
290
+ if (options?.cweId) {
291
+ params.append('cweId', options.cweId);
292
+ }
293
+ if (options?.startDate && options.endDate) {
294
+ params.append('pubStartDate', options.startDate);
295
+ params.append('pubEndDate', options.endDate);
296
+ }
297
+ params.append('resultsPerPage', maxResults.toString());
298
+ params.append('startIndex', startIndex.toString());
299
+
300
+ return `${NVD_BASE_URL}?${params.toString()}`;
301
+ }
302
+
303
+ function createFetchOptions(signal?: AbortSignal): RequestInit {
304
+ // An NVD API key raises the public rate limit from ~5 to ~50 requests per
305
+ // 30s rolling window (and is sent as the `apiKey` header, not a bearer token).
306
+ const apiKey = process.env['NVD_API_KEY'];
307
+ return {
308
+ headers: {
309
+ 'User-Agent': 'pi-research/2.0',
310
+ 'Accept': 'application/json',
311
+ ...(apiKey ? { apiKey } : {}),
312
+ },
313
+ // Compose the caller signal with the per-request timeout so either aborts.
314
+ signal: createTimeoutSignal(30000, signal),
315
+ };
316
+ }
317
+
318
+ function handleFetchError(error: unknown): never {
319
+ if (error instanceof Error) {
320
+ if (error.name === 'AbortError' || error.name === 'DOMException') {
321
+ throw new Error(`NVD API timeout: ${error.message}`);
322
+ }
323
+ throw new Error(`NVD API network error: ${error.message}`);
324
+ }
325
+ throw new Error(`NVD API network error: ${String(error)}`);
326
+ }
327
+
328
+ function handleResponseStatus(response: Response): void {
329
+ if (!response.ok) {
330
+ if (response.status === 429) {
331
+ throw new Error('NVD API rate limit exceeded (HTTP 429). Retrying with backoff...');
332
+ }
333
+ if (response.status >= 500) {
334
+ throw new Error(`NVD server error (HTTP ${response.status}). Retrying with backoff...`);
335
+ }
336
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
337
+ }
338
+ }
339
+
340
+ function fetchWithRetry(url: string, signal?: AbortSignal): Promise<Response> {
341
+ const retryOptions: RetryOptions = {
342
+ maxRetries: 3,
343
+ initialDelay: 2000,
344
+ maxDelay: 10000,
345
+ signal,
346
+ };
347
+
348
+ const endpoint = new URL(url).pathname;
349
+
350
+ return nvdCircuitBreaker.execute(async () => {
351
+ return retryWithBackoff(async () => {
352
+ let response: Response;
353
+ try {
354
+ response = await fetch(url, createFetchOptions(signal));
355
+ metrics.increment('nvd_requests_total', 1, { endpoint, status: 'success' });
356
+ metrics.increment('nvd_ratelimiter_used_total', 1, { endpoint });
357
+ } catch (error) {
358
+ const errorType = error instanceof Error ? error.name : 'unknown';
359
+ metrics.increment('nvd_requests_total', 1, { endpoint, status: 'error' });
360
+ metrics.increment('nvd_errors_total', 1, { endpoint, error_type: errorType });
361
+ handleFetchError(error);
362
+ }
363
+ handleResponseStatus(response);
364
+ return response;
365
+ }, retryOptions);
366
+ });
367
+ }
368
+
369
+ async function fetchPaginated(
370
+ term: string,
371
+ options: SearchOptions | undefined,
372
+ maxResults: number,
373
+ severityVersion: SeverityQueryVersion,
374
+ admitEntry: (entry: NVDEntry) => boolean = () => true,
375
+ ): Promise<Vulnerability[]> {
376
+ const allVulnerabilities: Vulnerability[] = [];
377
+ const maxPages = options?.maxPages ?? 5;
378
+ // Scale the page size to the caller's request (bounded by NVD 2.0's 2000 resultsPerPage
379
+ // ceiling), mirroring the GitHub-advisories client. The prior hard cap of 20 silently
380
+ // truncated any request for >20×maxPages results; a larger page also means FEWER requests
381
+ // for the same total, which is friendlier to NVD's aggressive rate limit, not worse.
382
+ const pageSize = Math.min(MAX_RESULTS_PER_PAGE, maxResults);
383
+ const signal = options?.signal;
384
+ let startIndex = 0;
385
+ let totalPagesFetched = 0;
386
+
387
+ while (totalPagesFetched < maxPages && allVulnerabilities.length < maxResults) {
388
+ if (signal?.aborted) break; // stop paginating promptly on cancel
389
+ const url = buildURL(term, options, pageSize, startIndex, severityVersion);
390
+
391
+ await nvdRateLimiter.acquire(signal);
392
+
393
+ const response = await fetchWithRetry(url, signal);
394
+ let data: unknown;
395
+ try {
396
+ data = await response.json();
397
+ } catch {
398
+ // A 200 with a truncated/HTML body (proxy or CDN error page) would otherwise
399
+ // throw and discard this whole term. Stop paginating this term instead.
400
+ metrics.increment('nvd_search_errors_total', 1, { error_type: 'malformed_json' });
401
+ break;
402
+ }
403
+
404
+ // The RAW fetched count drives the "NVD has nothing left" stop decision; the
405
+ // admission filter only decides what's KEPT. If a page returns 20 raw matches
406
+ // but the v2-only filter admits 0 (all also had v3), that must NOT look like
407
+ // exhaustion and truncate pagination — later pages may hold real v2-only rows.
408
+ const rawEntries = extractNVDEntries(data);
409
+ const admitted = rawEntries.filter(admitEntry).map((entry) => parseNVDEntry(entry, options));
410
+
411
+ if (rawEntries.length === 0) {
412
+ metrics.increment('nvd_cache_misses_total', 1, { term });
413
+ break;
414
+ }
415
+ metrics.increment('nvd_cache_hits_total', 1, { term });
416
+
417
+ allVulnerabilities.push(...admitted);
418
+
419
+ const responseData = data as Record<string, unknown> | undefined;
420
+ if (responseData?.['totalResults'] !== undefined && typeof responseData['totalResults'] === 'number' && startIndex + pageSize >= responseData['totalResults']) {
421
+ break;
422
+ }
423
+
424
+ startIndex += pageSize;
425
+ totalPagesFetched++;
426
+ }
427
+
428
+ metrics.increment('nvd_pagination_requests_total', totalPagesFetched);
429
+ metrics.observe('nvd_pagination_pages_fetched', totalPagesFetched);
430
+
431
+ return allVulnerabilities.slice(0, maxResults);
432
+ }
433
+
434
+ /**
435
+ * CVSS v2 has no CRITICAL band (it tops out at HIGH, ≥7.0) — cvssV2Severity only
436
+ * accepts LOW/MEDIUM/HIGH — so a CRITICAL (or unset) search has nothing meaningful
437
+ * to ask the v2-filtered endpoint.
438
+ */
439
+ function shouldSupplementWithV2(options: SearchOptions | undefined): boolean {
440
+ return options?.severity !== undefined && options.severity !== 'CRITICAL';
441
+ }
442
+
443
+ async function searchSingleTerm(
444
+ term: string,
445
+ options: SearchOptions | undefined,
446
+ maxResults: number,
447
+ ): Promise<Vulnerability[]> {
448
+ const primary = await fetchPaginated(term, options, maxResults, 'v3');
449
+
450
+ if (!shouldSupplementWithV2(options)) {
451
+ return primary;
452
+ }
453
+
454
+ try {
455
+ // Supplemental pass: recover CVEs that carry ONLY a CVSS v2 score and are thus
456
+ // invisible to the cvssV3Severity-filtered query above. Admit only entries with
457
+ // no v3/v4 metric so the two result sets are disjoint by construction.
458
+ const v2Only = await fetchPaginated(
459
+ term,
460
+ options,
461
+ maxResults,
462
+ 'v2',
463
+ (entry) => !hasNewerCvssMetric(entry.cve.metrics),
464
+ );
465
+ // `primary` first: a no-op given the disjoint construction, but if any entry
466
+ // unexpectedly appears in both, the v3/v4-authoritative record wins the dedup.
467
+ return deduplicateVulnerabilities([primary, v2Only]).slice(0, maxResults);
468
+ } catch (err) {
469
+ // A supplemental-fetch failure must NOT fail the term — degrade to v3 results.
470
+ const errorType = err instanceof Error ? err.name : 'unknown';
471
+ metrics.increment('nvd_search_errors_total', 1, { error_type: 'v2_supplement_failed', cause: errorType });
472
+ return primary;
473
+ }
474
+ }
475
+
476
+ function deduplicateVulnerabilities(vulnerabilityArrays: Vulnerability[][]): Vulnerability[] {
477
+ const uniqueVulns = new Map<string, Vulnerability>();
478
+
479
+ for (const termResults of vulnerabilityArrays) {
480
+ for (const vuln of termResults) {
481
+ if (!uniqueVulns.has(vuln.id)) {
482
+ uniqueVulns.set(vuln.id, vuln);
483
+ }
484
+ }
485
+ }
486
+
487
+ return Array.from(uniqueVulns.values());
488
+ }
489
+
490
+ /**
491
+ * Search NVD for vulnerabilities
492
+ *
493
+ * @param terms - Search terms (CVE IDs, keywords, CPE names)
494
+ * @param options - Optional filters
495
+ * @returns Promise<NVDResult> containing vulnerability search results
496
+ */
497
+ export async function searchNVD(
498
+ terms: string[],
499
+ options?: SearchOptions,
500
+ ): Promise<NVDResult> {
501
+ const startTime = Date.now();
502
+ const maxResults = Math.min(options?.maxResults ?? DEFAULT_MAX_RESULTS, MAX_RESULTS_PER_PAGE);
503
+ const vulnerabilities: Vulnerability[] = [];
504
+ let totalResults = 0;
505
+ let error: string | undefined = undefined;
506
+
507
+ try {
508
+ const searchPromises = terms.map(
509
+ (term: string) => searchSingleTerm(term, options, maxResults),
510
+ );
511
+
512
+ // allSettled (not all): a single failing term must not discard every other
513
+ // term's successfully-fetched results.
514
+ const settled = await Promise.allSettled(searchPromises);
515
+ const allResults: Vulnerability[][] = [];
516
+ const failures: string[] = [];
517
+ settled.forEach((r, i) => {
518
+ if (r.status === 'fulfilled') {
519
+ allResults.push(r.value);
520
+ } else {
521
+ const reason = r.reason instanceof Error ? r.reason.message : String(r.reason);
522
+ failures.push(`${terms[i] ?? `term#${i}`} (${reason})`);
523
+ metrics.increment('nvd_search_errors_total', 1, { error_type: 'term_failed' });
524
+ }
525
+ });
526
+ if (failures.length > 0) {
527
+ error = `NVD lookup failed for ${failures.length}/${terms.length} term(s): ${failures.join('; ')}`;
528
+ }
529
+
530
+ const uniqueVulns = deduplicateVulnerabilities(allResults);
531
+
532
+ totalResults = uniqueVulns.length;
533
+ vulnerabilities.push(...uniqueVulns.slice(0, maxResults));
534
+
535
+ } catch (err) {
536
+ const errorType = err instanceof Error ? err.name : 'unknown';
537
+ error = err instanceof Error ? err.message : String(err);
538
+ metrics.increment('nvd_search_errors_total', 1, { error_type: errorType });
539
+ } finally {
540
+ const duration = Date.now() - startTime;
541
+ metrics.observe('nvd_search_duration_ms', duration, { has_error: error ? 'true' : 'false' });
542
+ }
543
+
544
+ return {
545
+ count: totalResults,
546
+ vulnerabilities,
547
+ error,
548
+ };
549
+ }
550
+