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

Make requests and file IO more asynchronous #10

Merged
merged 6 commits into from
Dec 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ repos:
# --write-changes (Don't use this to stop typos making auto-corrections)
]
- repo: https://github.com/owenlamont/uv-secure
rev: 0.1.2
rev: 0.2.0
hooks:
- id: uv-secure
- repo: https://github.com/adrienverge/yamllint.git
Expand Down
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ Below are some ideas (in no particular order) I have for improving uv-secure:

- Support reading configuration from pyproject.toml
- Support reading configuration for multiple pyproject.toml files for mono repos
- Package for conda on conda-forge
- Add rate limiting on how hard the PyPi json API is hit to query package
vulnerabilities (this hasn't been a problem yet but I suspect may be for uv.lock files
with many dependencies).
Expand All @@ -105,15 +106,19 @@ Below are some ideas (in no particular order) I have for improving uv-secure:
## 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
completely happy with the options that were 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).
neither of the main package options I found were as frictionless as I had hoped:

- [pip-audit](https://pypi.org/project/pip-audit/) uv-secure is very much based on doing
the same vulnerability check that pip-audit does using PyPi's json API. pip-audit
however only works with requirements.txt so to make it work with uv projects you need
additional steps to convert your uv.lock file to a requirements.txt then you need to
run pip-audit with the --no-deps and/or --no-pip options to stop pip-audit trying to
create a virtual environment from the requirements.txt file. In short, you can use
pip-audit instead of uv-secure albeit with a bit more friction for uv projects. I hope
to add extra features beyond what pip-audit does or optimise things better (given the
more specialised case of only needing to support uv.lock files) in the future.
- [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
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,15 @@ classifiers = [
]

dependencies = [
"anyio>=4.7.0",
"httpx>=0.28.1",
"inflect>=7.4.0",
"pydantic>=2.10.3",
"rich>=13.9.4",
'tomli; python_version < "3.11"',
"typer>=0.15.1",
"uvloop>=0.21.0 ; sys_platform != 'win32'",
"winloop>=0.1.7 ; sys_platform == 'win32'",
]

[project.scripts]
Expand Down
2 changes: 1 addition & 1 deletion src/uv_secure/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.2.0"
__version__ = "0.2.1"
72 changes: 59 additions & 13 deletions src/uv_secure/dependency_checker/dependency_checker.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import asyncio
from collections.abc import Iterable
from pathlib import Path
import sys

from anyio import Path as APath
import inflect
from rich.console import Console
from rich.console import Console, ConsoleRenderable
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
Expand All @@ -11,20 +14,31 @@
from uv_secure.package_info import download_vulnerabilities, parse_uv_lock_file


def check_dependencies(uv_lock_path: Path, ignore_ids: list[str]) -> int:
if sys.platform in ("win32", "cygwin", "cli"):
from winloop import run
else:
# if we're on apple or linux do this instead
from uvloop import run


async def check_dependencies(
uv_lock_path: APath, ignore_ids: set[str]
) -> tuple[int, Iterable[ConsoleRenderable]]:
"""Checks dependencies for vulnerabilities and summarizes the results."""
console = Console()
console_outputs = []

if not uv_lock_path.exists():
console.print(f"[bold red]Error:[/] File {uv_lock_path} does not exist.")
if not await uv_lock_path.exists():
console_outputs.append(
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(
dependencies = await parse_uv_lock_file(uv_lock_path)
console_outputs.append(
f"[bold cyan]Checking {uv_lock_path} dependencies for vulnerabilities...[/]"
)

results = asyncio.run(download_vulnerabilities(dependencies))
results = await download_vulnerabilities(dependencies)

total_dependencies = len(results)
vulnerable_count = 0
Expand All @@ -44,7 +58,7 @@ def check_dependencies(uv_lock_path: Path, ignore_ids: list[str]) -> int:
vulnerable_plural = inf.plural("dependency", vulnerable_count)

if vulnerable_count > 0:
console.print(
console_outputs.append(
Panel.fit(
f"[bold red]Vulnerabilities detected![/]\n"
f"Checked: [bold]{total_dependencies}[/] {total_plural}\n"
Expand All @@ -71,14 +85,46 @@ def check_dependencies(uv_lock_path: Path, ignore_ids: list[str]) -> int:
)
table.add_row(dep.name, dep.version, vuln_id_hyperlink, vuln.details)

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

console.print(
console_outputs.append(
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
return 0, console_outputs # Exit successfully


async def process_lock_files(
uv_lock_paths: Iterable[Path], ignore_ids: set[str]
) -> Iterable[tuple[int, Iterable[ConsoleRenderable]]]:
status_output_tasks = [
check_dependencies(APath(uv_lock_path), ignore_ids)
for uv_lock_path in uv_lock_paths
]
return await asyncio.gather(*status_output_tasks)


def check_lock_files(uv_lock_paths: Iterable[Path], ignore_ids: set[str]) -> bool:
"""
Checks

Args:
uv_lock_paths: paths to uv_lock files
ignore_ids: Vulnerabilities IDs to ignore

Returns
-------
True if vulnerabilities were found, False otherwise.
"""
status_outputs = run(process_lock_files(uv_lock_paths, ignore_ids))
console = Console()
vulnerabilities_found = False
for status, console_output in status_outputs:
console.print(*console_output)
if status != 0:
vulnerabilities_found = True
return vulnerabilities_found
7 changes: 3 additions & 4 deletions src/uv_secure/package_info/lock_file_parser.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from pathlib import Path
import sys

from anyio import Path
from pydantic import BaseModel


Expand All @@ -16,10 +16,9 @@ class Dependency(BaseModel):
version: str


def parse_uv_lock_file(file_path: Path) -> list[Dependency]:
async 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)
data = toml.loads(await file_path.read_text())

package_data = data.get("package", [])
return [
Expand Down
11 changes: 5 additions & 6 deletions src/uv_secure/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import typer

from uv_secure.__version__ import __version__
from uv_secure.dependency_checker import check_dependencies
from uv_secure.dependency_checker.dependency_checker import check_lock_files


app = typer.Typer()
Expand Down Expand Up @@ -44,12 +44,11 @@ def main(
"""Parse uv.lock files, check vulnerabilities, and display summary."""
if not uv_lock_paths:
uv_lock_paths = [Path("./uv.lock")]
ignore_ids = [vuln_id.strip() for vuln_id in ignore.split(",") if vuln_id.strip()]
ignore_ids = {vuln_id.strip() for vuln_id in ignore.split(",") if vuln_id.strip()}

for uv_lock_path in uv_lock_paths:
status = check_dependencies(uv_lock_path, ignore_ids)
if status != 0:
raise typer.Exit(code=status)
vulnerabilities_found = check_lock_files(uv_lock_paths, ignore_ids)
if vulnerabilities_found:
raise typer.Exit(code=1)


if __name__ == "__main__":
Expand Down
57 changes: 57 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading