# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Tests for vulnerability advisory collection (`uv audit` + GitHub Advisory DB)."""
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from repomatic.config import VulnerableDepsConfig
from repomatic.deps.vulnerable_deps import (
AdvisorySource,
VulnerablePackage,
_run_uv_audit,
_uv_version,
collect_vulnerable_packages,
fetch_dependabot_alerts,
fix_vulnerable_deps,
format_vulnerability_table,
parse_uv_audit_json,
)
ALERTS_FIXTURE = [
{
"number": 6,
"state": "open",
"dependency": {
"package": {"ecosystem": "pip", "name": "raspberry"},
"manifest_path": "uv.lock",
"scope": "runtime",
},
"security_advisory": {
"ghsa_id": "GHSA-fruit-1111-aaaa",
"summary": "Raspberry juice leak under concurrent picking",
"html_url": "https://github.com/advisories/GHSA-fruit-1111-aaaa",
},
"security_vulnerability": {
"package": {"ecosystem": "pip", "name": "raspberry"},
"first_patched_version": {"identifier": "3.1.47"},
"vulnerable_version_range": "< 3.1.47",
"severity": "high",
},
},
{
"number": 7,
"state": "open",
"dependency": {
"package": {"ecosystem": "pip", "name": "raspberry"},
"manifest_path": "uv.lock",
"scope": "runtime",
},
"security_advisory": {
"ghsa_id": "GHSA-fruit-2222-bbbb",
"summary": "Raspberry seed validation bypass",
"html_url": "https://github.com/advisories/GHSA-fruit-2222-bbbb",
},
"security_vulnerability": {
"package": {"ecosystem": "pip", "name": "raspberry"},
"first_patched_version": {"identifier": "3.1.47"},
"vulnerable_version_range": ">= 3.1.30, < 3.1.47",
"severity": "high",
},
},
]
[docs]
def test_config_default_sources_mirror_advisory_source_enum():
"""`VulnerableDepsConfig.sources` default must list every `AdvisorySource`.
The default is spelled out as a literal in {mod}`repomatic.config` rather
than derived from {class}`AdvisorySource`, because the module boundary
forbids the derivation: `config` is a low-level module that
`vulnerable_deps` imports, so `config` cannot import the enum back without
a circular dependency. This test is the enforcement that keeps the literal
in step with the enum's members and their declaration order.
"""
assert VulnerableDepsConfig().sources == [source.value for source in AdvisorySource]
[docs]
def test_fetch_dependabot_alerts_parses_each_entry():
"""Each alert maps to one VulnerablePackage tagged with GHSA source."""
with patch(
"repomatic.deps.vulnerable_deps.run_gh_command",
return_value=json.dumps(ALERTS_FIXTURE),
):
result = fetch_dependabot_alerts("orchard/raspberry")
assert len(result) == 2
for v in result:
assert v.name == "raspberry"
assert v.fixed_version == "3.1.47"
assert v.sources == {AdvisorySource.GITHUB_ADVISORIES}
assert v.advisory_url.startswith("https://github.com/advisories/GHSA-")
[docs]
def test_fetch_dependabot_alerts_skips_entries_without_fix():
"""Alerts lacking first_patched_version are filtered out."""
no_fix = [
{
"security_advisory": {"ghsa_id": "GHSA-cookbook-7777-zzzz", "summary": "x"},
"security_vulnerability": {
"package": {"name": "muffin", "ecosystem": "pip"},
"first_patched_version": None,
},
"dependency": {"manifest_path": "uv.lock"},
},
]
with patch(
"repomatic.deps.vulnerable_deps.run_gh_command",
return_value=json.dumps(no_fix),
):
assert fetch_dependabot_alerts("orchard/raspberry") == []
[docs]
@pytest.mark.parametrize(
"patch_kwargs",
[
pytest.param({"side_effect": RuntimeError("HTTP 403")}, id="api-error"),
pytest.param({"return_value": "<<not json>>"}, id="invalid-json"),
],
)
def test_fetch_dependabot_alerts_degrades_to_empty(patch_kwargs):
"""Network/auth failures and unparsable responses degrade to an empty list."""
with patch("repomatic.deps.vulnerable_deps.run_gh_command", **patch_kwargs):
assert fetch_dependabot_alerts("orchard/raspberry") == []
[docs]
@pytest.fixture
def lock_with_raspberry(tmp_path):
"""Create a uv.lock containing a single 'raspberry' package."""
lock = tmp_path / "uv.lock"
lock.write_text(
'[[package]]\nname = "raspberry"\nversion = "3.1.46"\n',
encoding="UTF-8",
)
return lock
[docs]
def test_collect_unions_uv_audit_and_ghsa(lock_with_raspberry):
"""Same package, different advisories: both kept, sources distinct."""
audit_only = VulnerablePackage(
name="raspberry",
current_version="3.1.46",
advisory_id="PYSEC-3333-cccc",
advisory_title="Raspberry stem rot",
fixed_version="3.1.47",
advisory_url="https://example.com/PYSEC-3333-cccc",
sources={AdvisorySource.UV_AUDIT},
)
with (
patch(
"repomatic.deps.vulnerable_deps._run_uv_audit", return_value=[audit_only]
),
patch(
"repomatic.deps.vulnerable_deps.run_gh_command",
return_value=json.dumps(ALERTS_FIXTURE),
),
):
merged = collect_vulnerable_packages(
lock_with_raspberry, repo="orchard/raspberry"
)
advisory_ids = sorted(v.advisory_id for v in merged)
assert advisory_ids == [
"GHSA-fruit-1111-aaaa",
"GHSA-fruit-2222-bbbb",
"PYSEC-3333-cccc",
]
# GHSA-only entries must have current_version backfilled from the lock.
for v in merged:
assert v.current_version == "3.1.46"
[docs]
def test_collect_dedupes_when_advisory_id_matches(lock_with_raspberry):
"""Same package + same advisory ID across sources: merged into one entry."""
same_advisory_audit = VulnerablePackage(
name="raspberry",
current_version="3.1.46",
advisory_id="GHSA-fruit-1111-aaaa",
advisory_title="", # missing in audit, filled from GHSA
fixed_version="3.1.47",
advisory_url="",
sources={AdvisorySource.UV_AUDIT},
)
with (
patch(
"repomatic.deps.vulnerable_deps._run_uv_audit",
return_value=[same_advisory_audit],
),
patch(
"repomatic.deps.vulnerable_deps.run_gh_command",
return_value=json.dumps(ALERTS_FIXTURE[:1]),
),
):
merged = collect_vulnerable_packages(
lock_with_raspberry, repo="orchard/raspberry"
)
assert len(merged) == 1
only = merged[0]
assert only.sources == {
AdvisorySource.UV_AUDIT,
AdvisorySource.GITHUB_ADVISORIES,
}
# Missing audit fields backfilled from the GHSA entry.
assert only.advisory_title == "Raspberry juice leak under concurrent picking"
assert only.advisory_url.endswith("GHSA-fruit-1111-aaaa")
[docs]
def test_collect_skips_ghsa_when_repo_missing(lock_with_raspberry):
"""No repo argument means the GHSA source is skipped entirely."""
with (
patch("repomatic.deps.vulnerable_deps._run_uv_audit", return_value=[]),
patch("repomatic.deps.vulnerable_deps.run_gh_command") as gh,
):
result = collect_vulnerable_packages(lock_with_raspberry, repo=None)
assert result == []
gh.assert_not_called()
[docs]
def test_collect_respects_sources_filter(lock_with_raspberry):
"""Only the explicitly requested sources are queried."""
with (
patch("repomatic.deps.vulnerable_deps._run_uv_audit") as audit,
patch(
"repomatic.deps.vulnerable_deps.run_gh_command",
return_value=json.dumps(ALERTS_FIXTURE),
),
):
result = collect_vulnerable_packages(
lock_with_raspberry,
repo="orchard/raspberry",
sources=[AdvisorySource.GITHUB_ADVISORIES],
)
audit.assert_not_called()
assert all(v.sources == {AdvisorySource.GITHUB_ADVISORIES} for v in result)
[docs]
def test_collect_backfills_current_version_across_case_difference(tmp_path):
"""GHSA reports `GitPython`, lock stores `gitpython` — backfill must match."""
lock = tmp_path / "uv.lock"
lock.write_text(
'[[package]]\nname = "gitpython"\nversion = "3.1.46"\n',
encoding="UTF-8",
)
alert = [
{
"security_advisory": {"ghsa_id": "GHSA-orchard-aaaa-bbbb", "summary": "x"},
"security_vulnerability": {
"package": {"name": "GitPython", "ecosystem": "pip"},
"first_patched_version": {"identifier": "3.1.47"},
},
"dependency": {"manifest_path": "uv.lock"},
},
]
with (
patch("repomatic.deps.vulnerable_deps._run_uv_audit", return_value=[]),
patch(
"repomatic.deps.vulnerable_deps.run_gh_command",
return_value=json.dumps(alert),
),
):
result = collect_vulnerable_packages(lock, repo="orchard/gitpython")
assert len(result) == 1
assert result[0].current_version == "3.1.46"
[docs]
def test_collect_keeps_distinct_per_source_urls(lock_with_raspberry):
"""Merging same advisory from two sources retains both URLs."""
audit = VulnerablePackage(
name="raspberry",
current_version="3.1.46",
advisory_id="GHSA-fruit-1111-aaaa",
advisory_title="",
fixed_version="3.1.47",
advisory_url="https://osv.dev/vulnerability/PYSEC-raspberry",
sources={AdvisorySource.UV_AUDIT},
source_urls={
AdvisorySource.UV_AUDIT: "https://osv.dev/vulnerability/PYSEC-raspberry",
},
)
with (
patch("repomatic.deps.vulnerable_deps._run_uv_audit", return_value=[audit]),
patch(
"repomatic.deps.vulnerable_deps.run_gh_command",
return_value=json.dumps(ALERTS_FIXTURE[:1]),
),
):
merged = collect_vulnerable_packages(
lock_with_raspberry, repo="orchard/raspberry"
)
assert len(merged) == 1
only = merged[0]
assert only.source_urls[AdvisorySource.UV_AUDIT].startswith("https://osv.dev/")
assert only.source_urls[AdvisorySource.GITHUB_ADVISORIES].startswith(
"https://github.com/advisories/"
)
[docs]
def test_parse_uv_audit_json_maps_fields():
"""A preview report maps onto VulnerablePackage, preferring display_id."""
report = {
"schema": {"version": "preview"},
"summary": {
"audited_packages": 10,
"vulnerabilities": 1,
"adverse_statuses": 0,
},
"vulnerabilities": [
{
"dependency": {"name": "raspberry", "version": "3.1.46"},
"id": "PYSEC-2026-1",
"display_id": "GHSA-fruit-1111-aaaa",
"aliases": ["CVE-2026-0001", "PYSEC-2026-1"],
"summary": "Raspberry juice leak under concurrent picking",
"link": "https://osv.dev/vulnerability/PYSEC-2026-1",
"fix_versions": ["3.1.47", "4.0.1"],
},
],
"adverse_statuses": [],
}
vulns = parse_uv_audit_json(json.dumps(report))
assert vulns is not None
assert len(vulns) == 1
only = vulns[0]
assert only.name == "raspberry"
assert only.current_version == "3.1.46"
# display_id wins over the OSV primary id for the human-facing identifier.
assert only.advisory_id == "GHSA-fruit-1111-aaaa"
assert only.advisory_title == "Raspberry juice leak under concurrent picking"
# Every fix branch is preserved, not just the first.
assert only.fixed_version == "3.1.47, 4.0.1"
assert only.advisory_url.endswith("PYSEC-2026-1")
# All other identifiers become aliases; the primary is excluded.
assert only.aliases == {"CVE-2026-0001", "PYSEC-2026-1"}
assert only.sources == {AdvisorySource.UV_AUDIT}
[docs]
def test_parse_uv_audit_json_no_vulnerabilities():
"""A valid report with zero findings is an empty list, not a fallback."""
report = {"schema": {"version": "preview"}, "vulnerabilities": []}
assert parse_uv_audit_json(json.dumps(report)) == []
[docs]
@pytest.mark.parametrize(
"output",
(
pytest.param("", id="empty"),
pytest.param(" \n ", id="whitespace"),
pytest.param("<<not json>>", id="malformed"),
pytest.param("[1, 2, 3]", id="not-an-object"),
pytest.param(json.dumps({"schema": {"version": "v2"}}), id="unknown-schema"),
pytest.param(json.dumps({"vulnerabilities": []}), id="missing-schema"),
),
)
def test_parse_uv_audit_json_raises_on_unusable_output(output):
"""Unusable JSON raises so the scanner fails loud, never silently empty."""
with pytest.raises(RuntimeError):
parse_uv_audit_json(output)
[docs]
def test_collect_dedupes_across_sources_via_alias(lock_with_raspberry):
"""A PYSEC from uv audit and its aliased GHSA from Dependabot merge."""
audit = VulnerablePackage(
name="raspberry",
current_version="3.1.46",
advisory_id="PYSEC-2026-1",
advisory_title="Raspberry juice leak under concurrent picking",
fixed_version="3.1.47",
advisory_url="https://osv.dev/vulnerability/PYSEC-2026-1",
aliases={"GHSA-fruit-1111-aaaa"},
sources={AdvisorySource.UV_AUDIT},
)
with (
patch("repomatic.deps.vulnerable_deps._run_uv_audit", return_value=[audit]),
patch(
"repomatic.deps.vulnerable_deps.run_gh_command",
return_value=json.dumps(ALERTS_FIXTURE[:1]), # GHSA-fruit-1111-aaaa
),
):
merged = collect_vulnerable_packages(
lock_with_raspberry, repo="orchard/raspberry"
)
assert len(merged) == 1
only = merged[0]
assert only.sources == {
AdvisorySource.UV_AUDIT,
AdvisorySource.GITHUB_ADVISORIES,
}
# uv is collected first, so its PYSEC stays primary and GHSA is an alias.
assert only.advisory_id == "PYSEC-2026-1"
assert "GHSA-fruit-1111-aaaa" in only.aliases
[docs]
def test_fix_restores_the_lock_when_the_upgrade_moves_nothing(tmp_path):
"""An unreachable fix must leave `uv.lock` byte-identical.
uv writes the `--exclude-newer-package` overrides it is handed into the
lock's `[options]` table even when the resolution does not move, so a
vulnerability capped out of reach by another dependency would otherwise
dirty the file with a lone metadata line. That is enough for the
`fix-vulnerable-deps` job to open a pull request carrying no fix and an
empty report.
"""
lock = tmp_path / "uv.lock"
original = (
"[options]\n"
'exclude-newer-span = "P1W"\n'
"\n"
"[options.exclude-newer-package]\n"
'papaya = "2026-08-11T00:00:00Z"\n'
"\n"
"[[package]]\n"
'name = "raspberry"\n'
'version = "3.1.46"\n'
)
lock.write_text(original, encoding="UTF-8")
(tmp_path / "pyproject.toml").write_text(
'[tool.uv]\nexclude-newer = "1 week"\n', encoding="UTF-8"
)
vuln = VulnerablePackage(
name="raspberry",
current_version="3.1.46",
advisory_id="GHSA-fruit-1111-aaaa",
advisory_title="Raspberry juice leak under concurrent picking",
fixed_version="3.1.47",
advisory_url="https://github.com/advisories/GHSA-fruit-1111-aaaa",
sources={AdvisorySource.UV_AUDIT},
)
def record_the_override(*args, **kwargs):
"""Stand in for `uv lock`: log the override, resolve the same version."""
lock.write_text(
original.replace(
'papaya = "2026-08-11T00:00:00Z"\n',
'papaya = "2026-08-11T00:00:00Z"\n'
'raspberry = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" }\n',
),
encoding="UTF-8",
)
with (
patch(
"repomatic.deps.vulnerable_deps.collect_vulnerable_packages",
return_value=[vuln],
),
patch(
"repomatic.deps.vulnerable_deps.subprocess.run",
side_effect=record_the_override,
),
):
has_fixes, report = fix_vulnerable_deps(lock)
assert has_fixes is False
assert report == ""
assert lock.read_text(encoding="UTF-8") == original
[docs]
@pytest.mark.parametrize(
("stdout", "expected"),
(
pytest.param("uv 0.11.15 (abc1234 2026-05-18)\n", "0.11.15", id="with-hash"),
pytest.param("uv 0.12.0\n", "0.12.0", id="bare"),
pytest.param("uv 1.0.0 (deadbeef 2027-01-01)", "1.0.0", id="major"),
),
)
def test_uv_version_parses(stdout, expected):
"""`uv --version` output is parsed into a comparable Version."""
completed = SimpleNamespace(stdout=stdout, stderr="")
with patch("repomatic.deps.uv.subprocess.run", return_value=completed):
assert str(_uv_version()) == expected
[docs]
def test_run_uv_audit_parses_json(lock_with_raspberry):
"""A recent uv yields JSON that is parsed into records."""
report = {
"schema": {"version": "preview"},
"vulnerabilities": [
{
"dependency": {"name": "raspberry", "version": "3.1.46"},
"id": "PYSEC-2026-1",
"display_id": "PYSEC-2026-1",
"summary": "Raspberry juice leak",
"link": "https://osv.dev/vulnerability/PYSEC-2026-1",
"fix_versions": ["3.1.47"],
},
],
}
version = SimpleNamespace(stdout="uv 0.11.15 (abc1234 2026-05-18)\n", stderr="")
audit = SimpleNamespace(stdout=json.dumps(report), stderr="")
with patch("repomatic.deps.uv.subprocess.run", side_effect=[version, audit]):
vulns = _run_uv_audit(lock_with_raspberry)
assert [v.advisory_id for v in vulns] == ["PYSEC-2026-1"]
assert vulns[0].fixed_version == "3.1.47"
[docs]
def test_run_uv_audit_rejects_old_uv(lock_with_raspberry):
"""An older uv (no JSON audit output) fails loud rather than scanning nothing."""
version = SimpleNamespace(stdout="uv 0.11.14 (abc1234 2026-05-10)\n", stderr="")
with (
patch("repomatic.deps.uv.subprocess.run", return_value=version) as run,
pytest.raises(RuntimeError, match=r"0\.11\.15"),
):
_run_uv_audit(lock_with_raspberry)
# Bails after the version check, never invoking `uv audit`.
assert run.call_count == 1
[docs]
def test_run_uv_audit_raises_on_unknown_schema(lock_with_raspberry):
"""A recent uv emitting an unrecognized schema version fails loud."""
version = SimpleNamespace(stdout="uv 0.12.0 (abc1234 2026-06-01)\n", stderr="")
audit = SimpleNamespace(stdout=json.dumps({"schema": {"version": "v2"}}), stderr="")
with (
patch("repomatic.deps.uv.subprocess.run", side_effect=[version, audit]),
pytest.raises(RuntimeError, match="schema version"),
):
_run_uv_audit(lock_with_raspberry)