ci: hold osv-scan findings whose fix is inside the dependency lockout window

uv is pinned to `exclude-newer = "3 days"` and .npmrc to `min-release-age=3`, so a
release published in the last three days cannot be resolved even if we want it. When
osv-scanner points at such a release, the job failed with nothing anyone could do about
it, and a check that fails for three days at a time stops being read.

The scan now emits JSON and a filter step decides the verdict. For each finding it takes
the lowest fix above the installed version, reads that version's publish date from PyPI or
the npm registry, and compares its age against the window read from pyproject.toml and
.npmrc. Findings still inside the window are printed with the timestamp they become
installable and do not fail the job; everything else fails as before. Registry lookup
failures, unparseable versions and unknown ecosystems all fail closed.
This commit is contained in:
Yuneng Jiang 2026-07-25 15:53:29 -07:00
parent 4edf8f1551
commit cafc2cf86a
No known key found for this signature in database
3 changed files with 812 additions and 0 deletions

379
.github/scripts/osv_lockout_filter.py vendored Normal file
View file

@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""Turn osv-scanner JSON results into a CI verdict that respects the dependency lockout window.
A finding is only actionable if the fix it points at is old enough for our resolvers to
install it (``[tool.uv] exclude-newer`` for PyPI, ``min-release-age`` in ``.npmrc`` for npm).
Findings whose fix is still inside that window are reported and deferred until it expires.
"""
from __future__ import annotations
import argparse
import functools
import json
import os
import re
import sys
import time
import tomllib
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Callable, Mapping, Sequence
from packaging.version import InvalidVersion, Version
PYPI = "PyPI"
NPM = "npm"
PublishTimes = Callable[[str, str], Mapping[str, datetime] | None]
_SEMVER = re.compile(
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
r"(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?"
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
)
_PYPI_NAME = re.compile(r"[-_.]+")
_DAYS = re.compile(r"\s*(\d+)\s*days?\s*")
_MIN_RELEASE_AGE = re.compile(r"^\s*min-release-age\s*=\s*(\d+)\s*$", re.MULTILINE)
@dataclass(frozen=True, slots=True)
class Finding:
ecosystem: str
package: str
installed: str
vuln_id: str
severity: str
fixed_versions: tuple[str, ...]
source: str
@dataclass(frozen=True, slots=True)
class Deferred:
finding: Finding
target: str
published: datetime
unlocks_at: datetime
@dataclass(frozen=True, slots=True)
class Actionable:
finding: Finding
target: str | None
reason: str
@dataclass(frozen=True, slots=True)
class WindowError:
message: str
Verdict = Deferred | Actionable
def semver_key(version: str) -> tuple[tuple[int, int, int], tuple[int, ...], tuple[tuple[int, int, str], ...]] | None:
matched = _SEMVER.match(version.strip())
if matched is None:
return None
core = (int(matched.group(1)), int(matched.group(2)), int(matched.group(3)))
prerelease = matched.group(4)
if prerelease is None:
return (core, (1,), ())
return (
core,
(0,),
tuple((0, int(part), "") if part.isdigit() else (1, 0, part) for part in prerelease.split(".")),
)
def pypi_key(version: str) -> Version | None:
try:
return Version(version)
except InvalidVersion:
return None
def version_key(ecosystem: str, version: str) -> object | None:
if ecosystem == PYPI:
return pypi_key(version)
if ecosystem == NPM:
return semver_key(version)
return None
def _base_ecosystem(raw: str) -> str:
return raw.split(":", 1)[0]
def _canonical_name(ecosystem: str, name: str) -> str:
return _PYPI_NAME.sub("-", name).lower() if ecosystem == PYPI else name
def _fixed_versions(vulnerability: dict, ecosystem: str, package: str) -> tuple[str, ...]:
wanted = _canonical_name(ecosystem, package)
return tuple(
dict.fromkeys(
event["fixed"]
for affected in vulnerability.get("affected") or []
for affected_package in ((affected.get("package") or {}),)
if _base_ecosystem(str(affected_package.get("ecosystem") or "")) == ecosystem
and _canonical_name(ecosystem, str(affected_package.get("name") or "")) == wanted
for version_range in affected.get("ranges") or []
if version_range.get("type") in ("ECOSYSTEM", "SEMVER")
for event in version_range.get("events") or []
if "fixed" in event
)
)
def _severity(groups: Sequence[dict], vuln_id: str) -> str:
matches = tuple(
str(group.get("max_severity") or "") for group in groups or [] if vuln_id in (group.get("ids") or [])
)
return next((severity for severity in matches if severity), "-")
def _findings_for_package(scanned: dict, source: str) -> tuple[Finding, ...]:
package = scanned["package"]
ecosystem = _base_ecosystem(str(package["ecosystem"]))
name = str(package["name"])
groups = scanned.get("groups") or []
return tuple(
Finding(
ecosystem=ecosystem,
package=name,
installed=str(package["version"]),
vuln_id=str(vulnerability["id"]),
severity=_severity(groups, str(vulnerability["id"])),
fixed_versions=_fixed_versions(vulnerability, ecosystem, name),
source=source,
)
for vulnerability in scanned.get("vulnerabilities") or []
)
def _relative_source(path: str, repo_root: Path) -> str:
try:
return str(Path(path).relative_to(repo_root))
except ValueError:
return path
def parse_findings(payload: dict, repo_root: Path) -> tuple[Finding, ...]:
return tuple(
finding
for result in payload.get("results") or []
for source in (_relative_source(str((result.get("source") or {}).get("path") or "?"), repo_root),)
for scanned in result.get("packages") or []
for finding in _findings_for_package(scanned, source)
)
def _fetch_json(url: str, attempts: int = 3) -> dict | None:
request = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "litellm-osv-lockout"})
for attempt in range(attempts):
try:
with urllib.request.urlopen(request, timeout=60) as response:
return json.loads(response.read())
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
print(f"osv-lockout: {url} lookup failed ({exc})", file=sys.stderr)
if attempt + 1 < attempts:
time.sleep(2 * (attempt + 1))
return None
def _parse_timestamp(raw: str) -> datetime | None:
try:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc)
def _earliest_upload(files: Sequence[dict]) -> datetime | None:
stamps = tuple(
stamp
for entry in files or []
for stamp in (_parse_timestamp(str(entry.get("upload_time_iso_8601") or "")),)
if stamp is not None
)
return min(stamps) if stamps else None
def _pypi_publish_times(name: str) -> Mapping[str, datetime] | None:
payload = _fetch_json(f"https://pypi.org/pypi/{urllib.parse.quote(name, safe='')}/json")
if payload is None:
return None
return {
version: stamp
for version, files in (payload.get("releases") or {}).items()
for stamp in (_earliest_upload(files),)
if stamp is not None
}
def _npm_publish_times(name: str) -> Mapping[str, datetime] | None:
payload = _fetch_json(f"https://registry.npmjs.org/{urllib.parse.quote(name, safe='@')}")
if payload is None:
return None
return {
version: stamp
for version, raw in (payload.get("time") or {}).items()
if version not in ("created", "modified")
for stamp in (_parse_timestamp(str(raw)),)
if stamp is not None
}
@functools.lru_cache(maxsize=None)
def registry_publish_times(ecosystem: str, name: str) -> Mapping[str, datetime] | None:
if ecosystem == PYPI:
return _pypi_publish_times(name)
if ecosystem == NPM:
return _npm_publish_times(name)
return None
def _uv_window(pyproject: Path) -> timedelta | WindowError:
if not pyproject.is_file():
return WindowError(f"{pyproject} not found; cannot determine the PyPI lockout window")
raw = ((tomllib.loads(pyproject.read_text(encoding="utf-8")).get("tool") or {}).get("uv") or {}).get(
"exclude-newer"
)
if raw is None:
return timedelta(0)
matched = _DAYS.fullmatch(str(raw))
if matched is None:
return WindowError(
f"[tool.uv] exclude-newer = {raw!r} is not an 'N days' window; teach osv_lockout_filter.py how to read it"
)
return timedelta(days=int(matched.group(1)))
def _npm_window(npmrc: Path) -> timedelta:
if not npmrc.is_file():
return timedelta(0)
matched = _MIN_RELEASE_AGE.search(npmrc.read_text(encoding="utf-8"))
return timedelta(days=int(matched.group(1))) if matched else timedelta(0)
def lockout_windows(repo_root: Path) -> Mapping[str, timedelta] | WindowError:
pypi = _uv_window(repo_root / "pyproject.toml")
if isinstance(pypi, WindowError):
return pypi
return {PYPI: pypi, NPM: _npm_window(repo_root / ".npmrc")}
def _publish_date(times: Mapping[str, datetime], ecosystem: str, version: str) -> datetime | None:
if version in times:
return times[version]
wanted = version_key(ecosystem, version)
if wanted is None:
return None
return next((stamp for other, stamp in times.items() if version_key(ecosystem, other) == wanted), None)
def evaluate(
finding: Finding,
windows: Mapping[str, timedelta],
publish_times: PublishTimes,
now: datetime,
) -> Verdict:
window = windows.get(finding.ecosystem)
if window is None:
return Actionable(finding, None, f"no lockout window is configured for the {finding.ecosystem} ecosystem")
installed = version_key(finding.ecosystem, finding.installed)
if installed is None:
return Actionable(finding, None, f"cannot parse the installed version {finding.installed!r}")
upgrades = tuple(
(key, version)
for version in finding.fixed_versions
for key in (version_key(finding.ecosystem, version),)
if key is not None and key > installed
)
if not upgrades:
return Actionable(finding, None, "no published fix is newer than the installed version")
target = sorted(upgrades, key=lambda pair: pair[0])[0][1]
times = publish_times(finding.ecosystem, finding.package)
if times is None:
return Actionable(finding, target, "could not read publish dates from the package registry")
published = _publish_date(times, finding.ecosystem, target)
if published is None:
return Actionable(finding, target, f"the registry lists no publish date for {target}")
unlocks_at = published + window
if unlocks_at > now:
return Deferred(finding, target, published, unlocks_at)
return Actionable(finding, target, f"{target} has been installable since {_stamp(unlocks_at)}")
def _stamp(moment: datetime) -> str:
return moment.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
def _describe(finding: Finding, target: str | None) -> str:
upgrade = f"{finding.installed} -> {target}" if target else f"{finding.installed} (no upgrade)"
return f"{finding.ecosystem} {finding.package} {upgrade} {finding.vuln_id} (severity {finding.severity}) [{finding.source}]"
def render(
windows: Mapping[str, timedelta],
deferred: Sequence[Deferred],
actionable: Sequence[Actionable],
) -> str:
header = "Dependency lockout window: " + ", ".join(
f"{ecosystem} {window.days}d" for ecosystem, window in sorted(windows.items())
)
deferred_lines = tuple(
f" {_describe(item.finding, item.target)}\n"
f" published {_stamp(item.published)}; installable from {_stamp(item.unlocks_at)}"
for item in deferred
)
actionable_lines = tuple(f" {_describe(item.finding, item.target)}\n {item.reason}" for item in actionable)
sections = (
(
f"Deferred ({len(deferred)}) - the fix is still inside the lockout window, so it cannot be pulled in yet:",
deferred_lines or (" none",),
),
(
f"Blocking ({len(actionable)}) - the fix can be pulled in now:",
actionable_lines or (" none",),
),
)
return "\n".join((header, "", *(line for title, lines in sections for line in (title, *lines, ""))))
def main(argv: Sequence[str] | None = None, publish_times: PublishTimes = registry_publish_times) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--results", required=True, type=Path, help="osv-scanner --format json output file")
parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[2])
args = parser.parse_args(argv)
windows = lockout_windows(args.repo_root)
if isinstance(windows, WindowError):
print(f"osv-lockout: {windows.message}", file=sys.stderr)
return 1
if not args.results.is_file():
print(f"osv-lockout: {args.results} not found", file=sys.stderr)
return 1
findings = parse_findings(json.loads(args.results.read_text(encoding="utf-8")), args.repo_root)
now = datetime.now(timezone.utc)
verdicts = tuple(evaluate(finding, windows, publish_times, now) for finding in findings)
deferred = tuple(verdict for verdict in verdicts if isinstance(verdict, Deferred))
actionable = tuple(verdict for verdict in verdicts if isinstance(verdict, Actionable))
report = render(windows, deferred, actionable)
print(report)
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
if summary_path:
with open(summary_path, "a", encoding="utf-8") as summary:
summary.write(f"### OSV scan\n\n```\n{report}\n```\n")
return 1 if actionable else 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -36,9 +36,30 @@ jobs:
echo "bc98e15319ed0d515e3f9235287ba53cdc5535d576d24fd573978ecfe9ab92dc $RUNNER_TEMP/osv-scanner" | sha256sum -c -
chmod +x "$RUNNER_TEMP/osv-scanner"
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
# Exit 1 means "something is affected" and is handed to the filter step; anything above
# 1 is a scanner failure and still fails the job.
- name: Scan lockfiles
run: |
set +e
"$RUNNER_TEMP/osv-scanner" scan source \
--config osv-scanner.toml \
--format json \
--output-file "$RUNNER_TEMP/osv-results.json" \
-L uv.lock \
-L ui/litellm-dashboard/package-lock.json
scan_status=$?
set -e
if [ "$scan_status" -gt 1 ]; then
echo "osv-scanner exited with status $scan_status"
exit "$scan_status"
fi
- name: Apply the dependency lockout window
run: |
python -m pip install --disable-pip-version-check "packaging==26.2"
python .github/scripts/osv_lockout_filter.py --results "$RUNNER_TEMP/osv-results.json"

View file

@ -0,0 +1,412 @@
"""Unit tests for `.github/scripts/osv_lockout_filter.py`.
The filter decides whether an osv-scanner finding should fail CI. It must fail only when the
fix is old enough for our resolvers to install it, and must stay quiet while the fix is still
inside the dependency lockout window (`[tool.uv] exclude-newer`, `.npmrc` min-release-age).
Registry lookups are dependency-injected, so nothing here touches the network.
"""
from __future__ import annotations
import importlib.util
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT_PATH = REPO_ROOT / ".github" / "scripts" / "osv_lockout_filter.py"
NOW = datetime(2026, 7, 25, 12, 0, tzinfo=timezone.utc)
@pytest.fixture(scope="module")
def filter_module():
spec = importlib.util.spec_from_file_location("osv_lockout_filter", SCRIPT_PATH)
assert spec and spec.loader, f"Could not load spec for {SCRIPT_PATH}"
module = importlib.util.module_from_spec(spec)
sys.modules["osv_lockout_filter"] = module
spec.loader.exec_module(module)
return module
@pytest.fixture
def windows(filter_module):
return {filter_module.PYPI: timedelta(days=3), filter_module.NPM: timedelta(days=3)}
def _osv_payload(
*,
path: str,
ecosystem: str,
name: str,
version: str,
vuln_id: str,
fixed: list[str],
severity: str = "7.5",
) -> dict:
return {
"results": [
{
"source": {"path": path, "type": "lockfile"},
"packages": [
{
"package": {"name": name, "version": version, "ecosystem": ecosystem},
"groups": [{"ids": [vuln_id], "aliases": [vuln_id], "max_severity": severity}],
"vulnerabilities": [
{
"id": vuln_id,
"affected": [
{
"package": {"ecosystem": ecosystem, "name": name},
"ranges": [
{
"type": "ECOSYSTEM" if ecosystem == "PyPI" else "SEMVER",
"events": [{"introduced": "0"}, {"fixed": f}],
}
for f in fixed
],
}
],
}
],
}
],
}
]
}
def _finding(filter_module, **overrides):
defaults = dict(
ecosystem=filter_module.PYPI,
package="gitpython",
installed="3.1.54",
vuln_id="GHSA-94p4-4cq8-9g67",
severity="7.5",
fixed_versions=("3.1.55",),
source="uv.lock",
)
return filter_module.Finding(**{**defaults, **overrides})
def _times(mapping: dict[str, datetime]):
return lambda ecosystem, name: mapping
class TestParsing:
def test_extracts_every_field_from_a_real_scanner_payload(self, filter_module):
payload = _osv_payload(
path=str(REPO_ROOT / "ui" / "litellm-dashboard" / "package-lock.json"),
ecosystem="npm",
name="brace-expansion",
version="5.0.7",
vuln_id="GHSA-mh99-v99m-4gvg",
fixed=["5.0.8"],
)
findings = filter_module.parse_findings(payload, REPO_ROOT)
assert len(findings) == 1
assert findings[0] == filter_module.Finding(
ecosystem="npm",
package="brace-expansion",
installed="5.0.7",
vuln_id="GHSA-mh99-v99m-4gvg",
severity="7.5",
fixed_versions=("5.0.8",),
source="ui/litellm-dashboard/package-lock.json",
)
def test_ignores_fixed_events_for_a_different_package(self, filter_module):
payload = _osv_payload(
path="uv.lock", ecosystem="PyPI", name="gitpython", version="3.1.54", vuln_id="GHSA-x", fixed=["3.1.55"]
)
affected = payload["results"][0]["packages"][0]["vulnerabilities"][0]["affected"]
affected.append(
{
"package": {"ecosystem": "PyPI", "name": "some-other-package"},
"ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": "9.9.9"}]}],
}
)
assert filter_module.parse_findings(payload, REPO_ROOT)[0].fixed_versions == ("3.1.55",)
def test_matches_pypi_names_across_normalization(self, filter_module):
payload = _osv_payload(
path="uv.lock", ecosystem="PyPI", name="Zope.Interface", version="5.0", vuln_id="GHSA-x", fixed=["5.1"]
)
payload["results"][0]["packages"][0]["vulnerabilities"][0]["affected"][0]["package"]["name"] = "zope_interface"
assert filter_module.parse_findings(payload, REPO_ROOT)[0].fixed_versions == ("5.1",)
def test_skips_git_ranges_which_carry_commit_hashes_not_versions(self, filter_module):
payload = _osv_payload(
path="uv.lock", ecosystem="PyPI", name="gitpython", version="3.1.54", vuln_id="GHSA-x", fixed=["3.1.55"]
)
payload["results"][0]["packages"][0]["vulnerabilities"][0]["affected"][0]["ranges"].append(
{"type": "GIT", "events": [{"introduced": "0"}, {"fixed": "deadbeef"}]}
)
assert filter_module.parse_findings(payload, REPO_ROOT)[0].fixed_versions == ("3.1.55",)
class TestVersionOrdering:
@pytest.mark.parametrize(
"lower, higher",
[
("1.0.0", "1.0.1"),
("1.9.0", "1.10.0"),
("1.0.0-rc.1", "1.0.0"),
("1.0.0-alpha.1", "1.0.0-alpha.2"),
("1.0.0-alpha.1", "1.0.0-alpha.beta"),
("1.0.0-alpha", "1.0.0-alpha.1"),
],
)
def test_semver_precedence(self, filter_module, lower, higher):
assert filter_module.semver_key(lower) < filter_module.semver_key(higher)
def test_semver_ignores_build_metadata(self, filter_module):
assert filter_module.semver_key("1.2.3+build.5") == filter_module.semver_key("1.2.3")
def test_semver_rejects_non_semver(self, filter_module):
assert filter_module.semver_key("not-a-version") is None
assert filter_module.semver_key("1.2") is None
class TestEvaluate:
def test_defers_when_the_fix_is_inside_the_window(self, filter_module, windows):
published = NOW - timedelta(days=2)
verdict = filter_module.evaluate(
_finding(filter_module), windows, _times({"3.1.55": published}), NOW
)
assert isinstance(verdict, filter_module.Deferred)
assert verdict.target == "3.1.55"
assert verdict.unlocks_at == published + timedelta(days=3)
def test_blocks_when_the_fix_is_older_than_the_window(self, filter_module, windows):
verdict = filter_module.evaluate(
_finding(filter_module), windows, _times({"3.1.55": NOW - timedelta(days=4)}), NOW
)
assert isinstance(verdict, filter_module.Actionable)
assert verdict.target == "3.1.55"
def test_blocks_the_moment_the_window_expires(self, filter_module, windows):
exactly_three_days_old = NOW - timedelta(days=3)
verdict = filter_module.evaluate(
_finding(filter_module), windows, _times({"3.1.55": exactly_three_days_old}), NOW
)
assert isinstance(verdict, filter_module.Actionable)
def test_defers_one_second_before_the_window_expires(self, filter_module, windows):
verdict = filter_module.evaluate(
_finding(filter_module),
windows,
_times({"3.1.55": NOW - timedelta(days=3) + timedelta(seconds=1)}),
NOW,
)
assert isinstance(verdict, filter_module.Deferred)
def test_targets_the_lowest_fix_above_the_installed_version_not_an_old_branch_backport(
self, filter_module, windows
):
finding = _finding(filter_module, package="django", installed="5.2.0", fixed_versions=("4.2.9", "5.2.1"))
verdict = filter_module.evaluate(
finding,
windows,
_times({"4.2.9": NOW - timedelta(days=400), "5.2.1": NOW - timedelta(days=1)}),
NOW,
)
assert isinstance(verdict, filter_module.Deferred)
assert verdict.target == "5.2.1"
def test_picks_the_lowest_of_several_reachable_fixes(self, filter_module, windows):
finding = _finding(filter_module, installed="1.0.0", fixed_versions=("3.0.0", "1.0.1", "2.0.0"))
verdict = filter_module.evaluate(
finding,
windows,
_times({v: NOW - timedelta(days=10) for v in ("1.0.1", "2.0.0", "3.0.0")}),
NOW,
)
assert verdict.target == "1.0.1"
def test_blocks_when_no_fix_is_newer_than_the_installed_version(self, filter_module, windows):
finding = _finding(filter_module, installed="4.0.0", fixed_versions=("3.1.55",))
verdict = filter_module.evaluate(finding, windows, _times({"3.1.55": NOW}), NOW)
assert isinstance(verdict, filter_module.Actionable)
assert verdict.target is None
def test_blocks_when_the_registry_lookup_fails(self, filter_module, windows):
verdict = filter_module.evaluate(
_finding(filter_module), windows, lambda ecosystem, name: None, NOW
)
assert isinstance(verdict, filter_module.Actionable)
assert "registry" in verdict.reason
def test_blocks_when_the_registry_has_no_date_for_the_target(self, filter_module, windows):
verdict = filter_module.evaluate(
_finding(filter_module), windows, _times({"3.1.54": NOW - timedelta(days=9)}), NOW
)
assert isinstance(verdict, filter_module.Actionable)
def test_blocks_for_an_ecosystem_with_no_configured_window(self, filter_module, windows):
finding = _finding(filter_module, ecosystem="Go", package="golang.org/x/net", installed="0.1.0")
verdict = filter_module.evaluate(finding, windows, _times({"0.2.0": NOW - timedelta(days=9)}), NOW)
assert isinstance(verdict, filter_module.Actionable)
def test_uses_the_npm_window_for_npm_findings(self, filter_module):
finding = _finding(
filter_module, ecosystem="npm", package="brace-expansion", installed="5.0.7", fixed_versions=("5.0.8",)
)
mixed = {filter_module.PYPI: timedelta(days=0), filter_module.NPM: timedelta(days=3)}
verdict = filter_module.evaluate(finding, mixed, _times({"5.0.8": NOW - timedelta(days=2)}), NOW)
assert isinstance(verdict, filter_module.Deferred)
class TestLockoutWindows:
def test_reads_the_windows_this_repo_actually_enforces(self, filter_module):
resolved = filter_module.lockout_windows(REPO_ROOT)
assert not isinstance(resolved, filter_module.WindowError)
assert resolved == {filter_module.PYPI: timedelta(days=3), filter_module.NPM: timedelta(days=3)}
def test_reads_a_custom_day_count(self, filter_module, tmp_path):
(tmp_path / "pyproject.toml").write_text('[tool.uv]\nexclude-newer = "7 days"\n')
(tmp_path / ".npmrc").write_text("ignore-scripts=true\nmin-release-age=5\n")
assert filter_module.lockout_windows(tmp_path) == {
filter_module.PYPI: timedelta(days=7),
filter_module.NPM: timedelta(days=5),
}
def test_no_window_configured_means_no_deferral(self, filter_module, tmp_path):
(tmp_path / "pyproject.toml").write_text("[tool.uv]\n")
assert filter_module.lockout_windows(tmp_path) == {
filter_module.PYPI: timedelta(0),
filter_module.NPM: timedelta(0),
}
def test_rejects_an_exclude_newer_form_it_cannot_interpret(self, filter_module, tmp_path):
(tmp_path / "pyproject.toml").write_text("[tool.uv]\nexclude-newer = 2026-01-01\n")
assert isinstance(filter_module.lockout_windows(tmp_path), filter_module.WindowError)
def test_errors_when_pyproject_is_missing(self, filter_module, tmp_path):
assert isinstance(filter_module.lockout_windows(tmp_path), filter_module.WindowError)
class TestMain:
def _write(self, tmp_path: Path, payload: dict) -> Path:
results = tmp_path / "osv-results.json"
results.write_text(json.dumps(payload), encoding="utf-8")
return results
def test_exits_zero_when_the_only_finding_is_inside_the_window(self, filter_module, tmp_path, capsys):
results = self._write(
tmp_path,
_osv_payload(
path="uv.lock",
ecosystem="PyPI",
name="gitpython",
version="3.1.54",
vuln_id="GHSA-94p4-4cq8-9g67",
fixed=["3.1.55"],
),
)
recent = datetime.now(timezone.utc) - timedelta(hours=1)
status = filter_module.main(
["--results", str(results), "--repo-root", str(REPO_ROOT)],
publish_times=_times({"3.1.55": recent}),
)
assert status == 0
assert "GHSA-94p4-4cq8-9g67" in capsys.readouterr().out
def test_exits_one_when_the_fix_is_installable_today(self, filter_module, tmp_path):
results = self._write(
tmp_path,
_osv_payload(
path="uv.lock",
ecosystem="PyPI",
name="gitpython",
version="3.1.54",
vuln_id="GHSA-94p4-4cq8-9g67",
fixed=["3.1.55"],
),
)
old = datetime.now(timezone.utc) - timedelta(days=30)
status = filter_module.main(
["--results", str(results), "--repo-root", str(REPO_ROOT)],
publish_times=_times({"3.1.55": old}),
)
assert status == 1
def test_exits_zero_on_a_clean_scan(self, filter_module, tmp_path):
results = self._write(tmp_path, {"results": []})
assert filter_module.main(["--results", str(results), "--repo-root", str(REPO_ROOT)]) == 0
def test_exits_one_when_the_results_file_is_missing(self, filter_module, tmp_path):
assert filter_module.main(["--results", str(tmp_path / "nope.json"), "--repo-root", str(REPO_ROOT)]) == 1
def test_writes_a_step_summary_when_github_provides_one(self, filter_module, tmp_path, monkeypatch):
results = self._write(
tmp_path,
_osv_payload(
path="uv.lock",
ecosystem="PyPI",
name="gitpython",
version="3.1.54",
vuln_id="GHSA-94p4-4cq8-9g67",
fixed=["3.1.55"],
),
)
summary = tmp_path / "summary.md"
monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary))
filter_module.main(
["--results", str(results), "--repo-root", str(REPO_ROOT)],
publish_times=_times({"3.1.55": datetime.now(timezone.utc)}),
)
assert "GHSA-94p4-4cq8-9g67" in summary.read_text(encoding="utf-8")
class TestRender:
def test_names_the_date_a_deferred_finding_starts_failing(self, filter_module, windows):
deferred = filter_module.Deferred(
finding=_finding(filter_module),
target="3.1.55",
published=datetime(2026, 7, 23, 2, 52, tzinfo=timezone.utc),
unlocks_at=datetime(2026, 7, 26, 2, 52, tzinfo=timezone.utc),
)
report = filter_module.render(windows, (deferred,), ())
assert "3.1.54 -> 3.1.55" in report
assert "2026-07-26 02:52 UTC" in report
assert "Blocking (0)" in report