From 1b850d3f6ca90954426048ebc730608a1332159b Mon Sep 17 00:00:00 2001 From: Sean Turner Date: Sat, 1 Aug 2026 23:17:00 +0100 Subject: [PATCH 1/4] feat(report): opt-in deterministic version-range check for dependency findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dependency-CVE false positive is a factual question — is the installed version actually in the advisory's affected range? — not a code-reasoning one. Today create_dependency_report files whatever the agent passes; an agent can report a CVE against a version that's already patched past the range, or mis-attribute a CVE to the wrong package. This adds an opt-in deterministic check (no LLM) right before the dependency report is persisted, as a sibling of the existing dedup-reject: ask an advisory provider which advisories affect the exact installed version; if the cited CVE/GHSA isn't among them, reject the finding as out-of-range. - strix/report/dep_verify.py: AdvisoryProvider protocol + OsvProvider (OSV.dev /v1/query). No new dependency — uses requests (already core). - Provider-pluggable via DepVerifySettings: STRIX_DEP_VERIFY (default off), STRIX_DEP_VERIFY_PROVIDER (osv|none), STRIX_OSV_URL (point at a self-hosted OSV mirror for air-gapped / data-residency deployments — identical query contract). - Fail-open + asymmetric (never suppress a real finding): rejects ONLY on a definitive, non-empty provider answer that omits the cited advisory. Provider unreachable/error, empty result (coverage gap), missing fields, non-CVE/GHSA id, unknown ecosystem -> emit. - Off by default; when disabled the dependency path is unchanged. Tests inject a fake provider (no network) + cover provider resolution and the OSV response parse. 16 tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- strix/config/settings.py | 27 +++++ strix/report/dep_verify.py | 184 ++++++++++++++++++++++++++++++++++ strix/tools/reporting/tool.py | 20 ++++ tests/test_dep_verify.py | 157 +++++++++++++++++++++++++++++ 4 files changed, 388 insertions(+) create mode 100644 strix/report/dep_verify.py create mode 100644 tests/test_dep_verify.py diff --git a/strix/config/settings.py b/strix/config/settings.py index e53d125ce..9c00a46b3 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -71,6 +71,32 @@ class DedupeSettings(BaseSettings): ) +class DepVerifySettings(BaseSettings): + """Deterministic dependency version-range verification (report/dep_verify.py). + + A dependency-CVE false positive is a FACTUAL question — is the installed + version actually in the advisory's affected range? — not a code-reasoning one. + So this is a deterministic check (no LLM): before a dependency report is + persisted, ask an advisory provider which advisories affect the exact + installed version; if the cited CVE/GHSA isn't among them, the finding is out + of range and gets rejected. OFF by default; opt-in. + + PROVIDER-PLUGGABLE — not everyone can/will call a hosted advisory API + (air-gapped scans, data-residency rules, private advisory DBs). ``provider``: + "osv" -> query an OSV-schema API (default https://api.osv.dev; override + ``osv_url`` for a self-hosted OSV mirror — same /v1/query contract). + "none" -> disabled (== enabled=False). + Fail-open throughout: any uncertainty emits the finding (never suppress a real + one on a provider hiccup / coverage gap). + """ + + model_config = _BASE_CONFIG + + enabled: bool = Field(default=False, alias="STRIX_DEP_VERIFY") + provider: str = Field(default="osv", alias="STRIX_DEP_VERIFY_PROVIDER") + osv_url: str = Field(default="https://api.osv.dev/v1/query", alias="STRIX_OSV_URL") + + class ContextSettings(BaseSettings): """Context-window management: per-tool-output caps and history compaction.""" @@ -134,6 +160,7 @@ class Settings(BaseSettings): llm: LlmSettings = Field(default_factory=LlmSettings) dedupe: DedupeSettings = Field(default_factory=DedupeSettings) + dep_verify: DepVerifySettings = Field(default_factory=DepVerifySettings) runtime: RuntimeSettings = Field(default_factory=RuntimeSettings) context: ContextSettings = Field(default_factory=ContextSettings) telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings) diff --git a/strix/report/dep_verify.py b/strix/report/dep_verify.py new file mode 100644 index 000000000..48c207fc7 --- /dev/null +++ b/strix/report/dep_verify.py @@ -0,0 +1,184 @@ +"""Deterministic version-range verify for dependency-CVE findings. + +The code-sink verifier (report/verify.py) answers a REASONING question — is the +sink reachable + the control complete. A dependency-CVE false positive is a +different, FACTUAL question: is the installed version actually in the advisory's +vulnerable range? That's deterministic — a range check, no LLM — and more reliable +than any model for this sub-class (version-range containment is exact). This is +the "promote to a deterministic control" pattern. + +FP shape this catches: an agent files "CVE-XXXX in pkg@1.2.3" but 1.2.3 is OUT of +the advisory's affected range (already patched, or the CVE was mis-attributed to +the package). The provider is asked "which advisories affect pkg@version"; if the +finding's CVE/GHSA isn't among them, the installed version is not vulnerable → reject. + +PROVIDER-PLUGGABLE (Strix is global FOSS — not everyone can/will call a hosted +advisory API: air-gapped scans, data-residency rules, orgs with their own advisory +DB). The provider is selected by DepVerifySettings.provider: + "osv" -> query an OSV-schema endpoint (default api.osv.dev; override + STRIX_OSV_URL for a self-hosted OSV mirror — identical /v1/query + contract, so no code change, just the URL). + "none" -> disabled. +An AdvisoryProvider returns the set of advisory ids/aliases affecting a given +package@version, or None when it cannot answer (→ fail-open, emit). + +SAFETY (fail-open, asymmetric — never suppress a real dep finding): + - reject ONLY when the provider gives a definitive, non-empty answer AND the + finding's CVE/GHSA is provably absent from it; + - any uncertainty EMITS: provider disabled/unreachable/errors, package/version/ + ecosystem unparseable, non-CVE/GHSA id, empty result (coverage gap), or the + provider lists the CVE → return None (emit). + +No LLM. No extra package (OSV provider uses `requests`, already a core dep). +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Protocol + +import requests + +from strix.config import load_settings + + +logger = logging.getLogger(__name__) + +# Map common ecosystem strings (as agents/trivy emit them) to OSV's canonical set. +_ECOSYSTEM = { + "npm": "npm", "node": "npm", "javascript": "npm", "yarn": "npm", + "pypi": "PyPI", "pip": "PyPI", "python": "PyPI", + "go": "Go", "golang": "Go", "gomod": "Go", + "maven": "Maven", "java": "Maven", "gradle": "Maven", + "cargo": "crates.io", "rust": "crates.io", "crates.io": "crates.io", + "rubygems": "RubyGems", "ruby": "RubyGems", "gem": "RubyGems", + "nuget": "NuGet", "composer": "Packagist", "packagist": "Packagist", +} + + +def _norm_ecosystem(eco: str | None) -> str | None: + return _ECOSYSTEM.get((eco or "").strip().lower()) + + +def _ids_for(vuln: dict) -> set[str]: + """All identifiers an OSV advisory is known by (its id + aliases), upper-cased.""" + ids = {str(vuln.get("id", "")).upper()} + ids.update(str(a).upper() for a in (vuln.get("aliases") or [])) + return {i for i in ids if i} + + +class AdvisoryProvider(Protocol): + """Given a package coordinate, return the set of advisory ids/aliases (upper- + cased, e.g. {'CVE-...','GHSA-...'}) affecting THAT exact version — or None when + the provider cannot answer (→ caller fails open and emits the finding). + + An empty set means a definitive "no advisories affect this version"; None means + "don't know" (unreachable / error / not covered). The distinction matters: the + caller only rejects on a definitive, non-empty answer that omits the cited id.""" + + def affecting(self, pkg: str, ecosystem: str, version: str) -> set[str] | None: ... + + +class OsvProvider: + """AdvisoryProvider backed by an OSV-schema /v1/query endpoint. Default + api.osv.dev; point `url` at a self-hosted OSV mirror (identical contract) for + air-gapped / data-residency deployments. Uses `requests` (a core dep) — no + extra package.""" + + def __init__(self, url: str) -> None: + self.url = url + + def affecting(self, pkg: str, ecosystem: str, version: str) -> set[str] | None: + try: + ca = os.environ.get("REQUESTS_CA_BUNDLE") + resp = requests.post( + self.url, timeout=20, + json={"package": {"name": pkg, "ecosystem": ecosystem}, "version": version}, + verify=ca if ca else True, + ) + if resp.status_code != 200: + logger.info("dep-verify: OSV %s for %s@%s; can't answer", + resp.status_code, pkg, version) + return None + vulns = resp.json().get("vulns", []) or [] + except Exception: # noqa: BLE001 — advisory; a hiccup must never suppress a finding + logger.info("dep-verify: OSV query failed for %s@%s; can't answer", + pkg, version, exc_info=True) + return None + affecting: set[str] = set() + for v in vulns: + affecting |= _ids_for(v) + return affecting + + +def _resolve_provider(dep_settings: Any) -> AdvisoryProvider | None: + """Build the AdvisoryProvider from DepVerifySettings. None => disabled/unknown + provider => the check is a no-op (fail-open).""" + provider = (getattr(dep_settings, "provider", "osv") or "").strip().lower() + if provider in ("", "none", "off"): + return None + if provider == "osv": + return OsvProvider(getattr(dep_settings, "osv_url", "https://api.osv.dev/v1/query")) + logger.info("dep-verify: unknown provider %r; check disabled", provider) + return None + + +def verify_dependency( # noqa: PLR0911 — the returns are deliberate fail-open guard clauses + candidate: dict[str, Any], provider: AdvisoryProvider | None = None +) -> dict[str, Any] | None: + """Deterministically check a dep-CVE finding's version is in the CVE's range. + + candidate needs: cve (or ghsa), package_name, installed_version, package_ecosystem. + `provider` overrides the settings-resolved one (used by tests); when omitted it + is built from DepVerifySettings. Returns a REJECT dict when the installed + version is provably NOT affected by the cited advisory; None (emit) on any + uncertainty (fail-open).""" + cve = str(candidate.get("cve") or "").strip().upper() + ghsa = str(candidate.get("ghsa") or "").strip().upper() + ident = cve or ghsa + pkg = str(candidate.get("package_name") or "").strip() + version = str(candidate.get("installed_version") or "").strip() + eco = _norm_ecosystem(candidate.get("package_ecosystem")) + + # Need all four to make a definitive call; otherwise emit. + if not (ident and pkg and version and eco): + return None + # Only CVE/GHSA identifiers are resolvable by advisory providers. + if not ident.startswith(("CVE-", "GHSA-")): + return None + + if provider is None: + provider = _resolve_provider(load_settings().dep_verify) + if provider is None: + return None # disabled / unknown provider → emit + + affecting = provider.affecting(pkg, eco, version) + if affecting is None: + return None # provider couldn't answer → fail open, emit + if ident in affecting: + return None # confirmed: installed version IS in the CVE's range → emit (real) + if not affecting: + # definitive "no advisories affect this version" is still a coverage-gap + # risk (private/vendored pkg, provider lag) → fail open, emit. + logger.info("dep-verify: provider lists NO advisories for %s@%s; emitting " + "(coverage gap, not a confident FP)", pkg, version) + return None + + # Provider knows advisories for this version but NOT the cited one → the + # installed version is out of the cited CVE's range → false positive. + return { + "success": False, + "error": ( + f"Version-range verify rejected this dependency finding: {ident} does " + f"not affect {pkg}@{version} per the advisory provider (installed " + f"version is outside the advisory's vulnerable range — likely already " + f"patched or the CVE is mis-attributed to this package). Advisories " + f"affecting {pkg}@{version}: {sorted(affecting)}. If you believe the " + f"version IS vulnerable, cite the exact affected range." + ), + "verify_rejected": True, + "dep_version_out_of_range": True, + "installed_version": version, + "cited_advisory": ident, + } diff --git a/strix/tools/reporting/tool.py b/strix/tools/reporting/tool.py index b7bb18850..11f93b5ac 100644 --- a/strix/tools/reporting/tool.py +++ b/strix/tools/reporting/tool.py @@ -820,6 +820,26 @@ async def _do_create_dependency( # noqa: PLR0912 "reason": dedupe.get("reason", ""), } + # Deterministic version-range verify (opt-in, STRIX_DEP_VERIFY). A + # dependency-CVE false positive is a factual question — is the installed + # version actually in the advisory's affected range? — so it's checked + # against an advisory provider (OSV by default), no LLM. Fail-open: only + # rejects when the provider proves the version is OUT of the cited CVE's + # range. Sibling of the dedup-reject above. + from strix.config import load_settings + if load_settings().dep_verify.enabled: + from strix.report.dep_verify import verify_dependency + + dep_verdict = verify_dependency({ + "cve": parsed_cve, "package_name": package_name, + "installed_version": installed_version, + "package_ecosystem": package_ecosystem, + }) + if dep_verdict is not None: + logger.info("dep-verify rejected %s %s@%s (version out of range)", + parsed_cve, package_name, installed_version) + return dep_verdict + report_id = report_state.add_vulnerability_report( title=title, description=description, diff --git a/tests/test_dep_verify.py b/tests/test_dep_verify.py new file mode 100644 index 000000000..fd875fcc4 --- /dev/null +++ b/tests/test_dep_verify.py @@ -0,0 +1,157 @@ +"""Deterministic dependency version-range verify (provider-pluggable, OSV default). + +Load-bearing behaviours (a SAFE FP reducer for dep-CVE findings): + 1. In-range -> emit (real). Provider lists the CVE for the installed version. + 2. Out-of-range -> REJECT. Provider knows advisories for the version but NOT the + cited CVE (installed version outside the range / mis-attributed). + 3. Fail-open everywhere: provider can't answer (None), empty set (coverage gap), + missing fields, non-CVE/GHSA id, unknown ecosystem, disabled provider -> emit. + Never suppress a real dep finding on uncertainty. + 4. Provider-pluggable: OSV default, custom URL for mirrors, 'none' disables. +""" + +from __future__ import annotations + +import requests + +from strix.report import dep_verify as dv + + +def _cand(**kw): + base = {"cve": "CVE-2021-23337", "package_name": "lodash", + "installed_version": "4.17.20", "package_ecosystem": "npm"} + base.update(kw) + return base + + +class _FakeProvider: + """Returns a fixed affecting-set (or None for can't-answer).""" + def __init__(self, affecting): + self._a = affecting + + def affecting(self, _pkg, _ecosystem, _version): + return self._a + + +def _prov(affecting): + return _FakeProvider(affecting) + + +# --- verdict logic (explicit provider, no network) --- + +def test_in_range_emits(): + # provider lists the cited CVE as affecting this version -> real, emit. + assert dv.verify_dependency(_cand(), _prov({"GHSA-X", "CVE-2021-23337"})) is None + + +def test_out_of_range_rejects(): + # provider knows advisories for this version but NOT the cited CVE -> out of range. + out = dv.verify_dependency(_cand(), _prov({"GHSA-OTHER", "CVE-2020-0000"})) + assert out is not None + assert out["verify_rejected"] is True + assert out["dep_version_out_of_range"] is True + assert "CVE-2021-23337" in out["error"] + + +def test_empty_set_fails_open(): + # definitive "no advisories affect this version" -> coverage-gap risk -> emit. + assert dv.verify_dependency(_cand(), _prov(set())) is None + + +def test_provider_cant_answer_fails_open(): + # None = unreachable/error/not-covered -> emit. + assert dv.verify_dependency(_cand(), _prov(None)) is None + + +def test_matches_via_alias_or_id(): + assert dv.verify_dependency(_cand(cve="", ghsa="GHSA-JF85-CPCP-J695"), + _prov({"GHSA-JF85-CPCP-J695"})) is None + + +def test_missing_fields_emit(): + for miss in ("cve", "package_name", "installed_version", "package_ecosystem"): + c = _cand() + c[miss] = "" + if miss == "cve": + c["ghsa"] = "" + # provider that WOULD reject, to prove the guard short-circuits before it + assert dv.verify_dependency(c, _prov({"CVE-9999-0000"})) is None, miss + + +def test_unknown_ecosystem_emits(): + assert dv.verify_dependency(_cand(package_ecosystem="cocoapods-weird"), + _prov({"CVE-9999-0000"})) is None + + +def test_non_cve_identifier_emits(): + assert dv.verify_dependency(_cand(cve="RUSTSEC-2021-0001"), + _prov({"CVE-9999-0000"})) is None + + +# --- ecosystem normalisation --- + +def test_ecosystem_normalization(): + assert dv._norm_ecosystem("PyPI") == "PyPI" + assert dv._norm_ecosystem("pip") == "PyPI" + assert dv._norm_ecosystem("golang") == "Go" + assert dv._norm_ecosystem("node") == "npm" + assert dv._norm_ecosystem("bogus") is None + + +# --- provider resolution (the pluggable toggle) --- + +class _S: + def __init__(self, provider="osv", osv_url="https://api.osv.dev/v1/query"): + self.provider = provider + self.osv_url = osv_url + + +def test_resolve_provider_osv_default(): + p = dv._resolve_provider(_S()) + assert isinstance(p, dv.OsvProvider) + assert p.url == "https://api.osv.dev/v1/query" + + +def test_resolve_provider_custom_mirror_url(): + p = dv._resolve_provider(_S(osv_url="https://osv.internal.corp/v1/query")) + assert isinstance(p, dv.OsvProvider) + assert p.url == "https://osv.internal.corp/v1/query" + + +def test_resolve_provider_none_disables(): + assert dv._resolve_provider(_S(provider="none")) is None + assert dv._resolve_provider(_S(provider="")) is None + + +def test_resolve_provider_unknown_disables(): + assert dv._resolve_provider(_S(provider="snyk")) is None + + +def test_osv_provider_parses_query(monkeypatch): + # OsvProvider.affecting: mock requests.post, assert it flattens ids+aliases. + class _R: + status_code = 200 + + def json(self): + return {"vulns": [{"id": "GHSA-a", "aliases": ["CVE-2021-23337"]}, + {"id": "GHSA-b"}]} + monkeypatch.setattr(requests, "post", lambda *_a, **_k: _R()) + got = dv.OsvProvider("https://api.osv.dev/v1/query").affecting("lodash", "npm", "4.17.20") + assert got == {"GHSA-A", "CVE-2021-23337", "GHSA-B"} + + +def test_osv_provider_non200_returns_none(monkeypatch): + class _R: + status_code = 503 + + def json(self): + return {} + monkeypatch.setattr(requests, "post", lambda *_a, **_k: _R()) + assert dv.OsvProvider("u").affecting("p", "npm", "1.0") is None + + +def test_osv_provider_error_returns_none(monkeypatch): + def _boom(*_a, **_k): + raise requests.RequestException("timeout") + monkeypatch.setattr(requests, "post", _boom) + assert dv.OsvProvider("u").affecting("p", "npm", "1.0") is None From 8af0d81418f239a0cb227729ff4fc971e90a2003 Mon Sep 17 00:00:00 2001 From: Sean Turner Date: Mon, 3 Aug 2026 08:28:27 +0100 Subject: [PATCH 2/4] chore(report): log the confirmed-in-range emit so the check is observable in scan logs An in-range (real) finding was emitted silently, giving a scan log no evidence the version-range check ran. Add an INFO line on the confirmed-emit path, mirroring the reject / coverage-gap logs. --- strix/report/dep_verify.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/strix/report/dep_verify.py b/strix/report/dep_verify.py index 48c207fc7..8d5b8b504 100644 --- a/strix/report/dep_verify.py +++ b/strix/report/dep_verify.py @@ -157,6 +157,8 @@ def verify_dependency( # noqa: PLR0911 — the returns are deliberate fail-open if affecting is None: return None # provider couldn't answer → fail open, emit if ident in affecting: + logger.info("dep-verify: %s confirmed in range for %s@%s; emitting (real)", + ident, pkg, version) return None # confirmed: installed version IS in the CVE's range → emit (real) if not affecting: # definitive "no advisories affect this version" is still a coverage-gap From d963bbd9ef3c581d0c1692cab46ddf0448f47e6b Mon Sep 17 00:00:00 2001 From: Sean Turner Date: Mon, 3 Aug 2026 09:42:00 +0100 Subject: [PATCH 3/4] feat(report): reject a cited CVE when the package is known but the version is clean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refine the empty-result handling: when the provider returns no advisories for the installed version, distinguish two cases via a new AdvisoryProvider.knows_package (version-less query): - provider KNOWS the package (advisories on other versions) but none affect this version -> CONFIDENT out-of-range -> reject (e.g. minimist 1.2.6 cited for CVE-2021-44906, which was fixed in 1.2.6 — OSV knows minimist, 0 affect 1.2.6). - provider doesn't know the package at all -> genuine coverage gap (private / vendored / provider lag) -> still fail-open, emit. Without this the most common dependency false positive — a real CVE cited against a version that's already patched past its range — slipped through as a 'coverage gap'. Surfaced by a live scan: a weak model, coerced into over-claiming patched minimist CVEs, had them emitted; now they're correctly rejected while the genuinely in-range lodash CVEs still emit. Also refactor OsvProvider to a shared _query helper (used by both affecting and knows_package). 18 tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- strix/report/dep_verify.py | 68 +++++++++++++++++++++++++++----------- tests/test_dep_verify.py | 35 ++++++++++++++++---- 2 files changed, 76 insertions(+), 27 deletions(-) diff --git a/strix/report/dep_verify.py b/strix/report/dep_verify.py index 8d5b8b504..ae7ce8652 100644 --- a/strix/report/dep_verify.py +++ b/strix/report/dep_verify.py @@ -79,6 +79,13 @@ class AdvisoryProvider(Protocol): def affecting(self, pkg: str, ecosystem: str, version: str) -> set[str] | None: ... + def knows_package(self, pkg: str, ecosystem: str) -> bool | None: + """Does the provider have ANY advisory for this package (any version)? + Lets the caller tell a CONFIDENT out-of-range (provider knows the package + but nothing affects the installed version) from a COVERAGE GAP (provider + doesn't know the package at all → fail open). None = can't determine.""" + ... + class OsvProvider: """AdvisoryProvider backed by an OSV-schema /v1/query endpoint. Default @@ -89,28 +96,38 @@ class OsvProvider: def __init__(self, url: str) -> None: self.url = url - def affecting(self, pkg: str, ecosystem: str, version: str) -> set[str] | None: + def _query(self, payload: dict[str, Any]) -> list[dict] | None: try: ca = os.environ.get("REQUESTS_CA_BUNDLE") - resp = requests.post( - self.url, timeout=20, - json={"package": {"name": pkg, "ecosystem": ecosystem}, "version": version}, - verify=ca if ca else True, - ) + resp = requests.post(self.url, timeout=20, json=payload, + verify=ca if ca else True) if resp.status_code != 200: - logger.info("dep-verify: OSV %s for %s@%s; can't answer", - resp.status_code, pkg, version) + logger.info("dep-verify: OSV %s for %s; can't answer", + resp.status_code, payload.get("package")) return None - vulns = resp.json().get("vulns", []) or [] + return resp.json().get("vulns", []) or [] except Exception: # noqa: BLE001 — advisory; a hiccup must never suppress a finding - logger.info("dep-verify: OSV query failed for %s@%s; can't answer", - pkg, version, exc_info=True) + logger.info("dep-verify: OSV query failed for %s; can't answer", + payload.get("package"), exc_info=True) + return None + + def affecting(self, pkg: str, ecosystem: str, version: str) -> set[str] | None: + vulns = self._query( + {"package": {"name": pkg, "ecosystem": ecosystem}, "version": version}) + if vulns is None: return None affecting: set[str] = set() for v in vulns: affecting |= _ids_for(v) return affecting + def knows_package(self, pkg: str, ecosystem: str) -> bool | None: + # version-less query returns every advisory for the package (any version). + vulns = self._query({"package": {"name": pkg, "ecosystem": ecosystem}}) + if vulns is None: + return None + return len(vulns) > 0 + def _resolve_provider(dep_settings: Any) -> AdvisoryProvider | None: """Build the AdvisoryProvider from DepVerifySettings. None => disabled/unknown @@ -161,14 +178,25 @@ def verify_dependency( # noqa: PLR0911 — the returns are deliberate fail-open ident, pkg, version) return None # confirmed: installed version IS in the CVE's range → emit (real) if not affecting: - # definitive "no advisories affect this version" is still a coverage-gap - # risk (private/vendored pkg, provider lag) → fail open, emit. - logger.info("dep-verify: provider lists NO advisories for %s@%s; emitting " - "(coverage gap, not a confident FP)", pkg, version) - return None + # Nothing affects the installed version. Disambiguate: + # - provider KNOWS the package (has advisories for OTHER versions) but + # none hit this version → CONFIDENT out-of-range → reject. + # - provider doesn't know the package at all (private/vendored, lag) → + # genuine coverage gap → fail open, emit. + knows = None + try: + knows = provider.knows_package(pkg, eco) + except Exception: # noqa: BLE001 — advisory + knows = None + if not knows: # False (unknown pkg) or None (can't tell) → fail open + logger.info("dep-verify: provider has no advisories for %s at all; " + "emitting (coverage gap, not a confident FP)", pkg) + return None + # else: package is known, this version is clean → fall through to reject. - # Provider knows advisories for this version but NOT the cited one → the - # installed version is out of the cited CVE's range → false positive. + # Provider knows advisories for this version but NOT the cited one (or knows + # the package and NOTHING affects this version) → the installed version is out + # of the cited CVE's range → false positive. return { "success": False, "error": ( @@ -176,8 +204,8 @@ def verify_dependency( # noqa: PLR0911 — the returns are deliberate fail-open f"not affect {pkg}@{version} per the advisory provider (installed " f"version is outside the advisory's vulnerable range — likely already " f"patched or the CVE is mis-attributed to this package). Advisories " - f"affecting {pkg}@{version}: {sorted(affecting)}. If you believe the " - f"version IS vulnerable, cite the exact affected range." + f"affecting {pkg}@{version}: {sorted(affecting) or 'none'}. If you " + f"believe the version IS vulnerable, cite the exact affected range." ), "verify_rejected": True, "dep_version_out_of_range": True, diff --git a/tests/test_dep_verify.py b/tests/test_dep_verify.py index fd875fcc4..8cebfe98e 100644 --- a/tests/test_dep_verify.py +++ b/tests/test_dep_verify.py @@ -25,16 +25,21 @@ def _cand(**kw): class _FakeProvider: - """Returns a fixed affecting-set (or None for can't-answer).""" - def __init__(self, affecting): + """Returns a fixed affecting-set (None for can't-answer). `knows` is what + knows_package returns (True=package known, False=unknown, None=can't tell).""" + def __init__(self, affecting, knows=None): self._a = affecting + self._knows = knows def affecting(self, _pkg, _ecosystem, _version): return self._a + def knows_package(self, _pkg, _ecosystem): + return self._knows -def _prov(affecting): - return _FakeProvider(affecting) + +def _prov(affecting, knows=None): + return _FakeProvider(affecting, knows) # --- verdict logic (explicit provider, no network) --- @@ -53,9 +58,25 @@ def test_out_of_range_rejects(): assert "CVE-2021-23337" in out["error"] -def test_empty_set_fails_open(): - # definitive "no advisories affect this version" -> coverage-gap risk -> emit. - assert dv.verify_dependency(_cand(), _prov(set())) is None +def test_empty_set_unknown_package_fails_open(): + # empty affecting + provider doesn't know the package (knows=False) -> + # genuine coverage gap (private/vendored) -> emit. + assert dv.verify_dependency(_cand(), _prov(set(), knows=False)) is None + + +def test_empty_set_cant_tell_fails_open(): + # empty affecting + knows_package can't answer (None) -> fail open, emit. + assert dv.verify_dependency(_cand(), _prov(set(), knows=None)) is None + + +def test_empty_set_known_package_rejects(): + # empty affecting BUT provider KNOWS the package (advisories on other versions) + # -> confident out-of-range -> REJECT. The minimist@1.2.6 case a live scan of a + # weak model exposed: OSV knows minimist but 0 advisories affect 1.2.6. + out = dv.verify_dependency(_cand(), _prov(set(), knows=True)) + assert out is not None + assert out["verify_rejected"] is True + assert out["dep_version_out_of_range"] is True def test_provider_cant_answer_fails_open(): From 43a2886c6e90d102a2a22a0478a9565e492aa412 Mon Sep 17 00:00:00 2001 From: Sean Turner Date: Mon, 3 Aug 2026 10:29:49 +0100 Subject: [PATCH 4/4] fix(dep-verify): run the advisory check off the event loop (Greptile P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify_dependency does blocking HTTP (requests.post) and was called directly from the async create_dependency_report path — a slow/unreachable provider would block the shared agent event loop for the request timeout, stalling every concurrent agent (worst case two sequential calls on the clean-version path). - Offload the call via asyncio.to_thread so the event loop stays free. - Tighten the provider timeout 20s -> 10s (named OsvProvider._TIMEOUT_S): a single advisory lookup shouldn't hold a worker thread that long, and it fails open fast. - Add an async test asserting the verifier runs on a worker thread (not the main/ event-loop thread) and the reject still propagates. Addresses the Greptile review comment on #963. 38 report/dep tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- strix/report/dep_verify.py | 10 +++++++-- strix/tools/reporting/tool.py | 6 ++++- tests/test_reporting_fields.py | 41 ++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/strix/report/dep_verify.py b/strix/report/dep_verify.py index ae7ce8652..31c6eefd5 100644 --- a/strix/report/dep_verify.py +++ b/strix/report/dep_verify.py @@ -91,7 +91,13 @@ class OsvProvider: """AdvisoryProvider backed by an OSV-schema /v1/query endpoint. Default api.osv.dev; point `url` at a self-hosted OSV mirror (identical contract) for air-gapped / data-residency deployments. Uses `requests` (a core dep) — no - extra package.""" + extra package. + + The caller runs this off the event loop (asyncio.to_thread), but keep the + per-request timeout short so a stalled provider fails open quickly rather than + holding a worker thread — this is a bounded advisory lookup, not bulk I/O.""" + + _TIMEOUT_S = 10 def __init__(self, url: str) -> None: self.url = url @@ -99,7 +105,7 @@ class OsvProvider: def _query(self, payload: dict[str, Any]) -> list[dict] | None: try: ca = os.environ.get("REQUESTS_CA_BUNDLE") - resp = requests.post(self.url, timeout=20, json=payload, + resp = requests.post(self.url, timeout=self._TIMEOUT_S, json=payload, verify=ca if ca else True) if resp.status_code != 200: logger.info("dep-verify: OSV %s for %s; can't answer", diff --git a/strix/tools/reporting/tool.py b/strix/tools/reporting/tool.py index 11f93b5ac..1596130bb 100644 --- a/strix/tools/reporting/tool.py +++ b/strix/tools/reporting/tool.py @@ -830,7 +830,11 @@ async def _do_create_dependency( # noqa: PLR0912 if load_settings().dep_verify.enabled: from strix.report.dep_verify import verify_dependency - dep_verdict = verify_dependency({ + # verify_dependency does blocking HTTP (advisory provider). Run it in a + # worker thread so a slow/unreachable provider can't stall the shared + # async agent loop (it would otherwise block every concurrent agent for + # up to the provider timeout, x2 on the clean-version path). + dep_verdict = await asyncio.to_thread(verify_dependency, { "cve": parsed_cve, "package_name": package_name, "installed_version": installed_version, "package_ecosystem": package_ecosystem, diff --git a/tests/test_reporting_fields.py b/tests/test_reporting_fields.py index 79fa4e9a5..f1f1c7e02 100644 --- a/tests/test_reporting_fields.py +++ b/tests/test_reporting_fields.py @@ -2,10 +2,14 @@ from __future__ import annotations +import threading +import time from typing import TYPE_CHECKING import pytest +import strix.report.dep_verify as depv +from strix.config import loader from strix.report.dedupe import ( _check_dependency_duplicate, _prepare_report_for_comparison, @@ -192,6 +196,43 @@ async def test_dependency_report_with_zero_cvss_remains_low_severity( assert report["cvss"] == 0.0 +async def test_dependency_verify_runs_off_the_event_loop( + report_state: ReportState, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The verifier does blocking HTTP; it must be offloaded (asyncio.to_thread) + so a slow/unreachable provider can't stall the shared async agent loop. Stub + verify_dependency with a blocking call that records its thread + rejects, and + assert (a) the reject propagates and (b) it ran on a NON-main thread.""" + monkeypatch.setenv("STRIX_DEP_VERIFY", "1") + loader._cached = None + + main_thread = threading.current_thread() + ran_on: dict[str, object] = {} + + def _blocking_verify(_candidate: dict) -> dict: + ran_on["thread"] = threading.current_thread() + time.sleep(0.05) # simulate a slow provider (blocking) + return {"success": False, "verify_rejected": True, + "dep_version_out_of_range": True, "error": "out of range"} + + monkeypatch.setattr(depv, "verify_dependency", _blocking_verify) + + result = await _do_create_dependency( + title="CVE-2021-44906 in minimist 1.2.6", + description="Prototype pollution.", target="repo/package.json", + cve="CVE-2021-44906", package_name="minimist", installed_version="1.2.6", + impact="x", remediation_steps="upgrade", assumptions="a", + package_ecosystem="npm", fixed_version=None, cwe=None, + advisory_cvss=7.5, technical_analysis=None, fix_effort="low", + ) + # reject propagated + finding NOT persisted + assert result["verify_rejected"] is True + assert not report_state.vulnerability_reports + # ran off the event loop (a worker thread, not the main thread) + assert ran_on["thread"] is not main_thread + + async def test_dependency_report_requires_advisory_cvss(report_state: ReportState) -> None: result = await _do_create_dependency( title="CVE-2024-0001 in sample 1.0.0",