Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial lock scan #2

Merged
merged 10 commits into from
Dec 17, 2024
29 changes: 27 additions & 2 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ on:
jobs:
pytest:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand All @@ -18,5 +21,27 @@ jobs:
python-version-file: ".python-version"
- name: Install the project
run: uv sync --all-extras --dev
- name: Run tests
run: uv run pytest tests
- name: Run tests with Coverage
run: uv run pytest tests --cov=. --cov-report=term > coverage_summary.txt
- name: Prepare Comment Body
run: |
echo '### Coverage Report' >> comment_body.md
echo '```txt' >> comment_body.md
cat coverage_summary.txt >> comment_body.md
echo '' >> comment_body.md
echo '```' >> comment_body.md
- name: Find Coverage Report Comment
id: find-comment
uses: peter-evans/find-comment@v3
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: github-actions[bot]
body-includes: '### Coverage Report'
- name: Create or Update Coverage Comment
uses: peter-evans/create-or-update-comment@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
body-path: comment_body.md
comment-id: ${{ steps.find-comment.outputs.comment-id }}
edit-mode: replace
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ docs/_build/
target/

# Jupyter Notebook
.ipynb_checkpoints
.jupyter/
.ipynb_checkpoints/
*.ipynb

# IPython
profile_default/
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,30 @@
# uv-secure

Scan your uv.lock file for dependencies with known vulnerabilities

## Related Work and Motivation

