Skip to content

Commit

Permalink
Make requests and file IO more asynchronous (#10)
Browse files Browse the repository at this point in the history
* Created top level check_lock_files function to handle checking all lock files - and moved all the console printing into that top level function.

* Added AnyIO dependency and made all file IO function asynchronous

* Added uvloop and winloop dependencies

* Making some README tweaks to related work and roadmap

* Minor README tweaks

* Bumping patch version so I can release
  • Loading branch information
owenlamont authored Dec 25, 2024
1 parent 51c312a commit 38fd6c0
Show file tree
Hide file tree
Showing 8 changed files with 142 additions and 33 deletions.
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.

0 comments on commit 38fd6c0

Please sign in to comment.