Skip to content

Commit 57011a8

Browse files
authored
Merge pull request #7023 from JSONbored/fix/lockfile-drift-osv-per-item-fallback
fix(rees): add per-item OSV fallback to lockfile-drift batch queries
2 parents bd4c172 + 75a6e0a commit 57011a8

2 files changed

Lines changed: 178 additions & 2 deletions

File tree

review-enrichment/src/analyzers/lockfile-drift.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const MAX_LOCKFILE_FILES = 12;
5454
const MAX_PATCH_LINES_PER_FILE = 1200;
5555
const MAX_OSV_QUERIES = 40;
5656
const LOCKFILE_OSV_BATCH_CHUNK_SIZE = 10;
57+
const LOCKFILE_OSV_QUERY_MAX_BYTES = 512 * 1024;
5758
const VERSION_SAFE_RE = /^[0-9][0-9A-Za-z._+-]*$/;
5859
const MAX_PACKAGE_LEN = 200;
5960
const MAX_VERSION_LEN = 100;
@@ -384,6 +385,44 @@ export function extractLockfileChanges(
384385
return changes;
385386
}
386387

388+
/** Single-item OSV.dev fallback for a lockfile resolution whose batch chunk failed. Mirrors
389+
* dependency-scan.ts's fetchOsvDirect so a transient/oversized batch response degrades to per-item
390+
* queries instead of silently dropping the whole chunk's findings. */
391+
async function fetchOsvDirect(
392+
ecosystem: string,
393+
name: string,
394+
version: string,
395+
fetchImpl: typeof fetch,
396+
signal: AbortSignal | undefined,
397+
options: Pick<ScanOptions, "analysis" | "diagnostics" | "limits">,
398+
): Promise<Cve[]> {
399+
if (signal?.aborted) return [];
400+
const fetchOptions = {
401+
endpointCategory: "osv-query",
402+
method: "POST",
403+
headers: { "content-type": "application/json" },
404+
body: JSON.stringify({ package: { name, ecosystem }, version }),
405+
signal,
406+
fetchImpl,
407+
diagnostics: options.diagnostics,
408+
phase: "lockfile-drift",
409+
subcall: "osv-query",
410+
maxBytes: LOCKFILE_OSV_QUERY_MAX_BYTES,
411+
maxCallsPerCategory: options.limits?.maxOsvQueries ?? MAX_OSV_QUERIES,
412+
};
413+
const response = options.analysis
414+
? await options.analysis.fetchJson<{ vulns?: OsvVuln[] }>(
415+
"https://api.osv.dev/v1/query",
416+
fetchOptions,
417+
)
418+
: await boundedFetchJson<{ vulns?: OsvVuln[] }>(
419+
"https://api.osv.dev/v1/query",
420+
fetchOptions,
421+
);
422+
if (!response.ok) return [];
423+
return toCves(response.data.vulns);
424+
}
425+
387426
/** Batch-query OSV.dev for lockfile resolutions. Best-effort: returns empty CVE arrays on any failure. */
388427
export async function queryOsvBatch(
389428
changes: LockfileChange[],
@@ -422,7 +461,20 @@ export async function queryOsvBatch(
422461
: await boundedFetchJson<{
423462
results?: Array<{ vulns?: OsvVuln[] }>;
424463
}>("https://api.osv.dev/v1/querybatch", fetchOptions);
425-
if (!response.ok) continue;
464+
if (!response.ok) {
465+
for (const change of chunk) {
466+
const cves = await fetchOsvDirect(
467+
change.ecosystem,
468+
change.package,
469+
change.to,
470+
fetchImpl,
471+
signal,
472+
options,
473+
);
474+
results.set(`${change.ecosystem}::${change.package}@${change.to}`, cves);
475+
}
476+
continue;
477+
}
426478
chunk.forEach((change, index) => {
427479
results.set(
428480
`${change.ecosystem}::${change.package}@${change.to}`,

review-enrichment/test/lockfile-drift.test.ts

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import { test } from "node:test";
22
import assert from "node:assert/strict";
33

4-
import { extractLockfileChanges } from "../dist/analyzers/lockfile-drift.js";
4+
import { extractLockfileChanges, queryOsvBatch } from "../dist/analyzers/lockfile-drift.js";
5+
import { createAnalysisContext } from "../dist/analysis-context.js";
6+
7+
const jsonResponse = (body, init = {}) =>
8+
new Response(JSON.stringify(body), {
9+
status: 200,
10+
headers: { "content-type": "application/json" },
11+
...init,
12+
});
513

614
test("extractLockfileChanges matches lockfile basenames case-insensitively", () => {
715
const changes = extractLockfileChanges([
@@ -252,3 +260,119 @@ test("extractLockfileChanges skips malformed/partial lockfile hunks rather than
252260

253261
assert.deepEqual(changes, []);
254262
});
263+
264+
test("queryOsvBatch falls back to per-item OSV queries when a batch chunk fails, instead of dropping the whole chunk", async () => {
265+
// Mirrors dependency-scan.ts's own batch-failure fallback (scanDependencyChanges falls back to direct
266+
// OSV queries after an oversized batch response, test/analysis-context.test.ts): a failed /v1/querybatch
267+
// chunk must degrade to per-change /v1/query calls, not silently drop every finding in the chunk.
268+
let batchCalls = 0;
269+
const directPackages = [];
270+
const fetchImpl = async (url, init = {}) => {
271+
if (String(url) === "https://api.osv.dev/v1/querybatch") {
272+
batchCalls += 1;
273+
return new Response("Internal Server Error", { status: 500 });
274+
}
275+
assert.equal(String(url), "https://api.osv.dev/v1/query");
276+
const body = JSON.parse(String(init.body));
277+
directPackages.push(body.package.name);
278+
return jsonResponse({
279+
vulns:
280+
body.package.name === "lodash"
281+
? [
282+
{
283+
id: "GHSA-lockfile-fallback",
284+
summary: "lockfile drift fallback advisory",
285+
database_specific: { severity: "HIGH" },
286+
},
287+
]
288+
: [],
289+
});
290+
};
291+
const changes = [
292+
{ file: "package-lock.json", line: 5, ecosystem: "npm", package: "lodash", from: "4.17.20", to: "4.17.21" },
293+
{ file: "package-lock.json", line: 9, ecosystem: "npm", package: "axios", from: "1.6.0", to: "1.6.1" },
294+
];
295+
296+
const cvesByKey = await queryOsvBatch(changes, fetchImpl);
297+
298+
assert.equal(batchCalls, 1);
299+
assert.deepEqual(directPackages, ["lodash", "axios"]);
300+
assert.equal(cvesByKey.size, 2);
301+
const lodashCves = cvesByKey.get("npm::lodash@4.17.21");
302+
assert.equal(lodashCves?.length, 1);
303+
assert.equal(lodashCves?.[0]?.id, "GHSA-lockfile-fallback");
304+
assert.deepEqual(cvesByKey.get("npm::axios@1.6.1"), []);
305+
});
306+
307+
test("queryOsvBatch's per-item fallback still returns empty (never throws) when the direct query also fails", async () => {
308+
const fetchImpl = async () => new Response("Internal Server Error", { status: 500 });
309+
const changes = [
310+
{ file: "package-lock.json", line: 5, ecosystem: "npm", package: "lodash", from: "4.17.20", to: "4.17.21" },
311+
];
312+
313+
const cvesByKey = await queryOsvBatch(changes, fetchImpl);
314+
315+
assert.deepEqual(cvesByKey.get("npm::lodash@4.17.21"), []);
316+
});
317+
318+
test("queryOsvBatch's per-item fallback routes through the request-scoped analysis context (cache/metrics), honoring a custom maxOsvQueries limit", async () => {
319+
const context = createAnalysisContext({
320+
repoFullName: "JSONbored/loopover",
321+
prNumber: 1810,
322+
});
323+
let batchCalls = 0;
324+
const directPackages = [];
325+
const fetchImpl = async (url, init = {}) => {
326+
if (String(url) === "https://api.osv.dev/v1/querybatch") {
327+
batchCalls += 1;
328+
return new Response("Internal Server Error", { status: 500 });
329+
}
330+
assert.equal(String(url), "https://api.osv.dev/v1/query");
331+
const body = JSON.parse(String(init.body));
332+
directPackages.push(body.package.name);
333+
return jsonResponse({ vulns: [] });
334+
};
335+
const changes = [
336+
{ file: "package-lock.json", line: 5, ecosystem: "npm", package: "lodash", from: "4.17.20", to: "4.17.21" },
337+
];
338+
339+
const cvesByKey = await queryOsvBatch(changes, fetchImpl, undefined, {
340+
analysis: context,
341+
limits: { maxOsvQueries: 5 },
342+
});
343+
344+
assert.equal(batchCalls, 1);
345+
assert.deepEqual(directPackages, ["lodash"]);
346+
assert.deepEqual(cvesByKey.get("npm::lodash@4.17.21"), []);
347+
assert.deepEqual(context.snapshotMetrics().externalCallsByCategory, {
348+
"osv-querybatch": 1,
349+
"osv-query": 1,
350+
});
351+
});
352+
353+
test("queryOsvBatch's per-item fallback stops issuing direct queries once the signal is aborted mid-chunk", async () => {
354+
const controller = new AbortController();
355+
const directPackages = [];
356+
const fetchImpl = async (url, init = {}) => {
357+
if (String(url) === "https://api.osv.dev/v1/querybatch") {
358+
return new Response("Internal Server Error", { status: 500 });
359+
}
360+
const body = JSON.parse(String(init.body));
361+
directPackages.push(body.package.name);
362+
// Abort AFTER the first per-item request is issued but before the fallback loop reaches the
363+
// second change -- exercises fetchOsvDirect's own `if (signal?.aborted) return [];` guard.
364+
controller.abort();
365+
return jsonResponse({ vulns: [] });
366+
};
367+
const changes = [
368+
{ file: "package-lock.json", line: 5, ecosystem: "npm", package: "lodash", from: "4.17.20", to: "4.17.21" },
369+
{ file: "package-lock.json", line: 9, ecosystem: "npm", package: "axios", from: "1.6.0", to: "1.6.1" },
370+
];
371+
372+
const cvesByKey = await queryOsvBatch(changes, fetchImpl, controller.signal);
373+
374+
assert.deepEqual(directPackages, ["lodash"]);
375+
assert.deepEqual(cvesByKey.get("npm::lodash@4.17.21"), []);
376+
assert.equal(cvesByKey.has("npm::axios@1.6.1"), true);
377+
assert.deepEqual(cvesByKey.get("npm::axios@1.6.1"), []);
378+
});

0 commit comments

Comments
 (0)