diff --git a/extensions/package-vulnerability-scanner/main.py b/extensions/package-vulnerability-scanner/main.py index 7164ddfb..43dd3dba 100644 --- a/extensions/package-vulnerability-scanner/main.py +++ b/extensions/package-vulnerability-scanner/main.py @@ -120,6 +120,14 @@ def _upstream_error(message: str, exc: Exception) -> HTTPException: # deployment doesn't produce an unwieldy payload. PPM_QUERY_CHUNK = 100 +# How many times to try a Package Manager request before giving up, so a +# transient network or server blip doesn't fail the whole scan. +PPM_MAX_ATTEMPTS = 3 + +# Bound how many Package Manager requests run at once so a large scan does not +# overload the public service. +PPM_QUERY_CONCURRENCY = 5 + # How many times to try a Connect API call before giving up, so a transient 5xx # (e.g. a 504 while Connect is under load) doesn't fail the whole scan. CONTENT_MAX_ATTEMPTS = 3 @@ -177,6 +185,21 @@ async def _fetch_with_retry(fn, timeout=None): raise +# Like asyncio.gather, but if one coroutine raises, cancel the rest and wait for +# them to actually stop before propagating, instead of leaving them running +# in the background with their results discarded (and, for callers that close a +# resource like an httpx.AsyncClient right after gathering, out from under them). +async def _gather_and_cancel_on_error(*aws): + tasks = [asyncio.ensure_future(aw) for aw in aws] + try: + return await asyncio.gather(*tasks) + except Exception: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + raise + + @app.get("/api/content") async def search_content(request: Request, show_all: bool = False): try: @@ -262,7 +285,7 @@ async def fetch_one(guid: str) -> tuple[str, dict]: "error": "Couldn't read this content's packages.", } - pairs = await asyncio.gather(*(fetch_one(guid) for guid in guids)) + pairs = await _gather_and_cancel_on_error(*(fetch_one(guid) for guid in guids)) return dict(pairs) @@ -304,6 +327,7 @@ async def get_vulnerabilities(installed: InstalledPackages): # which only flags packages whose latest version is vulnerable and so misses # older deployed versions that were patched later. results = {} + ppm_semaphore = asyncio.Semaphore(PPM_QUERY_CONCURRENCY) async def fetch_repo_vulns(repo, specifiers): # name -> {vuln id -> vuln}; the same package may be requested at @@ -312,29 +336,67 @@ async def fetch_repo_vulns(repo, specifiers): specs = sorted(set(specifiers)) if not specs: return repo, {} - async with httpx.AsyncClient() as http: - for start in range(0, len(specs), PPM_QUERY_CHUNK): - payload = { - "repo": repo, - "names": specs[start : start + PPM_QUERY_CHUNK], - "omit_downloads": True, - "omit_dependencies": True, - } - response = await http.post(PPM_URL, json=payload) - response.raise_for_status() - for line in response.text.strip().split("\n"): - if not line: - continue - found = json.loads(line) - for vuln in found.get("vulns") or []: - merged.setdefault(found["name"], {})[vuln["id"]] = vuln + chunks = [ + specs[start : start + PPM_QUERY_CHUNK] + for start in range(0, len(specs), PPM_QUERY_CHUNK) + ] + + async def fetch_chunk(http, names): + payload = { + "repo": repo, + "names": names, + "omit_downloads": True, + "omit_dependencies": True, + } + # Retry a transient failure (network error or 5xx) with a short + # backoff so one blip against the public service doesn't fail the + # whole scan. A 4xx or a persistent failure still raises, so the scan + # fails loudly rather than silently under-reporting vulnerabilities. + async with ppm_semaphore: + for attempt in range(PPM_MAX_ATTEMPTS): + try: + response = await http.post(PPM_URL, json=payload) + response.raise_for_status() + return response.text + except httpx.HTTPStatusError as e: + if ( + e.response.status_code < 500 + or attempt == PPM_MAX_ATTEMPTS - 1 + ): + raise + except httpx.TransportError: + if attempt == PPM_MAX_ATTEMPTS - 1: + raise + await asyncio.sleep(0.5 * (attempt + 1)) + + # Query the chunks in parallel so a large repo doesn't pay one round-trip + # after another. The timeout is generous enough for a full chunk yet still + # bounds a stalled request so the scan can't hang. + async with httpx.AsyncClient(timeout=30.0) as http: + texts = await _gather_and_cancel_on_error( + *(fetch_chunk(http, chunk) for chunk in chunks) + ) + for text in texts: + for line in text.strip().split("\n"): + if not line: + continue + found = json.loads(line) + for vuln in found.get("vulns") or []: + merged.setdefault(found["name"], {})[vuln["id"]] = vuln return repo, {name: list(v.values()) for name, v in merged.items()} - for repo, data in await asyncio.gather( - fetch_repo_vulns("pypi", installed.pypi), - fetch_repo_vulns("cran", installed.cran), - ): - results[repo] = data + # A failure (after retries) is reported so the scan fails loudly rather than + # silently under-reporting vulnerabilities, matching the packages endpoint. + try: + for repo, data in await _gather_and_cancel_on_error( + fetch_repo_vulns("pypi", installed.pypi), + fetch_repo_vulns("cran", installed.cran), + ): + results[repo] = data + except Exception as e: + raise _upstream_error( + "Couldn't fetch vulnerabilities from Package Manager.", e + ) return results diff --git a/extensions/package-vulnerability-scanner/manifest.json b/extensions/package-vulnerability-scanner/manifest.json index 881f813f..580f0ec3 100644 --- a/extensions/package-vulnerability-scanner/manifest.json +++ b/extensions/package-vulnerability-scanner/manifest.json @@ -20,17 +20,17 @@ }, "packages": {}, "files": { - "dist/assets/index-Bd7WoAqv.css": { - "checksum": "980b7708188eee5f84707dcc6cf3b01d" + "dist/assets/index-CKUnK_gI.css": { + "checksum": "910900dfbf81f7ac4caf125b844a495c" }, - "dist/assets/index-BEWOoybn.js": { - "checksum": "da5ddbd1d9affbb66e1449a6f1e9392b" + "dist/assets/index-CgkWwSXn.js": { + "checksum": "31a12fd9c6e9d80d692a587a0bc20ec5" }, "dist/index.html": { - "checksum": "1a6d2fe30d21376a0668ad781de538e6" + "checksum": "d8f8de453602d81829635911a04d612d" }, "main.py": { - "checksum": "8697dad866d91d30ec56cf62707bc817" + "checksum": "ea913b00aaac84531ee46021969f4eaa" }, "requirements.txt": { "checksum": "9fc5cc5fb559eded1f323793b73e82c5" diff --git a/extensions/package-vulnerability-scanner/src/components/ContentList.vue b/extensions/package-vulnerability-scanner/src/components/ContentList.vue index c7d18151..4ae8ae7c 100644 --- a/extensions/package-vulnerability-scanner/src/components/ContentList.vue +++ b/extensions/package-vulnerability-scanner/src/components/ContentList.vue @@ -3,11 +3,12 @@ import { computed, ref, watch } from "vue"; import { storeToRefs } from "pinia"; import { usePackagesStore } from "../stores/packages"; -import { useVulnsStore, type InstalledPackages } from "../stores/vulns"; +import { useVulnsStore } from "../stores/vulns"; import { useContentStore } from "../stores/content"; import { useScannerStore } from "../stores/scanner"; import { useUserStore } from "../stores/user"; import type { User } from "../stores/user"; +import { collectInstalledPackages } from "../lib/collectInstalledPackages"; import StatusMessage from "./ui/StatusMessage.vue"; import SkeletonText from "./ui/SkeletonText.vue"; import BadgeTabs, { type Tab } from "./ui/BadgeTabs.vue"; @@ -76,27 +77,15 @@ async function fetchPackagesInBatches(batchSize = 3) { // Packages are loaded; look up vulnerabilities for the exact installed // versions. Skip on a plain remount where nothing new was fetched. if (contentToFetch.length > 0 || !vulnStore.isFetched) { - await vulnStore.fetchVulns(collectInstalledPackages()); + await vulnStore.fetchVulns( + collectInstalledPackages( + contentStore.contentList, + packagesStore.contentItems, + ), + ); } } -// Gather unique installed packages as "name==version", grouped by repo. -function collectInstalledPackages(): InstalledPackages { - const pypi = new Set(); - const cran = new Set(); - for (const item of Object.values(packagesStore.contentItems)) { - for (const pkg of item.packages) { - const language = pkg.language.toLowerCase(); - if (language === "python") { - pypi.add(`${pkg.name}==${pkg.version}`); - } else if (language === "r") { - cran.add(`${pkg.name}==${pkg.version}`); - } - } - } - return { pypi: [...pypi], cran: [...cran] }; -} - fetchPackagesInBatches(); const tabs = computed(() => { diff --git a/extensions/package-vulnerability-scanner/src/lib/collectInstalledPackages.test.ts b/extensions/package-vulnerability-scanner/src/lib/collectInstalledPackages.test.ts new file mode 100644 index 00000000..a22d6dc3 --- /dev/null +++ b/extensions/package-vulnerability-scanner/src/lib/collectInstalledPackages.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; + +import { collectInstalledPackages } from "./collectInstalledPackages"; +import type { ContentListItem } from "../stores/content"; +import type { ContentPackages, Package } from "../stores/packages"; + +function content(guid: string): ContentListItem { + return { guid, title: guid }; +} + +function pkg(name: string, version: string, language: string): Package { + return { name, version, language, hash: null }; +} + +function item(guid: string, packages: Package[]): ContentPackages { + return { + guid, + packages, + isLoading: false, + error: null, + isFetched: true, + lastFetchTime: null, + }; +} + +describe("collectInstalledPackages", () => { + it("groups python into pypi and r into cran as name==version", () => { + const result = collectInstalledPackages([content("g1")], { + g1: item("g1", [pkg("flask", "2.0", "python"), pkg("dplyr", "1.1", "r")]), + }); + + expect(result.pypi).toEqual(["flask==2.0"]); + expect(result.cran).toEqual(["dplyr==1.1"]); + }); + + it("dedupes the same specifier across content items", () => { + const result = collectInstalledPackages([content("g1"), content("g2")], { + g1: item("g1", [pkg("flask", "2.0", "python")]), + g2: item("g2", [pkg("flask", "2.0", "python")]), + }); + + expect(result.pypi).toEqual(["flask==2.0"]); + }); + + it("treats language case-insensitively", () => { + const result = collectInstalledPackages([content("g1")], { + g1: item("g1", [pkg("flask", "2.0", "Python"), pkg("dplyr", "1.1", "R")]), + }); + + expect(result.pypi).toEqual(["flask==2.0"]); + expect(result.cran).toEqual(["dplyr==1.1"]); + }); + + it("ignores languages that are neither python nor r", () => { + const result = collectInstalledPackages([content("g1")], { + g1: item("g1", [pkg("some-lib", "1.0", "javascript")]), + }); + + expect(result).toEqual({ pypi: [], cran: [] }); + }); + + it("ignores cached packages for content not in the visible list", () => { + // g2 has packages cached but is not in the list, so its packages must not + // leak into the query. + const result = collectInstalledPackages([content("g1")], { + g1: item("g1", [pkg("flask", "2.0", "python")]), + g2: item("g2", [pkg("django", "3.0", "python")]), + }); + + expect(result.pypi).toEqual(["flask==2.0"]); + }); + + it("skips content in the list that has no packages entry yet", () => { + const result = collectInstalledPackages([content("g1")], {}); + expect(result).toEqual({ pypi: [], cran: [] }); + }); +}); diff --git a/extensions/package-vulnerability-scanner/src/lib/collectInstalledPackages.ts b/extensions/package-vulnerability-scanner/src/lib/collectInstalledPackages.ts new file mode 100644 index 00000000..a9818c4f --- /dev/null +++ b/extensions/package-vulnerability-scanner/src/lib/collectInstalledPackages.ts @@ -0,0 +1,27 @@ +import type { ContentListItem } from "../stores/content"; +import type { ContentPackages } from "../stores/packages"; +import type { InstalledPackages } from "../stores/vulns"; + +// Gather unique installed packages as "name==version", grouped by repo, across +// the content currently in view. Content not in the list (cached packages for +// hidden content) is ignored, so the vuln lookup matches what's on screen. +export function collectInstalledPackages( + contentList: ContentListItem[], + contentItems: Record, +): InstalledPackages { + const pypi = new Set(); + const cran = new Set(); + for (const content of contentList) { + const item = contentItems[content.guid]; + if (!item) continue; + for (const pkg of item.packages) { + const language = pkg.language.toLowerCase(); + if (language === "python") { + pypi.add(`${pkg.name}==${pkg.version}`); + } else if (language === "r") { + cran.add(`${pkg.name}==${pkg.version}`); + } + } + } + return { pypi: [...pypi], cran: [...cran] }; +} diff --git a/extensions/package-vulnerability-scanner/src/stores/vulns.test.ts b/extensions/package-vulnerability-scanner/src/stores/vulns.test.ts new file mode 100644 index 00000000..8b26798b --- /dev/null +++ b/extensions/package-vulnerability-scanner/src/stores/vulns.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { setActivePinia, createPinia } from "pinia"; + +import { useVulnsStore, type Vulnerability } from "./vulns"; + +function vuln( + id: string, + version: string, + fixed: string | null, +): Vulnerability { + return { + id, + versions: { [version]: {} }, + ranges: fixed + ? [{ type: "ECOSYSTEM", events: [{ introduced: "0" }, { fixed }] }] + : [], + summary: "", + details: "", + modified: "", + published: "", + }; +} + +beforeEach(() => { + setActivePinia(createPinia()); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("vulns store getDetailsForPackageVersion", () => { + it("returns vulnerabilities and the fixed version for a matching version", () => { + const store = useVulnsStore(); + store.pypi = { flask: [vuln("CVE-1", "1.0", "2.1")] }; + + const result = store.getDetailsForPackageVersion("flask", "1.0", "pypi"); + + expect(result.vulnerabilities.map((v) => v.id)).toEqual(["CVE-1"]); + expect(result.latestFixedVersion).toBe("2.1"); + }); + + it("returns nothing for a version that is not vulnerable", () => { + const store = useVulnsStore(); + store.pypi = { flask: [vuln("CVE-1", "1.0", "2.1")] }; + + const result = store.getDetailsForPackageVersion("flask", "9.9", "pypi"); + + expect(result.vulnerabilities).toEqual([]); + expect(result.latestFixedVersion).toBeNull(); + }); + + it("picks the numerically-latest fixed version across vulns", () => { + const store = useVulnsStore(); + store.pypi = { + flask: [vuln("CVE-1", "1.0", "2.1"), vuln("CVE-2", "1.0", "2.10")], + }; + + const result = store.getDetailsForPackageVersion("flask", "1.0", "pypi"); + + expect(result.vulnerabilities).toHaveLength(2); + // "2.10" is later than "2.1" by numeric comparison, not string comparison. + expect(result.latestFixedVersion).toBe("2.10"); + }); + + it("returns a null fixed version when no range provides one", () => { + const store = useVulnsStore(); + store.pypi = { flask: [vuln("CVE-1", "1.0", null)] }; + + const result = store.getDetailsForPackageVersion("flask", "1.0", "pypi"); + + expect(result.vulnerabilities).toHaveLength(1); + expect(result.latestFixedVersion).toBeNull(); + }); + + it("keeps looking past a range that carries no fix", () => { + // Advisories often list a GIT range with only an "introduced" event before + // the ECOSYSTEM range that names the fix. Stopping at the first fixless + // range would report the fix as unknown. + const store = useVulnsStore(); + store.pypi = { + flask: [ + { + id: "CVE-1", + versions: { "1.0": {} }, + ranges: [ + { type: "GIT", events: [{ introduced: "0" }] }, + { + type: "ECOSYSTEM", + events: [{ introduced: "0" }, { fixed: "2.5" }], + }, + ], + summary: "", + details: "", + modified: "", + published: "", + }, + ], + }; + + const result = store.getDetailsForPackageVersion("flask", "1.0", "pypi"); + + expect(result.latestFixedVersion).toBe("2.5"); + }); + + it("prefers an ECOSYSTEM fix over one from another range type", () => { + const store = useVulnsStore(); + store.pypi = { + flask: [ + { + id: "CVE-1", + versions: { "1.0": {} }, + ranges: [ + { type: "GIT", events: [{ fixed: "abc123" }] }, + { type: "ECOSYSTEM", events: [{ fixed: "2.5" }] }, + ], + summary: "", + details: "", + modified: "", + published: "", + }, + ], + }; + + const result = store.getDetailsForPackageVersion("flask", "1.0", "pypi"); + + expect(result.latestFixedVersion).toBe("2.5"); + }); + + it("reads cran vulnerabilities from the cran map", () => { + const store = useVulnsStore(); + store.cran = { dplyr: [vuln("CVE-9", "1.0", "1.1")] }; + + const result = store.getDetailsForPackageVersion("dplyr", "1.0", "cran"); + + expect(result.vulnerabilities.map((v) => v.id)).toEqual(["CVE-9"]); + }); +}); + +describe("vulns store fetchVulns", () => { + it("stores results and marks fetched on success", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ pypi: { flask: [] }, cran: {} }), + })), + ); + const store = useVulnsStore(); + + await store.fetchVulns({ pypi: ["flask==2.0"], cran: [] }); + + expect(store.isFetched).toBe(true); + expect(store.isLoading).toBe(false); + expect(store.error).toBeNull(); + expect(store.pypi).toHaveProperty("flask"); + }); + + it("records an error without throwing on a failed request", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 502 })), + ); + const store = useVulnsStore(); + + await store.fetchVulns({ pypi: ["flask==2.0"], cran: [] }); + + expect(store.error).not.toBeNull(); + expect(store.isFetched).toBe(false); + expect(store.isLoading).toBe(false); + }); +}); diff --git a/extensions/package-vulnerability-scanner/src/stores/vulns.ts b/extensions/package-vulnerability-scanner/src/stores/vulns.ts index 21809ceb..484b580f 100644 --- a/extensions/package-vulnerability-scanner/src/stores/vulns.ts +++ b/extensions/package-vulnerability-scanner/src/stores/vulns.ts @@ -1,6 +1,8 @@ import { defineStore } from "pinia"; import { ref } from "vue"; +import { errorDetail } from "../lib/errorDetail"; + export interface VulnerabilityEvent { introduced?: string; fixed?: string; @@ -57,7 +59,12 @@ export const useVulnsStore = defineStore("vulns", () => { }); if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); + throw new Error( + await errorDetail( + response, + "Couldn't reach Posit Package Manager to check for vulnerabilities", + ), + ); } const data = await response.json(); @@ -91,11 +98,14 @@ export const useVulnsStore = defineStore("vulns", () => { }; for (const range of vuln.ranges) { - if (range.type === "ECOSYSTEM" && range.events) { - return getFixedEventValue(range); - } else { - result = getFixedEventValue(range); + const fixed = getFixedEventValue(range); + if (!fixed) continue; + // Prefer a fix from an ECOSYSTEM (semantic-version) range; otherwise remember + // any fix as a fallback and keep looking for an ECOSYSTEM one. + if (range.type === "ECOSYSTEM") { + return fixed; } + result = fixed; } return result; diff --git a/extensions/package-vulnerability-scanner/test_main.py b/extensions/package-vulnerability-scanner/test_main.py index 6ef337bf..0e99fc63 100644 --- a/extensions/package-vulnerability-scanner/test_main.py +++ b/extensions/package-vulnerability-scanner/test_main.py @@ -5,6 +5,7 @@ # The client makes no network call at construction; every test replaces `client` # or `get_visitor_client` with a mock, so no request ever leaves the process. import asyncio +import json import os from unittest.mock import MagicMock, PropertyMock @@ -28,6 +29,54 @@ async def noop_sleep(*args, **kwargs): pass +class _Resp: + def __init__(self, text): + self.text = text + + def raise_for_status(self): + pass + + +class FakeHttpx: + # Stand-in for httpx.AsyncClient used as an async context manager. `handler` + # maps a request payload to a response body (str); returning or raising an + # Exception simulates a failed request. Records every call for assertions. + def __init__(self, handler): + self.handler = handler + self.calls = 0 + self.payloads = [] + + def __call__(self, *args, **kwargs): + return self + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def post(self, url, json): + self.calls += 1 + self.payloads.append(json) + result = self.handler(json) + if isinstance(result, Exception): + raise result + return _Resp(result) + + +def scripted(items): + # A handler that returns or raises each item in turn, for retry-sequence tests. + it = iter(items) + return lambda payload: next(it) + + +def make_status_error(status): + request = httpx.Request("POST", main.PPM_URL) + return httpx.HTTPStatusError( + "err", request=request, response=httpx.Response(status, request=request) + ) + + @pytest.fixture def api(): return TestClient(main.app) @@ -278,6 +327,40 @@ def test_client_sessions_carry_the_timeout_adapter(): ) for prefix in ("http://", "https://"): assert isinstance(visitor.session.adapters[prefix], main._TimeoutAdapter) +# --- _gather_and_cancel_on_error -------------------------------------------- + + +def test_gather_and_cancel_on_error_returns_all_results(): + async def ok(value): + return value + + result = asyncio.run(main._gather_and_cancel_on_error(ok(1), ok(2), ok(3))) + assert result == [1, 2, 3] + + +def test_gather_and_cancel_on_error_cancels_siblings_on_failure(): + cancelled = [] + + async def fails_fast(): + raise ValueError("boom") + + async def runs_until_cancelled(): + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + cancelled.append(1) + raise + + async def run(): + with pytest.raises(ValueError): + await main._gather_and_cancel_on_error( + fails_fast(), runs_until_cancelled() + ) + + asyncio.run(run()) + # The still-running sibling was cancelled (and awaited) rather than left to + # keep running in the background with its result discarded. + assert cancelled == [1] # --- get_visitor_client ---------------------------------------------------- @@ -672,3 +755,119 @@ def test_packages_endpoint_non_client_error_is_502(monkeypatch, api): assert resp.status_code == 502 assert resp.json()["detail"] == "Couldn't fetch packages from Connect." + + +# --- /api/vulns -------------------------------------------------------------- + + +def test_vulns_endpoint_merges_vulns_by_id(monkeypatch, api): + # Two records for the same package (different versions) must merge into one + # list of vulns, deduped by id; the empty cran repo makes no request. + ndjson = ( + '{"name": "flask", "vulns": [{"id": "CVE-1"}]}\n' + '{"name": "flask", "vulns": [{"id": "CVE-1"}, {"id": "CVE-2"}]}' + ) + fake = FakeHttpx(lambda payload: ndjson) + monkeypatch.setattr(main.httpx, "AsyncClient", fake) + + resp = api.post("/api/vulns", json={"pypi": ["flask==2.0"], "cran": []}) + + assert resp.status_code == 200 + data = resp.json() + assert {v["id"] for v in data["pypi"]["flask"]} == {"CVE-1", "CVE-2"} + assert data["cran"] == {} + assert fake.calls == 1 # cran empty -> no request + + +def test_vulns_endpoint_retries_5xx_then_succeeds(monkeypatch, api): + monkeypatch.setattr(main.asyncio, "sleep", noop_sleep) + ndjson = '{"name": "flask", "vulns": [{"id": "CVE-1"}]}' + fake = FakeHttpx(scripted([make_status_error(503), ndjson])) + monkeypatch.setattr(main.httpx, "AsyncClient", fake) + + resp = api.post("/api/vulns", json={"pypi": ["flask==2.0"], "cran": []}) + + assert resp.status_code == 200 + assert resp.json()["pypi"]["flask"][0]["id"] == "CVE-1" + assert fake.calls == 2 # one 5xx, one retry that succeeded + + +def test_vulns_endpoint_retries_transport_error_then_succeeds(monkeypatch, api): + monkeypatch.setattr(main.asyncio, "sleep", noop_sleep) + ndjson = '{"name": "flask", "vulns": [{"id": "CVE-1"}]}' + fake = FakeHttpx(scripted([httpx.ConnectError("connection failed"), ndjson])) + monkeypatch.setattr(main.httpx, "AsyncClient", fake) + + resp = api.post("/api/vulns", json={"pypi": ["flask==2.0"], "cran": []}) + + assert resp.status_code == 200 + assert fake.calls == 2 + + +def test_vulns_endpoint_does_not_retry_4xx(monkeypatch, api): + monkeypatch.setattr(main.asyncio, "sleep", noop_sleep) + fake = FakeHttpx(scripted([make_status_error(404)])) + monkeypatch.setattr(main.httpx, "AsyncClient", fake) + + # A 4xx is not retried and is not swallowed: it surfaces with its real status + # rather than silently under-reporting vulnerabilities or being folded into + # a generic 502. + resp = api.post("/api/vulns", json={"pypi": ["flask==2.0"], "cran": []}) + assert resp.status_code == 404 + assert "Package Manager" in resp.json()["detail"] + assert fake.calls == 1 + + +def test_vulns_endpoint_gives_up_after_max_attempts(monkeypatch, api): + monkeypatch.setattr(main.asyncio, "sleep", noop_sleep) + fake = FakeHttpx(scripted([make_status_error(503)] * main.PPM_MAX_ATTEMPTS)) + monkeypatch.setattr(main.httpx, "AsyncClient", fake) + + resp = api.post("/api/vulns", json={"pypi": ["flask==2.0"], "cran": []}) + assert resp.status_code == 502 + assert fake.calls == main.PPM_MAX_ATTEMPTS + + +def test_vulns_endpoint_splits_large_query_into_chunks(monkeypatch, api): + # More than PPM_QUERY_CHUNK specifiers must be split across parallel requests, + # and every request must carry the omit flags the scan relies on. + specs = [f"pkg{i}==1.0" for i in range(main.PPM_QUERY_CHUNK + 50)] + + def echo(payload): + return "\n".join( + json.dumps({"name": n, "vulns": [{"id": f"V-{n}"}]}) + for n in payload["names"] + ) + + fake = FakeHttpx(echo) + monkeypatch.setattr(main.httpx, "AsyncClient", fake) + + resp = api.post("/api/vulns", json={"pypi": specs, "cran": []}) + + assert resp.status_code == 200 + assert len(resp.json()["pypi"]) == len(specs) + assert fake.calls == 2 # 100 + 50 -> two chunks + assert all( + p["repo"] == "pypi" and p["omit_downloads"] and p["omit_dependencies"] + for p in fake.payloads + ) + + +def test_vulns_endpoint_skips_blank_and_vulnless_records(monkeypatch, api): + # A blank line, a null "vulns", and a missing "vulns" key are all ignored; + # only packages with actual vulns appear in the result. + ndjson = "\n".join( + [ + '{"name": "a", "vulns": null}', + "", + '{"name": "b"}', + '{"name": "c", "vulns": [{"id": "X"}]}', + ] + ) + fake = FakeHttpx(lambda payload: ndjson) + monkeypatch.setattr(main.httpx, "AsyncClient", fake) + + resp = api.post("/api/vulns", json={"pypi": ["a==1", "b==1", "c==1"], "cran": []}) + + assert resp.status_code == 200 + assert resp.json()["pypi"] == {"c": [{"id": "X"}]}