@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.
- package/LICENSE +1 -1
- package/README.md +55 -138
- package/dist/cli.mjs +23375 -0
- package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
- package/dist/prompts/research-knowledge-search-extractor.md +41 -0
- package/dist/prompts/research-tool-usage.md +92 -0
- package/dist/prompts/researcher.md +89 -0
- package/dist/prompts/system-coordinator.md +75 -0
- package/dist/prompts/system-lead-evaluator.md +118 -0
- package/dist/thread-worker.mjs +2611 -0
- package/dist/webgpu-probe.mjs +80 -0
- package/docs/AGENT-SKILL.md +130 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +211 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +122 -0
- package/docs/THIRD-PARTY-NOTICES.md +66 -0
- package/package.json +102 -36
- package/scripts/build.cjs +140 -0
- package/scripts/cleanup.cjs +149 -0
- package/scripts/prepare.cjs +28 -0
- package/scripts/setup.cjs +134 -0
- package/skills/pi-research/README.md +90 -0
- package/skills/pi-research/SKILL.md +102 -0
- package/skills/pi-research/references/configuration.md +77 -0
- package/skills/pi-research/scripts/run.mjs +237 -0
- package/src/cleanup/research-cleanup.ts +140 -0
- package/src/cli.ts +1195 -0
- package/src/config.ts +1032 -155
- package/src/constants.ts +115 -84
- package/src/core/health-check-service.ts +265 -0
- package/src/core/interfaces/health-check-interfaces.ts +50 -0
- package/src/core/interfaces/knowledge-interfaces.ts +190 -0
- package/src/core/interfaces/observer-interfaces.ts +67 -0
- package/src/core/interfaces/orchestration-interfaces.ts +86 -0
- package/src/core/interfaces/planning-interfaces.ts +100 -0
- package/src/core/interfaces/process-interfaces.ts +37 -0
- package/src/core/interfaces/research-plan-types.ts +52 -0
- package/src/core/interfaces/scheduler-interfaces.ts +91 -0
- package/src/core/interfaces/service-names.ts +39 -0
- package/src/core/interfaces/state-manager-interfaces.ts +29 -0
- package/src/core/interfaces/steering-interfaces.ts +31 -0
- package/src/core/llm/agentic-repair.ts +169 -0
- package/src/core/llm/inject-date.ts +52 -0
- package/src/core/llm/llm-timeout.ts +64 -0
- package/src/core/llm/llm-utils.ts +86 -0
- package/src/core/llm/model-registry-factory.ts +287 -0
- package/src/core/llm/pi-ai-completion.ts +29 -0
- package/src/core/llm/prompts.ts +50 -0
- package/src/core/llm/research-model-resolver.ts +91 -0
- package/src/core/planning-constants.ts +13 -0
- package/src/core/planning-service.ts +649 -0
- package/src/core/planning-utils.ts +299 -0
- package/src/core/scheduler-factory.ts +48 -0
- package/src/core/scheduler-service.ts +123 -0
- package/src/core/service-initialization.ts +194 -0
- package/src/core/service-interfaces.ts +48 -0
- package/src/core/service-registry.ts +659 -0
- package/src/healthcheck/index.ts +271 -265
- package/src/healthcheck/registry.ts +130 -0
- package/src/index.ts +639 -50
- package/src/infrastructure/browser/browser-cleanup.ts +309 -0
- package/src/infrastructure/browser/browser-client.ts +199 -0
- package/src/infrastructure/browser/browser-error-utils.ts +160 -0
- package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
- package/src/infrastructure/browser/browser-server.ts +174 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
- package/src/infrastructure/browser/cleanup-utils.ts +155 -0
- package/src/infrastructure/browser/client-agent.ts +31 -0
- package/src/infrastructure/browser/config.ts +288 -0
- package/src/infrastructure/browser/ensure-browser.ts +205 -0
- package/src/infrastructure/browser/index.ts +37 -0
- package/src/infrastructure/browser/priority-task-queue.ts +226 -0
- package/src/infrastructure/browser/scheduler-factory.ts +354 -0
- package/src/infrastructure/browser/task-execution-service.ts +209 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
- package/src/infrastructure/browser/thread-worker-types.ts +25 -0
- package/src/infrastructure/browser/thread-worker.mjs +2611 -0
- package/src/infrastructure/browser/thread-worker.ts +168 -0
- package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
- package/src/infrastructure/embedding/embedding-client.ts +205 -0
- package/src/infrastructure/embedding/embedding-factory.ts +324 -0
- package/src/infrastructure/embedding/embedding-server.ts +535 -0
- package/src/infrastructure/file-lock-service.ts +592 -0
- package/src/infrastructure/gpu-resource-service.ts +191 -0
- package/src/infrastructure/knowledge-store-service.ts +475 -0
- package/src/infrastructure/metrics-service.ts +129 -0
- package/src/infrastructure/process-lifecycle-service.ts +253 -0
- package/src/infrastructure/scheduler-factory-service.ts +58 -0
- package/src/infrastructure/service-initialization.ts +292 -0
- package/src/infrastructure/state/state-backup-manager.ts +290 -0
- package/src/infrastructure/state/state-browser-api.ts +62 -0
- package/src/infrastructure/state/state-browser-manager.ts +70 -0
- package/src/infrastructure/state/state-manager-service.ts +264 -0
- package/src/infrastructure/state/state-manager.ts +547 -0
- package/src/infrastructure/state/state-metrics.ts +92 -0
- package/src/infrastructure/state/state-migration.ts +103 -0
- package/src/infrastructure/state/state-path-configuration.ts +119 -0
- package/src/infrastructure/state/state-session-api.ts +94 -0
- package/src/infrastructure/state/state-session-manager.ts +127 -0
- package/src/infrastructure/state/state-validator.ts +93 -0
- package/src/infrastructure/types/state-types.ts +85 -0
- package/src/knowledge/chunker.ts +157 -0
- package/src/knowledge/embedder-init.ts +413 -0
- package/src/knowledge/embedder-utils.ts +296 -0
- package/src/knowledge/embedder.ts +592 -0
- package/src/knowledge/index.ts +126 -0
- package/src/knowledge/lancedb-loader.ts +35 -0
- package/src/knowledge/migration.ts +17 -0
- package/src/knowledge/model-config.ts +132 -0
- package/src/knowledge/onnx-env.ts +48 -0
- package/src/knowledge/store-operations.ts +291 -0
- package/src/knowledge/store-schema.ts +79 -0
- package/src/knowledge/store.ts +1251 -0
- package/src/knowledge/transformers-loader.ts +31 -0
- package/src/knowledge/webgpu-probe.mjs +80 -0
- package/src/knowledge/webgpu-viability.ts +255 -0
- package/src/knowledge/writer-queue.ts +252 -0
- package/src/logger.ts +316 -122
- package/src/observers/research-observer-impl.ts +459 -0
- package/src/orchestration/deep-research-orchestrator.ts +524 -773
- package/src/orchestration/deep-research-types.ts +47 -35
- package/src/orchestration/headless-observer.ts +114 -0
- package/src/orchestration/orchestration-types.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +463 -0
- package/src/orchestration/research-orchestration-service.ts +549 -0
- package/src/orchestration/research-session-service.ts +178 -0
- package/src/orchestration/research-synthesis-service.ts +348 -0
- package/src/orchestration/researcher-executor.ts +394 -0
- package/src/orchestration/researcher.ts +115 -42
- package/src/orchestration/service-initialization.ts +109 -0
- package/src/orchestration/session-context.ts +7 -3
- package/src/orchestration/session-state.ts +672 -0
- package/src/prompts/research-knowledge-relevance-triage.md +35 -0
- package/src/prompts/research-knowledge-search-extractor.md +41 -0
- package/src/prompts/research-tool-usage.md +63 -16
- package/src/prompts/researcher.md +59 -136
- package/src/prompts/system-coordinator.md +64 -13
- package/src/prompts/system-lead-evaluator.md +82 -189
- package/src/research-config.ts +872 -0
- package/src/sdk.ts +847 -0
- package/src/security/cisa-kev.ts +72 -9
- package/src/security/cvss.ts +92 -0
- package/src/security/ecosystem.ts +45 -0
- package/src/security/github-advisories.ts +168 -428
- package/src/security/github-advisory-types.ts +282 -0
- package/src/security/index.ts +63 -177
- package/src/security/nvd-client.ts +550 -0
- package/src/security/nvd-types.ts +179 -0
- package/src/security/osv-client.ts +246 -0
- package/src/security/osv-types.ts +285 -0
- package/src/security/types.ts +67 -53
- package/src/skill-install/skill-installer.ts +519 -0
- package/src/stackexchange/index.ts +81 -27
- package/src/stackexchange/output/compact.ts +7 -6
- package/src/stackexchange/output/table.ts +11 -7
- package/src/stackexchange/queries.ts +0 -10
- package/src/stackexchange/rest-client.ts +141 -66
- package/src/stackexchange/types.ts +0 -17
- package/src/tool.ts +15 -423
- package/src/tools/grep.ts +32 -310
- package/src/tools/health-tool-definition.ts +101 -0
- package/src/tools/index.ts +38 -9
- package/src/tools/research-knowledge-search.ts +974 -0
- package/src/tools/research-knowledge-types.ts +77 -0
- package/src/tools/research-tool-definition.ts +494 -0
- package/src/tools/scrape.ts +286 -388
- package/src/tools/search.ts +98 -153
- package/src/tools/security.ts +107 -80
- package/src/tools/stackexchange.ts +58 -35
- package/src/tools/youtube-transcript.ts +163 -0
- package/src/tui/knowledge-search-panel.ts +86 -0
- package/src/tui/pulse.ts +86 -0
- package/src/tui/research-health.ts +124 -0
- package/src/tui/research-panel-color-utils.ts +340 -0
- package/src/tui/research-panel-format.ts +54 -0
- package/src/tui/research-panel-state.ts +404 -0
- package/src/tui/research-panel-wave.ts +280 -0
- package/src/tui/research-panel.ts +339 -435
- package/src/tui/research-tui-manager.ts +233 -0
- package/src/tui/tui-controller.ts +137 -0
- package/src/types/extension-context.ts +18 -49
- package/src/types/index.ts +61 -0
- package/src/types/llm.ts +68 -29
- package/src/types/research-panel-types.ts +61 -0
- package/src/utils/circuit-breaker.ts +146 -0
- package/src/utils/citation-utils.ts +97 -0
- package/src/utils/disk-space-checker.ts +55 -0
- package/src/utils/error-tracker.ts +265 -0
- package/src/utils/host-config.ts +45 -0
- package/src/utils/input-validation.ts +8 -10
- package/src/utils/json-utils.ts +150 -16
- package/src/utils/llm-usage.ts +54 -0
- package/src/utils/log-rotation.ts +125 -0
- package/src/utils/log-utils.ts +159 -0
- package/src/utils/make-resource-loader.ts +3 -1
- package/src/utils/metrics-summary.ts +459 -0
- package/src/utils/metrics.ts +302 -0
- package/src/utils/research-export.ts +28 -12
- package/src/utils/safe-unref.ts +31 -0
- package/src/utils/shared-links.ts +195 -90
- package/src/utils/shutdown-manager.ts +65 -0
- package/src/utils/stdio-capture.ts +407 -0
- package/src/utils/text-utils.ts +258 -11
- package/src/utils/tool-usage-tracker.ts +83 -16
- package/src/utils/url-utils.ts +255 -0
- package/src/utils/user-agent.ts +28 -0
- package/src/web-research/browser-search.ts +179 -0
- package/src/web-research/retry-utils.ts +173 -77
- package/src/web-research/scraper-types.ts +72 -0
- package/src/web-research/scraper-utils.ts +434 -0
- package/src/web-research/search.ts +78 -218
- package/src/web-research/types.ts +9 -24
- package/src/web-research/utils.ts +2 -143
- package/src/web-research/web-scraper.ts +477 -0
- package/src/youtube/index.ts +154 -0
- package/src/youtube/potoken.ts +259 -0
- package/src/youtube/transcript-client.ts +390 -0
- package/src/youtube/video-id.ts +118 -0
- package/config/searxng/default-settings.yml +0 -189
- package/config/searxng/limiter.toml +0 -34
- package/config/tooling/eslint.config.js +0 -73
- package/config/tooling/vitest.config.integration.ts +0 -17
- package/config/tooling/vitest.config.ts +0 -40
- package/config/tooling/vitest.config.unit.ts +0 -12
- package/docs/assets/README-banner.jpg +0 -0
- package/scripts/setup.js +0 -212
- package/src/infrastructure/searxng-lifecycle.ts +0 -644
- package/src/infrastructure/searxng-manager.ts +0 -1183
- package/src/infrastructure/state-manager.ts +0 -918
- package/src/orchestration/deep-research-reducer.ts +0 -129
- package/src/orchestration/id-utils.ts +0 -63
- package/src/security/nvd.ts +0 -532
- package/src/security/osv.ts +0 -487
- package/src/stackexchange/cache.ts +0 -111
- package/src/stackexchange/output/index.ts +0 -7
- package/src/stackexchange/output/json.ts +0 -21
- package/src/tui/searxng-status.ts +0 -80
- package/src/utils/inject-date.ts +0 -29
- package/src/utils/research-error.ts +0 -205
- package/src/utils/searxng-config.ts +0 -94
- package/src/utils/session-state.ts +0 -295
- package/src/web-research/dependencies.ts +0 -20
- package/src/web-research/scrapers.ts +0 -935
- 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
|
+
|