Skip to content
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
18 changes: 18 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,24 @@ permissions:
contents: read

jobs:
dependency-audit:
name: Audit Resolved Python Dependencies
runs-on: ubuntu-latest
timeout-minutes: 15
concurrency:
group: check-${{ github.workflow }}-${{ github.ref }}-dependency-audit
cancel-in-progress: true
steps:
- uses: actions/checkout@v6

- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: '3.13'

- name: Audit pyproject.toml and docs/requirements.txt
run: python scripts/audit_dependencies.py

codeql:
name: CodeQL (${{ matrix.language }})
runs-on: ubuntu-latest
Expand Down
4 changes: 4 additions & 0 deletions changelog.d/20260816_issue_58_dependency_audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Security

- Audit the complete resolved Python dependency set on pushes, pull requests,
and a weekly schedule with pinned `pip-audit` tooling.
120 changes: 120 additions & 0 deletions scripts/audit_dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Resolve and audit every Python dependency surface supported by the template."""

from __future__ import annotations

import subprocess
import sys
import tempfile
import tomllib
from pathlib import Path

PIP_AUDIT_VERSION = "2.10.1"
DEPENDENCY_SURFACES = ("pyproject.toml", "docs/requirements.txt")


def run(command: list[str], *, cwd: Path) -> str:
"""Run a command, failing the audit when dependency resolution fails."""
completed = subprocess.run(
command,
cwd=cwd,
check=True,
text=True,
stdout=subprocess.PIPE,
)
output = completed.stdout.strip()
if output:
print(output)
return output


def python_executable(venv: Path) -> Path:
"""Return the Python executable for a virtual environment."""
scripts_directory = "Scripts" if sys.platform == "win32" else "bin"
executable = "python.exe" if sys.platform == "win32" else "python"
return venv / scripts_directory / executable


def project_install_target(project_root: Path) -> str:
"""Build an install target that resolves every declared optional extra."""
pyproject_path = project_root / "pyproject.toml"
with pyproject_path.open("rb") as pyproject_file:
pyproject = tomllib.load(pyproject_file)
extras = sorted(pyproject.get("project", {}).get("optional-dependencies", {}))
return f".[{','.join(extras)}]" if extras else "."


def audit_dependencies(project_root: Path) -> None:
"""Resolve application dependencies in isolation and audit the result."""
missing = [
surface
for surface in DEPENDENCY_SURFACES
if not (project_root / surface).is_file()
]
if missing:
message = f"Unmapped or missing dependency surfaces: {', '.join(missing)}"
raise FileNotFoundError(message)

with tempfile.TemporaryDirectory(prefix="dependency-audit-") as temporary:
temporary_root = Path(temporary)
target_venv = temporary_root / "target"
audit_venv = temporary_root / "audit"
run([sys.executable, "-m", "venv", str(target_venv)], cwd=project_root)
run([sys.executable, "-m", "venv", str(audit_venv)], cwd=project_root)

target_python = python_executable(target_venv)
audit_python = python_executable(audit_venv)
run(
[
str(target_python),
"-m",
"pip",
"install",
project_install_target(project_root),
],
cwd=project_root,
)
run(
[
str(target_python),
"-m",
"pip",
"install",
"-r",
"docs/requirements.txt",
],
cwd=project_root,
)
run(
[
str(audit_python),
"-m",
"pip",
"install",
f"pip-audit=={PIP_AUDIT_VERSION}",
],
cwd=project_root,
)
site_packages = run(
[
str(target_python),
"-c",
"import sysconfig; print(sysconfig.get_paths()['purelib'])",
],
cwd=project_root,
)
run(
[
str(audit_python),
"-m",
"pip_audit",
"--path",
site_packages,
"--skip-editable",
],
cwd=project_root,
)


if __name__ == "__main__":
audit_dependencies(Path(__file__).resolve().parents[1])
25 changes: 25 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ def test_security_workflow_scans_code_actions_and_dependencies() -> None:
workflow = read_workflow("security.yml")
codeql_job = workflow_job_block(workflow, "codeql")
dependency_job = workflow_job_block(workflow, "dependency-review")
audit_job = workflow_job_block(workflow, "dependency-audit")

assert "branches: [main]" in workflow
assert "pull_request:" in workflow
Expand All @@ -135,6 +136,30 @@ def test_security_workflow_scans_code_actions_and_dependencies() -> None:
assert "fail-on-severity: high" in dependency_job
assert "comment-summary-in-pr: on-failure" in dependency_job

assert "timeout-minutes: 15" in audit_job
assert "uses: actions/checkout@v6" in audit_job
assert "uses: actions/setup-python@v6" in audit_job
assert "python scripts/audit_dependencies.py" in audit_job
assert "if: github.event_name == 'pull_request'" not in audit_job

audit_script = (ROOT / "scripts" / "audit_dependencies.py").read_text(
encoding="utf-8"
)
assert 'PIP_AUDIT_VERSION = "2.10.1"' in audit_script


def test_dependency_audit_maps_every_declared_surface() -> None:
"""Every dependency declaration in the template must be audited."""
script = (ROOT / "scripts" / "audit_dependencies.py").read_text(encoding="utf-8")
dependency_surfaces = [ROOT / "pyproject.toml", *ROOT.rglob("requirements*.txt")]

assert dependency_surfaces
for surface in dependency_surfaces:
relative_surface = surface.relative_to(ROOT).as_posix()
assert (
relative_surface in script
), f"Dependency surface {relative_surface!r} has no audit mapping"


def test_links_workflow_fails_for_every_broken_live_link() -> None:
"""Archived snapshots must not make broken live links pass validation."""
Expand Down
Loading