I created this package as I wanted a dependency vulnerability scanner but I wasn't
completely happy with the options that seemed available. I use
[uv](https://docs.astral.sh/uv/) and wanted something that works with uv.lock files but
neither of the main package options I found fitted my requirements:

- [pip-audit](https://pypi.org/project/pip-audit/) only works with requirements.txt
files but even if you convert a uv.lock file to a requirements.txt file, pip-audit
wants to create a whole virtual environment to check all transitive dependencies (but
that should be completely unnecessary when the lock file already contains the full
dependencies).
- [safety](https://pypi.org/project/safety/) also doesn't work with uv.lock file out of
the box, it does apparently work statically without needing to build a virtual
environment but it does require you to create an account on the
[safety site](https://platform.safetycli.com/). They have some limited free account
but require a paid account to use seriously. If you already have a safety account
though there is a [uv-audit](https://pypi.org/project/uv-audit/) package that wraps
safety to support scanning uv.lock files.
- [Python Security PyCharm Plugin](https://plugins.jetbrains.com/plugin/13609-python-security)
Lastly I was inspired by Anthony Shaw's Python Security plugin - which does CVE
dependency scanning within PyCharm.

I build uv-secure because I wanted a CLI tool I could run with pre-commit. Statically
analyse the uv.lock file without needing to create a virtual environment, and finally
doesn't require you to create (and pay for) an account with any service.
16 changes: 15 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uv-secure"
version = "0.1.0"
version = "0.2.0"
description = "Scan your uv.lock file for dependencies with known vulnerabilities"
readme = "README.md"
authors = [
Expand All @@ -9,11 +9,25 @@ authors = [
requires-python = ">=3.9"
dependencies = [
"httpx>=0.28.1",
"inflect>=7.4.0",
"pydantic>=2.10.3",
"rich>=13.9.4",
"typer>=0.15.1",
]

[project.optional-dependencies]
jupyter = [
"ipywidgets>=8.1.5",
"jupyterlab-code-formatter>=3.0.2",
"jupyterlab>=4.2.5",
"lckr-jupyterlab-variableinspector>=3.2.4",
"nbdime>=4.0.2",
"nbstripout>=0.7.1",
"notebook>=7.2.2",
"ruff>=0.6.4",
"tqdm>=4.66.6",
]

[project.scripts]
uv-secure = "uv_secure:main"

Expand Down
4 changes: 2 additions & 2 deletions src/uv_secure/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from uv_secure.run import app
from uv_secure.run import app, check_dependencies


__all__ = ["app"]
__all__ = ["app", "check_dependencies"]
170 changes: 167 additions & 3 deletions src/uv_secure/run.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,177 @@
import asyncio
from pathlib import Path
import re
import sys
from typing import Optional

import httpx
import inflect
from pydantic import BaseModel
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
import typer


app = typer.Typer()

# Conditional import for toml
if sys.version_info >= (3, 11):
import tomllib as toml
else:
import tomli as toml


class Dependency(BaseModel):
name: str
version: str


class Vulnerability(BaseModel):
id: str
details: str
fixed_in: Optional[list[str]] = None
aliases: Optional[list[str]] = None
link: Optional[str] = None
source: Optional[str] = None
summary: Optional[str] = None
withdrawn: Optional[str] = None


def parse_uv_lock_file(file_path: Path) -> list[Dependency]:
"""Parses a uv.lock TOML file and extracts package PyPi dependencies"""
with file_path.open("rb") as f:
data = toml.load(f)

package_data = data.get("package", [])
return [
Dependency(name=package["name"], version=package["version"])
for package in package_data
if package.get("source", {}).get("registry") == "https://pypi.org/simple"
]


def canonicalize_name(name: str) -> str:
"""Converts a package name to its canonical form for PyPI URLs"""
return re.sub(r"[_.]+", "-", name).lower()


async def fetch_vulnerabilities(
client: httpx.AsyncClient, dependency: Dependency
) -> tuple[Dependency, list[Vulnerability]]:
"""Queries the PyPi JSON API for vulnerabilities of a given dependency."""
canonical_name = canonicalize_name(dependency.name)
url = f"https://pypi.org/pypi/{canonical_name}/{dependency.version}/json"
try:
response = await client.get(url)
if response.status_code == 200:
data = response.json()
vulnerabilities = [
Vulnerability(**v) for v in data.get("vulnerabilities", [])
]
return dependency, vulnerabilities
typer.echo(
f"Warning: Could not fetch data for {dependency.name}=={dependency.version}"
)
except httpx.RequestError as e:
typer.echo(f"Error fetching {dependency.name}=={dependency.version}: {e}")
return dependency, []


async def check_all_vulnerabilities(
dependencies: list[Dependency],
) -> list[tuple[Dependency, list[Vulnerability]]]:
"""Fetch vulnerabilities for all dependencies concurrently."""
async with httpx.AsyncClient(timeout=10) as client:
tasks = [fetch_vulnerabilities(client, dep) for dep in dependencies]
return await asyncio.gather(*tasks)


def check_dependencies(uv_lock_path: Path, ignore_ids: list[str]) -> int:
"""Checks dependencies for vulnerabilities and summarizes the results."""
console = Console()
inf = inflect.engine()

if not uv_lock_path.exists():
console.print(f"[bold red]Error:[/] File {uv_lock_path} does not exist.")
raise typer.Exit(1)

dependencies = parse_uv_lock_file(uv_lock_path)
console.print("[bold cyan]Checking dependencies for vulnerabilities...[/]")

results = asyncio.run(check_all_vulnerabilities(dependencies))

total_dependencies = len(results)
vulnerable_count = 0
vulnerabilities_found = []

for dep, vulnerabilities in results:
# Filter out ignored vulnerabilities
filtered_vulnerabilities = [
vuln for vuln in vulnerabilities if vuln.id not in ignore_ids
]
if filtered_vulnerabilities:
vulnerable_count += 1
vulnerabilities_found.append((dep, filtered_vulnerabilities))

total_plural = inf.plural("dependency", total_dependencies)
vulnerable_plural = inf.plural("dependency", vulnerable_count)

if vulnerable_count > 0:
console.print(
Panel.fit(
f"[bold red]Vulnerabilities detected![/]\n"
f"Checked: [bold]{total_dependencies}[/] {total_plural}\n"
f"Vulnerable: [bold]{vulnerable_count}[/] {vulnerable_plural}"
)
)

table = Table(
title="Vulnerable Dependencies",
show_header=True,
header_style="bold magenta",
)
table.add_column("Package", style="dim", width=20)
table.add_column("Version", style="dim", width=10)
table.add_column("Vulnerability ID", style="bold cyan", width=25)
table.add_column("Details", width=40)

for dep, vulnerabilities in vulnerabilities_found:
for vuln in vulnerabilities:
vuln_id_hyperlink = (
Text.assemble((vuln.id, f"link {vuln.link}"))
if vuln.link
else Text(vuln.id)
)
table.add_row(dep.name, dep.version, vuln_id_hyperlink, vuln.details)

console.print(table)
return 1 # Exit with failure status

console.print(
Panel.fit(
f"[bold green]No vulnerabilities detected![/]\n"
f"Checked: [bold]{total_dependencies}[/] {total_plural}\n"
f"All dependencies appear safe! 🎉"
)
)
return 0 # Exit successfully


_ignore_option = typer.Option(
"",
"--ignore",
"-i",
help="Comma-separated list of vulnerability IDs to ignore, e.g. VULN-123,VULN-456",
)


@app.command()
def main() -> int:
print("Hello from uv-secure!")
return 0
def main(uv_lock_path: Path, ignore: str = _ignore_option) -> int:
"""Parse a uv.lock file, check vulnerabilities, and display summary."""
ignore_ids = [vuln_id.strip() for vuln_id in ignore.split(",") if vuln_id.strip()]
return check_dependencies(uv_lock_path, ignore_ids)


if __name__ == "__main__":
Expand Down
87 changes: 84 additions & 3 deletions tests/uv_secure/test_run.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,92 @@
from pathlib import Path

from _pytest.capture import CaptureFixture
import pytest
from pytest_httpx import HTTPXMock
from pytest_mock import MockFixture
from typer.testing import CliRunner

from uv_secure import app
from uv_secure import app, check_dependencies


runner = CliRunner()


def test_app() -> None:
result = runner.invoke(app)
@pytest.fixture
def temp_uv_lock_file(tmp_path: Path) -> Path:
"""Fixture to create a temporary uv.lock file with a single dependency."""
uv_lock_path = tmp_path / "uv.lock"
uv_lock_data = """
[[package]]
name = "example-package"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
"""
uv_lock_path.write_text(uv_lock_data)
return uv_lock_path


def test_app(mocker: MockFixture) -> None:
mock_check_dependencies = mocker.patch("uv_secure.run.check_dependencies")
result = runner.invoke(app, "uv.lock")
mock_check_dependencies.assert_called_once_with(Path("uv.lock"), [])
assert result.exit_code == 0


def test_check_dependencies_no_vulnerabilities(
temp_uv_lock_file: Path, httpx_mock: HTTPXMock, capsys: CaptureFixture[str]
) -> None:
"""Test check_dependencies with a single dependency and no vulnerabilities."""
# Mock PyPI JSON API response with no vulnerabilities
httpx_mock.add_response(
url="https://pypi.org/pypi/example-package/1.0.0/json",
json={"vulnerabilities": []},
)

# Run the check_dependencies function
exit_code = check_dependencies(temp_uv_lock_file, ignore_ids=[])

# Capture the console output
captured = capsys.readouterr()

# Assertions
assert exit_code == 0
assert "No vulnerabilities detected!" in captured.out
assert "Checked: 1 dependency" in captured.out
assert "All dependencies appear safe!" in captured.out


def test_check_dependencies_with_vulnerability(
temp_uv_lock_file: Path, httpx_mock: HTTPXMock, capsys: CaptureFixture[str]
) -> None:
"""Test check_dependencies with a single dependency and a single vulnerability."""
# Mock PyPI JSON API response with one vulnerability
httpx_mock.add_response(
url="https://pypi.org/pypi/example-package/1.0.0/json",
json={
"vulnerabilities": [
{
"id": "VULN-123",
"details": "A critical vulnerability in example-package.",
"fixed_in": ["1.0.1"],
"link": "https://example.com/vuln-123",
}
]
},
)

# Run the check_dependencies function
exit_code = check_dependencies(temp_uv_lock_file, ignore_ids=[])

# Capture the console output
captured = capsys.readouterr()

# Assertions
assert exit_code == 1
assert "Vulnerabilities detected!" in captured.out
assert "Checked: 1 dependency" in captured.out
assert "Vulnerable: 1 dependency" in captured.out
assert "example-package" in captured.out
assert "VULN-123" in captured.out
assert "A critical vulnerability in" in captured.out
assert "example-package." in captured.out
Loading
Loading