Skip to content

Commit

Permalink
Mono repo support (#13)
Browse files Browse the repository at this point in the history
* Bump version number, dependencies, and linters

* Started work to support directory scanning for config files

Added stub get_config_lock_file_pairs function and helper find_files function. Added Asyncer dependency.

* Add support for scanning a root directory for uv.lock files and hierarchical config files

* Allow hierarchical configuration to be used when passing multiple lock file arguments

* Updating command help and README documentation

* Updating README to describe configuration discovery

* Remove some superfluous comments

* Added test case for valid file name that doesn't exist
  • Loading branch information
owenlamont authored Jan 5, 2025
1 parent 3f9add6 commit 97fbc55
Show file tree
Hide file tree
Showing 12 changed files with 364 additions and 57 deletions.
14 changes: 7 additions & 7 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,29 +33,29 @@ repos:
- id: markdownlint
args: [--fix]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.14.0
rev: v1.14.1
hooks:
- id: mypy
additional_dependencies:
- pydantic
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.8.4
rev: v0.8.5
hooks:
- id: ruff-format
types_or: [python, pyi, jupyter]
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
args: [ --fix, --exit-non-zero-on-fix ]
types_or: [ python, pyi, jupyter ]
- id: ruff-format
types_or: [python, pyi, jupyter]
- repo: https://github.com/crate-ci/typos
rev: v1.28.4
rev: v1.29.4
hooks:
- id: typos
args: [
--force-exclude,
# --write-changes (Don't use this to stop typos making auto-corrections)
]
- repo: https://github.com/owenlamont/uv-secure
rev: 0.2.1
rev: 0.2.2
hooks:
- id: uv-secure
- repo: https://github.com/adrienverge/yamllint.git
Expand Down
40 changes: 32 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ After installation, you can run uv-secure --help to see the options.
Parse uv.lock files, check vulnerabilities, and display summary.
╭─ Arguments ──────────────────────────────────────────────────────────────────────────╮
│ uv_lock_paths [UV_LOCK_PATHS]... Paths to the uv.lock files [default: None] │
│ uv_lock_paths [UV_LOCK_PATHS]... Paths to the uv.lock files or a single │
│ project root level directory (defaults to │
│ working directory if not set) │
│ [default: None] │
╰──────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────────────╮
│ --ignore -i TEXT Comma-separated list of vulnerability IDs to │
Expand All @@ -75,9 +78,6 @@ After installation, you can run uv-secure --help to see the options.
╰──────────────────────────────────────────────────────────────────────────────────────╯
```

By default, if run with no arguments uv-secure will look for a uv.lock file in the
current working directory and scan that for known vulnerabilities. E.g.

```text
>> uv-secure
Checking dependencies for vulnerabilities...
Expand Down Expand Up @@ -105,14 +105,41 @@ ignore_vulnerabilities = ["VULN-123"]
ignore_vulnerabilities = ["VULN-123"]
```

### Configuration discovery

If the ignore and config options are left unset uv-secure will search for configuration
files above each uv.lock file and use the deepest found pyproject.toml, uv-secure.toml,
or .uv-secure.toml for the configuration when processing that specific uv.lock file.
uv-secure tries to follow
[Ruff's configuration file discovery strategy](https://docs.astral.sh/ruff/configuration/#config-file-discovery)

Similar to Ruff, pyproject.toml files that don't contain uv-secure configuration are
ignored. Currently if multiple uv-secure configuration files are defined in the same
directory upstream from a uv.lock file the configurations are used in this precedence
order:

1. .uv-secure.toml
2. uv-secure.toml
3. pyproject.toml (assuming it contains uv-secure configuration)

So .uv-secure.toml files are used first, then uv-secure.toml files, and last
pyproject.toml files with uv-secure config (only if you define all three in the same
directory though - which would be a bit weird - I may make this a warning or error in
future).

Like Ruff configuration files aren't hierarchically combined, just the nearest / highest
precedence configuration is used. If you do specify configuration options directly using
the --ignore option or pass a specific configuration file those take precedence and
hierarchical configuration file discovery is disabled.

## Pre-commit Usage

uv-secure can be run as a pre-commit hook by adding this configuration to your
.pre-commit-config.yaml file:

```yaml
- repo: https://github.com/owenlamont/uv-secure
rev: 0.2.0
rev: 0.3.0
hooks:
- id: uv-secure
```
Expand All @@ -132,9 +159,6 @@ from where pre-commit is run.

Below are some ideas (in no particular order) I have for improving uv-secure:

- Support reading configuration from pyproject.toml
- Support searching a directory for all uv.lock files
- 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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ classifiers = [

dependencies = [
"anyio>=4.7.0",
"asyncer>=0.0.8",
"httpx>=0.28.1",
"inflect>=7.4.0",
"pydantic>=2.10.3",
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.2"
__version__ = "0.3.0"
7 changes: 4 additions & 3 deletions src/uv_secure/configuration/config_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@


class Configuration(BaseModel):
ignore_vulnerabilities: set[str] = Field(default_factory=set)
model_config = {"frozen": True}
ignore_vulnerabilities: tuple[str, ...] = Field(default_factory=tuple)


def config_cli_arg_factory(ignore: Optional[str] = None) -> Configuration:
Expand All @@ -26,9 +27,9 @@ def config_cli_arg_factory(ignore: Optional[str] = None) -> Configuration:
uv-secure configuration object
"""
ignore_vulnerabilities = (
{vuln_id.strip() for vuln_id in ignore.split(",") if vuln_id.strip()}
tuple(vuln_id.strip() for vuln_id in ignore.split(",") if vuln_id.strip())
if ignore is not None
else set()
else tuple() # noqa: C408
)
return Configuration(ignore_vulnerabilities=ignore_vulnerabilities)

Expand Down
8 changes: 6 additions & 2 deletions src/uv_secure/dependency_checker/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
from uv_secure.dependency_checker.dependency_checker import check_dependencies
from uv_secure.dependency_checker.dependency_checker import (
check_dependencies,
check_lock_files,
RunStatus,
)


__all__ = ["check_dependencies"]
__all__ = ["RunStatus", "check_dependencies", "check_lock_files"]
68 changes: 49 additions & 19 deletions src/uv_secure/dependency_checker/dependency_checker.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
from collections.abc import Iterable
from collections.abc import Iterable, Sequence
from enum import Enum
from pathlib import Path
from typing import Optional

Expand All @@ -15,6 +16,7 @@
config_file_factory,
Configuration,
)
from uv_secure.directory_scanner import get_lock_to_config_map
from uv_secure.package_info import download_vulnerabilities, parse_uv_lock_file


Expand All @@ -28,7 +30,7 @@ async def check_dependencies(
console_outputs.append(
f"[bold red]Error:[/] File {uv_lock_path} does not exist."
)
return 1, console_outputs
return 2, console_outputs

dependencies = await parse_uv_lock_file(uv_lock_path)
console_outputs.append(
Expand Down Expand Up @@ -97,41 +99,69 @@ async def check_dependencies(
return 0, console_outputs # Exit successfully


class RunStatus(Enum):
NO_VULNERABILITIES = (0,)
VULNERABILITIES_FOUND = 1
RUNTIME_ERROR = 2


async def check_lock_files(
uv_lock_paths: Optional[Iterable[Path]],
file_paths: Optional[Sequence[Path]],
ignore: Optional[str],
config_path: Optional[Path],
) -> bool:
) -> RunStatus:
"""Checks
Args:
uv_lock_paths: paths to uv_lock files
file_paths: paths to files or directory to process
ignore_ids: Vulnerabilities IDs to ignore
Returns
-------
True if vulnerabilities were found, False otherwise.
"""
if not uv_lock_paths:
uv_lock_paths = [Path("./uv.lock")]

if ignore is not None:
config = config_cli_arg_factory(ignore)
elif config_path is not None:
possible_config = await config_file_factory(APath(config_path))
config = possible_config if possible_config is not None else Configuration()
if not file_paths:
file_paths = (Path("."),)

console = Console()
if len(file_paths) == 1 and file_paths[0].is_dir():
lock_to_config_map = await get_lock_to_config_map(APath(file_paths[0]))
file_paths = tuple(lock_to_config_map.keys())
else:
config = Configuration()
if ignore is not None:
config = config_cli_arg_factory(ignore)
lock_to_config_map = {APath(file): config for file in file_paths}
elif config_path is not None:
possible_config = await config_file_factory(APath(config_path))
config = possible_config if possible_config is not None else Configuration()
lock_to_config_map = {APath(file): config for file in file_paths}
elif all(file_path.name == "uv.lock" for file_path in file_paths):
lock_to_config_map = await get_lock_to_config_map(
[APath(file_path) for file_path in file_paths]
)
file_paths = tuple(lock_to_config_map.keys())
else:
console.print(
"[bold red]Error:[/] file_paths must either reference a single "
"project root directory or a sequence of uv.lock file paths"
)
return RunStatus.RUNTIME_ERROR

status_output_tasks = [
check_dependencies(APath(uv_lock_path), config)
for uv_lock_path in uv_lock_paths
check_dependencies(APath(uv_lock_path), lock_to_config_map[APath(uv_lock_path)])
for uv_lock_path in file_paths
]
status_outputs = await asyncio.gather(*status_output_tasks)
console = Console()
vulnerabilities_found = False
runtime_error = False
for status, console_output in status_outputs:
console.print(*console_output)
if status != 0:
if status == 1:
vulnerabilities_found = True
return vulnerabilities_found
elif status == 2:
runtime_error = True
if runtime_error:
return RunStatus.RUNTIME_ERROR
if vulnerabilities_found:
return RunStatus.VULNERABILITIES_FOUND
return RunStatus.NO_VULNERABILITIES
4 changes: 4 additions & 0 deletions src/uv_secure/directory_scanner/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from uv_secure.directory_scanner.directory_scanner import get_lock_to_config_map


__all__ = ["get_lock_to_config_map"]
104 changes: 104 additions & 0 deletions src/uv_secure/directory_scanner/directory_scanner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
from collections.abc import Iterable
from typing import Union

from anyio import Path
from asyncer import create_task_group

from uv_secure.configuration import config_file_factory, Configuration


async def search_file(directory: Path, filename: str) -> list[Path]:
return [file_path async for file_path in directory.glob(f"**/{filename}")]


async def _find_files(
directory: Path, filenames: Iterable[str]
) -> dict[str, list[Path]]:
async with create_task_group() as tg:
tasks = {
filename: tg.soonify(search_file)(directory, filename)
for filename in filenames
}

return {filename: task.value for filename, task in tasks.items()}


async def _get_root_dir(file_paths: Iterable[Path]) -> Path:
async with create_task_group() as tg:
tasks = [tg.soonify(path.resolve)() for path in file_paths]

resolved_paths = [task.value for task in tasks]
if len(resolved_paths) == 1:
return resolved_paths[0].parent

split_paths = [list(rp.parts) for rp in resolved_paths]
min_length = min(len(parts) for parts in split_paths)
common_prefix_len = 0

for part_idx in range(min_length):
segment_set = {parts[part_idx] for parts in split_paths}
if len(segment_set) == 1:
common_prefix_len += 1
else:
break

common_parts = split_paths[0][:common_prefix_len]
return Path(*common_parts)


async def get_lock_to_config_map(
file_paths: Union[Path, list[Path]],
) -> dict[Path, Configuration]:
"""Get map of uv.lock files to their configurations.
Using provided uv.lock files or root directory discover the uv.lock files and also
find and map the nearest parent configuration for each uv.lock file.
Args:
file_paths: A list of uv.lock files or root directory
Returns:
A dictionary mapping uv.lock files to their nearest Configuration
"""
if type(file_paths) is Path:
root_dir = file_paths
config_and_lock_files = await _find_files(
root_dir, ["pyproject.toml", "uv-secure.toml", ".uv-secure.toml", "uv.lock"]
)
else:
root_dir = await _get_root_dir(file_paths)
config_and_lock_files = await _find_files(
root_dir, ["pyproject.toml", "uv-secure.toml", ".uv-secure.toml"]
)
config_and_lock_files["uv.lock"] = file_paths

config_file_paths = (
config_and_lock_files["pyproject.toml"]
+ config_and_lock_files["uv-secure.toml"]
+ config_and_lock_files[".uv-secure.toml"]
)

async with create_task_group() as tg:
config_futures = [
tg.soonify(config_file_factory)(path) for path in config_file_paths
]
configs = [future.value for future in config_futures]
path_config_map = {
p.parent: c for p, c in zip(config_file_paths, configs) if c is not None
}

lock_file_paths = config_and_lock_files.get("uv.lock", [])
lock_to_config_map: dict[Path, Configuration] = {}
default_config = Configuration()
for lock_file in lock_file_paths:
current_dir = lock_file.parent
while True:
found_config = path_config_map.get(current_dir)
if found_config is not None or current_dir == root_dir:
break
current_dir = current_dir.parent

if found_config is None:
found_config = default_config
lock_to_config_map[lock_file] = found_config
return lock_to_config_map
Loading

0 comments on commit 97fbc55

Please sign in to comment.