@lincoln504/pi-research 0.0.0
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 +21 -0
- package/README.md +131 -0
- package/config/searxng/default-settings.yml +189 -0
- package/config/searxng/limiter.toml +34 -0
- package/config/tooling/eslint.config.js +73 -0
- package/config/tooling/vitest.config.integration.ts +17 -0
- package/config/tooling/vitest.config.ts +40 -0
- package/config/tooling/vitest.config.unit.ts +12 -0
- package/docs/ARCHITECTURE.md +109 -0
- package/docs/assets/README-banner.jpg +0 -0
- package/package.json +92 -0
- package/scripts/setup.js +174 -0
- package/src/config.ts +206 -0
- package/src/constants.ts +148 -0
- package/src/healthcheck/index.ts +309 -0
- package/src/index.ts +71 -0
- package/src/infrastructure/searxng-lifecycle.ts +577 -0
- package/src/infrastructure/searxng-manager.ts +1173 -0
- package/src/infrastructure/state-manager.ts +918 -0
- package/src/logger.ts +246 -0
- package/src/orchestration/deep-research-orchestrator.ts +848 -0
- package/src/orchestration/deep-research-reducer.ts +129 -0
- package/src/orchestration/deep-research-types.ts +54 -0
- package/src/orchestration/id-utils.ts +63 -0
- package/src/orchestration/researcher.ts +104 -0
- package/src/orchestration/session-context.ts +43 -0
- package/src/prompts/research-tool-usage.md +45 -0
- package/src/prompts/researcher.md +166 -0
- package/src/prompts/system-coordinator.md +24 -0
- package/src/prompts/system-lead-evaluator.md +225 -0
- package/src/security/cisa-kev.ts +242 -0
- package/src/security/github-advisories.ts +607 -0
- package/src/security/index.ts +406 -0
- package/src/security/nvd.ts +532 -0
- package/src/security/osv.ts +487 -0
- package/src/security/types.ts +138 -0
- package/src/stackexchange/cache.ts +111 -0
- package/src/stackexchange/index.ts +352 -0
- package/src/stackexchange/output/compact.ts +64 -0
- package/src/stackexchange/output/index.ts +7 -0
- package/src/stackexchange/output/json.ts +21 -0
- package/src/stackexchange/output/table.ts +123 -0
- package/src/stackexchange/queries.ts +168 -0
- package/src/stackexchange/rest-client.ts +127 -0
- package/src/stackexchange/types.ts +129 -0
- package/src/tool.ts +430 -0
- package/src/tools/grep.ts +335 -0
- package/src/tools/index.ts +77 -0
- package/src/tools/scrape.ts +451 -0
- package/src/tools/search.ts +188 -0
- package/src/tools/security.ts +292 -0
- package/src/tools/stackexchange.ts +101 -0
- package/src/tui/research-panel.ts +576 -0
- package/src/tui/searxng-status.ts +80 -0
- package/src/types/extension-context.ts +60 -0
- package/src/types/llm.ts +111 -0
- package/src/utils/inject-date.ts +29 -0
- package/src/utils/input-validation.ts +103 -0
- package/src/utils/json-utils.ts +262 -0
- package/src/utils/make-resource-loader.ts +43 -0
- package/src/utils/research-error.ts +205 -0
- package/src/utils/research-export.ts +199 -0
- package/src/utils/searxng-config.ts +94 -0
- package/src/utils/session-state.ts +295 -0
- package/src/utils/shared-links.ts +123 -0
- package/src/utils/shutdown-manager.ts +54 -0
- package/src/utils/text-utils.ts +83 -0
- package/src/utils/tool-usage-tracker.ts +135 -0
- package/src/web-research/dependencies.ts +20 -0
- package/src/web-research/retry-utils.ts +232 -0
- package/src/web-research/scrapers.ts +935 -0
- package/src/web-research/search.ts +235 -0
- package/src/web-research/types.ts +47 -0
- package/src/web-research/utils.ts +158 -0
- package/tsconfig.json +40 -0
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Advisory Database API Client
|
|
3
|
+
*
|
|
4
|
+
* GitHub Security Advisories API
|
|
5
|
+
* API: https://api.github.com/advisories
|
|
6
|
+
* Free public API, rate limited (60/hr unauthenticated, 5000/hr authenticated)
|
|
7
|
+
*
|
|
8
|
+
* Rate limits are returned in headers:
|
|
9
|
+
* - X-RateLimit-Limit: Total requests allowed
|
|
10
|
+
* - X-RateLimit-Remaining: Requests remaining
|
|
11
|
+
* - X-RateLimit-Reset: Unix timestamp when limit resets
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Advisory, GitHubResult } from './types.ts';
|
|
15
|
+
import { logger } from '../logger.ts';
|
|
16
|
+
import { createTimeoutSignal, retryWithBackoff } from '../web-research/retry-utils.ts';
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Type Definitions for Fetch API
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Standard fetch API types for compatibility
|
|
24
|
+
*/
|
|
25
|
+
type FetchType = (
|
|
26
|
+
_input: string | Request,
|
|
27
|
+
_init?: RequestInit,
|
|
28
|
+
) => Promise<Response>;
|
|
29
|
+
|
|
30
|
+
declare const fetch: FetchType;
|
|
31
|
+
|
|
32
|
+
interface Headers {
|
|
33
|
+
append(_name: string, _value: string): void;
|
|
34
|
+
delete(_name: string): void;
|
|
35
|
+
get(_name: string): string | null;
|
|
36
|
+
has(_name: string): boolean;
|
|
37
|
+
set(_name: string, _value: string): void;
|
|
38
|
+
forEach(_callback: (_value: string, _name: string) => void): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Response {
|
|
42
|
+
readonly headers: Headers;
|
|
43
|
+
readonly ok: boolean;
|
|
44
|
+
readonly redirected: boolean;
|
|
45
|
+
readonly status: number;
|
|
46
|
+
readonly statusText: string;
|
|
47
|
+
readonly type: 'basic' | 'cors' | 'default' | 'error' | 'opaque' | 'opaqueredirect';
|
|
48
|
+
readonly url: string;
|
|
49
|
+
readonly body: ReadableStream<Uint8Array> | null;
|
|
50
|
+
readonly bodyUsed: boolean;
|
|
51
|
+
clone(): Response;
|
|
52
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
53
|
+
blob(): Promise<Blob>;
|
|
54
|
+
formData(): Promise<FormData>;
|
|
55
|
+
json(): Promise<unknown>;
|
|
56
|
+
text(): Promise<string>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// Type Definitions for GitHub API Responses
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* GitHub Advisory Vulnerability Package
|
|
65
|
+
*/
|
|
66
|
+
interface GitHubAdvisoryPackage {
|
|
67
|
+
readonly ecosystem?: string;
|
|
68
|
+
readonly name?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* GitHub Advisory Affected Entry
|
|
73
|
+
*/
|
|
74
|
+
interface GitHubAdvisoryAffected {
|
|
75
|
+
readonly package?: GitHubAdvisoryPackage;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* GitHub Advisory Vulnerability
|
|
80
|
+
*/
|
|
81
|
+
interface GitHubAdvisoryVulnerability {
|
|
82
|
+
readonly package?: GitHubAdvisoryPackage;
|
|
83
|
+
readonly affected?: readonly GitHubAdvisoryAffected[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Raw GitHub Advisory from API response
|
|
88
|
+
*/
|
|
89
|
+
interface GitHubAdvisoryRaw {
|
|
90
|
+
readonly ghsa_id?: string;
|
|
91
|
+
readonly id?: string;
|
|
92
|
+
readonly summary?: string;
|
|
93
|
+
readonly description?: string;
|
|
94
|
+
readonly severity?: string;
|
|
95
|
+
readonly published_at?: string;
|
|
96
|
+
readonly updated_at?: string;
|
|
97
|
+
readonly cve_id?: string;
|
|
98
|
+
readonly html_url?: string;
|
|
99
|
+
readonly advisory_url?: string;
|
|
100
|
+
readonly vulnerabilities?: readonly GitHubAdvisoryVulnerability[];
|
|
101
|
+
readonly affected?: readonly GitHubAdvisoryAffected[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* GitHub API list response wrapper
|
|
106
|
+
*/
|
|
107
|
+
interface GitHubAdvisoryListResponse {
|
|
108
|
+
readonly items?: readonly GitHubAdvisoryRaw[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
// ============================================================================
|
|
113
|
+
// Constants
|
|
114
|
+
// ============================================================================
|
|
115
|
+
|
|
116
|
+
const GITHUB_API_BASE = 'https://api.github.com';
|
|
117
|
+
const DEFAULT_MAX_RESULTS = 20;
|
|
118
|
+
|
|
119
|
+
// ============================================================================
|
|
120
|
+
// Type Guards
|
|
121
|
+
// ============================================================================
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Type guard to check if value is a GitHubAdvisoryRaw
|
|
125
|
+
*/
|
|
126
|
+
function isGitHubAdvisoryRaw(value: unknown): value is GitHubAdvisoryRaw {
|
|
127
|
+
if (typeof value !== 'object' || value === null) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Type guard to check if value is an array
|
|
135
|
+
*/
|
|
136
|
+
function isArray(value: unknown): value is readonly unknown[] {
|
|
137
|
+
return Array.isArray(value);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Type guard to check if value is a string
|
|
142
|
+
*/
|
|
143
|
+
function isString(value: unknown): value is string {
|
|
144
|
+
return typeof value === 'string';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Type guard to check if value is an object (non-null, non-array)
|
|
149
|
+
*/
|
|
150
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
151
|
+
return typeof value === 'object' && value !== null && !isArray(value);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Type guard for GitHubAdvisoryPackage
|
|
156
|
+
*/
|
|
157
|
+
function isGitHubAdvisoryPackage(value: unknown): value is GitHubAdvisoryPackage {
|
|
158
|
+
if (!isObject(value)) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Type guard for GitHubAdvisoryAffected
|
|
166
|
+
*/
|
|
167
|
+
function isGitHubAdvisoryAffected(value: unknown): value is GitHubAdvisoryAffected {
|
|
168
|
+
if (!isObject(value)) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Type guard for GitHubAdvisoryVulnerability
|
|
176
|
+
*/
|
|
177
|
+
function isGitHubAdvisoryVulnerability(value: unknown): value is GitHubAdvisoryVulnerability {
|
|
178
|
+
if (!isObject(value)) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Type guard to check if response is a list response with items
|
|
186
|
+
*/
|
|
187
|
+
function isGitHubAdvisoryListResponse(value: unknown): value is GitHubAdvisoryListResponse {
|
|
188
|
+
if (!isObject(value)) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
return 'items' in value;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Type guard to check if response is an array of advisory objects
|
|
196
|
+
*/
|
|
197
|
+
function isGitHubAdvisoryArray(value: unknown): value is GitHubAdvisoryRaw[] {
|
|
198
|
+
if (!isArray(value)) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
return value.every(isGitHubAdvisoryRaw);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Type guard to check if response is a single advisory object
|
|
206
|
+
*/
|
|
207
|
+
function isSingleGitHubAdvisory(value: unknown): value is GitHubAdvisoryRaw {
|
|
208
|
+
if (!isGitHubAdvisoryRaw(value)) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
// Single advisory typically has ghsa_id
|
|
212
|
+
return 'ghsa_id' in value || 'summary' in value;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ============================================================================
|
|
216
|
+
// Helper Functions
|
|
217
|
+
// ============================================================================
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Extract affected packages from an advisory item
|
|
221
|
+
*/
|
|
222
|
+
function extractAffectedPackages(item: GitHubAdvisoryRaw): string[] {
|
|
223
|
+
const affectedPackages: string[] = [];
|
|
224
|
+
|
|
225
|
+
// Try different possible structures for affected packages
|
|
226
|
+
// GitHub API response structure may vary between endpoints
|
|
227
|
+
|
|
228
|
+
// Check for vulnerabilities array
|
|
229
|
+
if (item.vulnerabilities !== undefined && isArray(item.vulnerabilities)) {
|
|
230
|
+
for (const vuln of item.vulnerabilities) {
|
|
231
|
+
if (!isGitHubAdvisoryVulnerability(vuln)) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Check for package in various possible locations
|
|
236
|
+
if (vuln.package !== undefined) {
|
|
237
|
+
if (isGitHubAdvisoryPackage(vuln.package)) {
|
|
238
|
+
const ecosystem = vuln.package.ecosystem ?? '';
|
|
239
|
+
const name = vuln.package.name;
|
|
240
|
+
|
|
241
|
+
if (isString(name) && name !== '') {
|
|
242
|
+
if (ecosystem !== '') {
|
|
243
|
+
affectedPackages.push(`${ecosystem}/${name}`);
|
|
244
|
+
} else {
|
|
245
|
+
affectedPackages.push(name);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} else if (vuln.affected !== undefined && isArray(vuln.affected)) {
|
|
250
|
+
// Alternative structure with 'affected' array
|
|
251
|
+
for (const aff of vuln.affected) {
|
|
252
|
+
if (isGitHubAdvisoryAffected(aff) && aff.package !== undefined) {
|
|
253
|
+
const pkg = aff.package;
|
|
254
|
+
if (isGitHubAdvisoryPackage(pkg)) {
|
|
255
|
+
const ecosystem = pkg.ecosystem ?? '';
|
|
256
|
+
const name = pkg.name;
|
|
257
|
+
|
|
258
|
+
if (isString(name) && name !== '') {
|
|
259
|
+
if (ecosystem !== '') {
|
|
260
|
+
affectedPackages.push(`${ecosystem}/${name}`);
|
|
261
|
+
} else {
|
|
262
|
+
affectedPackages.push(name);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Check for direct 'affected' array at the top level
|
|
273
|
+
if (affectedPackages.length === 0 && item.affected !== undefined && isArray(item.affected)) {
|
|
274
|
+
for (const aff of item.affected) {
|
|
275
|
+
if (isGitHubAdvisoryAffected(aff) && aff.package !== undefined) {
|
|
276
|
+
const pkg = aff.package;
|
|
277
|
+
if (isGitHubAdvisoryPackage(pkg)) {
|
|
278
|
+
const ecosystem = pkg.ecosystem ?? '';
|
|
279
|
+
const name = pkg.name;
|
|
280
|
+
|
|
281
|
+
if (isString(name) && name !== '') {
|
|
282
|
+
if (ecosystem !== '') {
|
|
283
|
+
affectedPackages.push(`${ecosystem}/${name}`);
|
|
284
|
+
} else {
|
|
285
|
+
affectedPackages.push(name);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return affectedPackages;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Extract references from an advisory item
|
|
298
|
+
*/
|
|
299
|
+
function extractReferences(item: GitHubAdvisoryRaw): string[] {
|
|
300
|
+
const references: string[] = [];
|
|
301
|
+
|
|
302
|
+
if (item.html_url !== undefined && item.html_url !== '') {
|
|
303
|
+
references.push(item.html_url);
|
|
304
|
+
}
|
|
305
|
+
if (item.advisory_url !== undefined && item.advisory_url !== '') {
|
|
306
|
+
references.push(item.advisory_url);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return references;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ============================================================================
|
|
313
|
+
// Main API Functions
|
|
314
|
+
// ============================================================================
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Search GitHub security advisories
|
|
318
|
+
*
|
|
319
|
+
* @param terms - Search terms (CVE IDs, GHSA IDs, package names)
|
|
320
|
+
* @param options - Optional filters
|
|
321
|
+
* @returns Promise<GitHubResult> containing matching advisories
|
|
322
|
+
*/
|
|
323
|
+
export async function searchGitHubAdvisories(
|
|
324
|
+
terms: readonly string[],
|
|
325
|
+
options?: {
|
|
326
|
+
readonly ecosystem?: string; // npm, pip, maven, go, etc.
|
|
327
|
+
readonly severity?: string; // LOW, MODERATE, HIGH, CRITICAL
|
|
328
|
+
readonly maxResults?: number;
|
|
329
|
+
readonly repo?: string; // "owner/repo" format
|
|
330
|
+
},
|
|
331
|
+
): Promise<GitHubResult> {
|
|
332
|
+
const maxResults = options?.maxResults ?? DEFAULT_MAX_RESULTS;
|
|
333
|
+
const advisories: Advisory[] = [];
|
|
334
|
+
let error: string | undefined = undefined;
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
let allAdvisories: Advisory[] = [];
|
|
338
|
+
|
|
339
|
+
// If repo specified, search repo-specific advisories
|
|
340
|
+
if (options?.repo !== undefined && options.repo !== '') {
|
|
341
|
+
const repoParts = options.repo.split('/');
|
|
342
|
+
|
|
343
|
+
if (repoParts.length !== 2 || repoParts[0] === '' || repoParts[1] === '') {
|
|
344
|
+
throw new Error(`Invalid repo format: "${options.repo}". Expected "owner/name".`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const [owner, name] = repoParts;
|
|
348
|
+
const url = `${GITHUB_API_BASE}/repos/${owner}/${name}/security-advisories?per_page=${maxResults}`;
|
|
349
|
+
|
|
350
|
+
const response = await retryWithBackoff(async (): Promise<Response> => {
|
|
351
|
+
const resp = await fetch(url, {
|
|
352
|
+
headers: {
|
|
353
|
+
'User-Agent': 'pi-research/2.0',
|
|
354
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
355
|
+
},
|
|
356
|
+
signal: createTimeoutSignal(30000),
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
if (resp.ok === false) {
|
|
360
|
+
if (resp.status === 404) {
|
|
361
|
+
throw new Error(`Repository "${owner}/${name}" not found or no access to security advisories.`);
|
|
362
|
+
}
|
|
363
|
+
if (resp.status === 403) {
|
|
364
|
+
throw new Error('GitHub API rate limit exceeded (HTTP 403). Retrying with backoff...');
|
|
365
|
+
}
|
|
366
|
+
if (resp.status >= 500) {
|
|
367
|
+
throw new Error(`GitHub server error (HTTP ${resp.status}). Retrying with backoff...`);
|
|
368
|
+
}
|
|
369
|
+
throw new Error(`GitHub API error (${resp.status}): ${resp.statusText}`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return resp;
|
|
373
|
+
}, {
|
|
374
|
+
maxRetries: 2,
|
|
375
|
+
initialDelay: 1000,
|
|
376
|
+
maxDelay: 5000,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const data = await response.json();
|
|
380
|
+
let repoAdvisories: readonly GitHubAdvisoryRaw[] = [];
|
|
381
|
+
|
|
382
|
+
if (isGitHubAdvisoryArray(data)) {
|
|
383
|
+
repoAdvisories = data;
|
|
384
|
+
} else if (isGitHubAdvisoryListResponse(data) && isArray(data.items)) {
|
|
385
|
+
// Filter to ensure only valid advisory objects
|
|
386
|
+
repoAdvisories = data.items.filter(isGitHubAdvisoryRaw);
|
|
387
|
+
} else if (isSingleGitHubAdvisory(data)) {
|
|
388
|
+
repoAdvisories = [data];
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
allAdvisories = repoAdvisories.map(mapGitHubAdvisory);
|
|
392
|
+
} else {
|
|
393
|
+
// Search global advisories.
|
|
394
|
+
// The GitHub advisories API supports cve_id and ghsa_id as query params,
|
|
395
|
+
// so use those for precise lookups; fall back to a general listing for
|
|
396
|
+
// keyword terms (client-side filtering is applied afterwards).
|
|
397
|
+
const termResults: Advisory[] = [];
|
|
398
|
+
|
|
399
|
+
for (const term of terms) {
|
|
400
|
+
const termUpper = term.toUpperCase();
|
|
401
|
+
let apiUrl: string;
|
|
402
|
+
|
|
403
|
+
if (termUpper.startsWith('CVE-')) {
|
|
404
|
+
// Use cve_id param for CVE lookups
|
|
405
|
+
apiUrl = `${GITHUB_API_BASE}/advisories?cve_id=${encodeURIComponent(termUpper)}&per_page=${maxResults}`;
|
|
406
|
+
} else if (termUpper.startsWith('GHSA-')) {
|
|
407
|
+
// Direct advisory lookup by GHSA ID
|
|
408
|
+
apiUrl = `${GITHUB_API_BASE}/advisories/${encodeURIComponent(term)}`;
|
|
409
|
+
} else {
|
|
410
|
+
// Keyword: fetch recent published advisories and filter client-side
|
|
411
|
+
apiUrl = `${GITHUB_API_BASE}/advisories?per_page=${maxResults}&state=published&direction=desc`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const response = await retryWithBackoff(async (): Promise<Response> => {
|
|
415
|
+
const resp = await fetch(apiUrl, {
|
|
416
|
+
headers: {
|
|
417
|
+
'User-Agent': 'pi-research/2.0',
|
|
418
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
419
|
+
},
|
|
420
|
+
signal: createTimeoutSignal(30000),
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
if (resp.ok === false) {
|
|
424
|
+
if (resp.status === 404) {
|
|
425
|
+
throw new Error('Advisory not found (HTTP 404)');
|
|
426
|
+
}
|
|
427
|
+
if (resp.status === 403) {
|
|
428
|
+
throw new Error('GitHub API rate limit exceeded (HTTP 403). Retrying with backoff...');
|
|
429
|
+
}
|
|
430
|
+
if (resp.status >= 500) {
|
|
431
|
+
throw new Error(`GitHub server error (HTTP ${resp.status}). Retrying with backoff...`);
|
|
432
|
+
}
|
|
433
|
+
throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return resp;
|
|
437
|
+
}, {
|
|
438
|
+
maxRetries: 2,
|
|
439
|
+
initialDelay: 1000,
|
|
440
|
+
maxDelay: 5000,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const data = await response.json();
|
|
444
|
+
let items: readonly GitHubAdvisoryRaw[] = [];
|
|
445
|
+
|
|
446
|
+
// GHSA direct lookup returns an object; list endpoints return arrays
|
|
447
|
+
if (isGitHubAdvisoryArray(data)) {
|
|
448
|
+
items = data;
|
|
449
|
+
} else if (isSingleGitHubAdvisory(data)) {
|
|
450
|
+
items = [data];
|
|
451
|
+
} else if (isGitHubAdvisoryListResponse(data) && isArray(data.items)) {
|
|
452
|
+
items = data.items.filter(isGitHubAdvisoryRaw);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
termResults.push(...items.map(mapGitHubAdvisory));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Deduplicate by GHSA ID
|
|
459
|
+
const seen = new Set<string>();
|
|
460
|
+
for (const adv of termResults) {
|
|
461
|
+
if (!seen.has(adv.id)) {
|
|
462
|
+
seen.add(adv.id);
|
|
463
|
+
allAdvisories.push(adv);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Filter by search terms if provided (OR logic: match any term)
|
|
469
|
+
if (terms.length > 0) {
|
|
470
|
+
allAdvisories = allAdvisories.filter((adv): boolean =>
|
|
471
|
+
terms.some((term): boolean => {
|
|
472
|
+
const t = term.toLowerCase();
|
|
473
|
+
const advId = adv.id.toLowerCase();
|
|
474
|
+
const advSummary = adv.summary.toLowerCase();
|
|
475
|
+
const advDescription = adv.description !== undefined
|
|
476
|
+
? adv.description.toLowerCase()
|
|
477
|
+
: '';
|
|
478
|
+
const advCveId = adv.cveId !== undefined
|
|
479
|
+
? adv.cveId.toLowerCase()
|
|
480
|
+
: '';
|
|
481
|
+
|
|
482
|
+
return (
|
|
483
|
+
advId.includes(t) ||
|
|
484
|
+
advCveId.includes(t) ||
|
|
485
|
+
advSummary.includes(t) ||
|
|
486
|
+
advDescription.includes(t)
|
|
487
|
+
);
|
|
488
|
+
}),
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Filter by severity if provided
|
|
493
|
+
// GitHub uses "MODERATE" where the rest of the industry uses "MEDIUM"
|
|
494
|
+
if (options?.severity !== undefined && options.severity !== '') {
|
|
495
|
+
const severity = options.severity.toUpperCase();
|
|
496
|
+
const githubSeverity = severity === 'MEDIUM' ? 'MODERATE' : severity;
|
|
497
|
+
allAdvisories = allAdvisories.filter((adv): boolean =>
|
|
498
|
+
adv.severity === githubSeverity,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
advisories.push(...allAdvisories.slice(0, maxResults));
|
|
503
|
+
|
|
504
|
+
} catch (err: unknown) {
|
|
505
|
+
error = err instanceof Error ? err.message : String(err);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return {
|
|
509
|
+
count: advisories.length,
|
|
510
|
+
advisories,
|
|
511
|
+
error,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Get specific advisory by GHSA ID or CVE ID
|
|
517
|
+
*
|
|
518
|
+
* @param id - The GHSA ID or CVE ID to fetch
|
|
519
|
+
* @returns Promise resolving to Advisory or null if not found
|
|
520
|
+
*/
|
|
521
|
+
export async function getAdvisoryById(id: string): Promise<Advisory | null> {
|
|
522
|
+
try {
|
|
523
|
+
if (id === '') {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const url = `${GITHUB_API_BASE}/advisories/${encodeURIComponent(id)}`;
|
|
528
|
+
|
|
529
|
+
const response = await retryWithBackoff(async (): Promise<Response> => {
|
|
530
|
+
const resp = await fetch(url, {
|
|
531
|
+
headers: {
|
|
532
|
+
'User-Agent': 'pi-research/2.0',
|
|
533
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
534
|
+
},
|
|
535
|
+
signal: createTimeoutSignal(30000),
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
if (resp.ok === false) {
|
|
539
|
+
if (resp.status === 404) {
|
|
540
|
+
throw new Error('Advisory not found (HTTP 404)');
|
|
541
|
+
}
|
|
542
|
+
if (resp.status === 403) {
|
|
543
|
+
throw new Error('GitHub API rate limit exceeded (HTTP 403). Retrying with backoff...');
|
|
544
|
+
}
|
|
545
|
+
if (resp.status >= 500) {
|
|
546
|
+
throw new Error(`GitHub server error (HTTP ${resp.status}). Retrying with backoff...`);
|
|
547
|
+
}
|
|
548
|
+
throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return resp;
|
|
552
|
+
}, {
|
|
553
|
+
maxRetries: 2,
|
|
554
|
+
initialDelay: 1000,
|
|
555
|
+
maxDelay: 5000,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const data = await response.json();
|
|
559
|
+
|
|
560
|
+
if (isGitHubAdvisoryRaw(data)) {
|
|
561
|
+
return mapGitHubAdvisory(data);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return null;
|
|
565
|
+
} catch (err: unknown) {
|
|
566
|
+
// Check if it's a 404 error (not found) - return null instead of throwing
|
|
567
|
+
if (err instanceof Error && err.message.includes('HTTP 404')) {
|
|
568
|
+
logger.warn(`[GitHub Advisories] Advisory ${id} not found`);
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
logger.error(`[GitHub Advisories] Error fetching advisory ${id}:`, err);
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Map GitHub advisory to our Advisory interface
|
|
578
|
+
*
|
|
579
|
+
* @param item - Raw GitHub advisory object
|
|
580
|
+
* @returns Mapped Advisory object
|
|
581
|
+
*/
|
|
582
|
+
function mapGitHubAdvisory(item: GitHubAdvisoryRaw): Advisory {
|
|
583
|
+
const ghsaId = item.ghsa_id ?? item.id ?? '';
|
|
584
|
+
const summary = item.summary ?? '';
|
|
585
|
+
const description = item.description ?? '';
|
|
586
|
+
const severityRaw = item.severity ?? 'UNKNOWN';
|
|
587
|
+
const severity = severityRaw.toUpperCase();
|
|
588
|
+
const published = item.published_at ?? '';
|
|
589
|
+
const modified = item.updated_at ?? '';
|
|
590
|
+
const cveId = item.cve_id ?? '';
|
|
591
|
+
|
|
592
|
+
const affectedPackages = extractAffectedPackages(item);
|
|
593
|
+
const references = extractReferences(item);
|
|
594
|
+
|
|
595
|
+
return {
|
|
596
|
+
id: ghsaId,
|
|
597
|
+
source: 'github',
|
|
598
|
+
severity,
|
|
599
|
+
summary,
|
|
600
|
+
description,
|
|
601
|
+
published,
|
|
602
|
+
modified,
|
|
603
|
+
cveId,
|
|
604
|
+
references,
|
|
605
|
+
affectedPackages,
|
|
606
|
+
};
|
|
607
|
+
}
|