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
2 changes: 1 addition & 1 deletion src/python_security_auditing/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def main() -> None:
settings = Settings()

if settings.debug:
print(f"[debug] settings: {settings.model_dump()}", file=sys.stderr)
print(f"[debug] settings: {settings.model_dump(exclude={'github_token'})}", file=sys.stderr)

bandit_report: dict[str, Any] = {}
pip_audit_report: list[dict[str, Any]] = []
Expand Down
25 changes: 17 additions & 8 deletions src/python_security_auditing/pr_comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,21 @@
from __future__ import annotations

import json
import shutil
import subprocess
import sys

from .settings import Settings


def _resolve_exe(name: str) -> str:
"""Resolve an executable name to its full path via PATH, raising if not found."""
resolved = shutil.which(name)
if resolved is None:
raise FileNotFoundError(f"Required tool not found on PATH: {name!r}")
return resolved


def comment_marker(workflow: str) -> str:
if workflow:
return f"<!-- security-scan-results::{workflow} -->"
Expand All @@ -23,9 +32,9 @@
if not settings.github_head_ref or not settings.github_repository:
return None

result = subprocess.run(
result = subprocess.run( # nosec B603,B605 -- list args, full path via _resolve_exe()
[

Check notice

Code scanning / Bandit

subprocess call - check for execution of untrusted input. Note

subprocess call - check for execution of untrusted input.
"gh",
_resolve_exe("gh"),
"pr",
"list",
"--head",
Expand Down Expand Up @@ -64,8 +73,8 @@

# Find an existing comment with our marker
existing_id: int | None = None
list_result = subprocess.run(
["gh", "api", f"repos/{repo}/issues/{pr_number}/comments"],
list_result = subprocess.run( # nosec B603,B605 -- list args, full path via _resolve_exe()
[_resolve_exe("gh"), "api", f"repos/{repo}/issues/{pr_number}/comments"],
Comment thread Dismissed
capture_output=True,
text=True,
)
Expand All @@ -76,9 +85,9 @@
break

if existing_id is not None:
subprocess.run(
subprocess.run( # nosec B603,B605 -- list args, full path via _resolve_exe()
[

Check notice

Code scanning / Bandit

subprocess call - check for execution of untrusted input. Note

subprocess call - check for execution of untrusted input.
"gh",
_resolve_exe("gh"),
"api",
"--method",
"PATCH",
Expand All @@ -89,7 +98,7 @@
check=True,
)
else:
subprocess.run(
["gh", "pr", "comment", str(pr_number), "--body", body, "--repo", repo],
subprocess.run( # nosec B603,B605 -- list args, full path via _resolve_exe()
[_resolve_exe("gh"), "pr", "comment", str(pr_number), "--body", body, "--repo", repo],
Comment thread Dismissed
check=True,
)
103 changes: 72 additions & 31 deletions src/python_security_auditing/runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import json
import shutil
import subprocess
import sys
import tempfile
Expand All @@ -12,6 +13,14 @@
from .settings import Settings


def _resolve_exe(name: str) -> str:
"""Resolve an executable name to its full path via PATH, raising if not found."""
resolved = shutil.which(name)
if resolved is None:
raise FileNotFoundError(f"Required tool not found on PATH: {name!r}")
return resolved


def generate_requirements(settings: Settings) -> Path:
"""Return a requirements.txt Path suitable for pip-audit.

Expand All @@ -29,56 +38,88 @@
out_path = Path(tmp.name)

if pm == "uv":
cmd = ["uv", "export", "--format", "requirements-txt", "--no-hashes", "-o", str(out_path)]
cmd = [
_resolve_exe("uv"),
"export",
"--format",
"requirements-txt",
"--no-hashes",
"-o",
str(out_path),
]
if settings.debug:
print(f"[debug] uv export command: {cmd}", file=sys.stderr)
subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
)
try:
subprocess.run( # nosec B603,B605 -- list args, full path via _resolve_exe()
cmd,
Comment thread Dismissed
check=True,
capture_output=True,
text=True,
)
except subprocess.CalledProcessError as exc:
print(
f"uv export failed (no lockfile?): {exc.stderr.strip()}",
file=sys.stderr,
)
return out_path
if settings.debug:
print(
f"[debug] generated requirements ({out_path}):\n{out_path.read_text()}",
file=sys.stderr,
)
elif pm == "pip":
result = subprocess.run(["pip", "freeze"], capture_output=True, text=True, check=True)
result = subprocess.run( # nosec B603,B605 -- list args, full path via _resolve_exe()
[_resolve_exe("pip"), "freeze"], capture_output=True, text=True, check=True
Comment thread Dismissed
)
out_path.write_text(result.stdout)
if settings.debug:
print(f"[debug] pip freeze output ({out_path}):\n{result.stdout}", file=sys.stderr)
elif pm == "poetry":
subprocess.run(
["poetry", "self", "add", "poetry-plugin-export"],
check=True,
capture_output=True,
text=True,
)
subprocess.run(
[
"poetry",
"export",
"--format",
"requirements.txt",
"--without-hashes",
"-o",
str(out_path),
],
check=True,
# poetry-plugin-export is bundled in Poetry 1.8+; ignore failure here
subprocess.run( # nosec B603,B605 -- list args, full path via _resolve_exe()
[_resolve_exe("poetry"), "self", "add", "poetry-plugin-export"],
Comment thread Dismissed
check=False,
capture_output=True,
text=True,
)
try:
subprocess.run( # nosec B603,B605 -- list args, full path via _resolve_exe()
[
Comment thread Dismissed
_resolve_exe("poetry"),
"export",
"--format",
"requirements.txt",
"--without-hashes",
"-o",
str(out_path),
],
check=True,
capture_output=True,
text=True,
)
except subprocess.CalledProcessError as exc:
print(
f"poetry export failed (no lockfile?): {exc.stderr.strip()}",
file=sys.stderr,
)
return out_path
if settings.debug:
print(
f"[debug] poetry export output ({out_path}):\n{out_path.read_text()}",
file=sys.stderr,
)
elif pm == "pipenv":
result = subprocess.run(
["pipenv", "requirements"], capture_output=True, text=True, check=True
)
out_path.write_text(result.stdout)
try:
result = subprocess.run( # nosec B603,B605 -- list args, full path via _resolve_exe()
[_resolve_exe("pipenv"), "requirements"], capture_output=True, text=True, check=True
Comment thread Dismissed
)
out_path.write_text(result.stdout)
except subprocess.CalledProcessError as exc:
print(
f"pipenv requirements failed (no lockfile?): {exc.stderr.strip()}",
file=sys.stderr,
)
return out_path
if settings.debug:
print(
f"[debug] pipenv requirements output ({out_path}):\n{result.stdout}",
Expand Down Expand Up @@ -135,12 +176,12 @@
) -> list[dict[str, Any]]:
"""Run pip-audit, write pip-audit-report.json, return parsed report."""
output_file = Path("pip-audit-report.json")
cmd = ["pip-audit", "-r", str(requirements_path), "-f", "json"]
cmd = [_resolve_exe("pip-audit"), "-r", str(requirements_path), "--no-deps", "-f", "json"]

if settings and settings.debug:
print(f"[debug] pip-audit command: {cmd}", file=sys.stderr)

result = subprocess.run(cmd, capture_output=True, text=True)
result = subprocess.run(cmd, capture_output=True, text=True) # nosec B603,B605 -- list args, full path via _resolve_exe()
Comment thread Dismissed
# pip-audit exits 1 when vulnerabilities are found — that is expected
if result.returncode not in (0, 1):
print(
Expand Down
35 changes: 35 additions & 0 deletions src/python_security_auditing/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import re
from pathlib import Path
from typing import Literal

from pydantic import field_validator
Expand Down Expand Up @@ -61,7 +63,40 @@ def _empty_str_to_none(cls, v: object) -> object:
return None
return v

@field_validator("bandit_sarif_path", "requirements_file", "github_step_summary", mode="after")
@classmethod
def _no_path_traversal(cls, v: str) -> str:
if v and ".." in Path(v).parts:
raise ValueError(f"Path traversal not allowed: {v!r}")
return v

@field_validator("github_repository", mode="after")
@classmethod
def _validate_repository_format(cls, v: str) -> str:
if not v:
return v
if ".." in v or v.startswith("/") or v.count("/") != 1:
raise ValueError(f"github_repository must be 'owner/repo' format, got: {v!r}")
return v

@field_validator("github_run_id", mode="after")
@classmethod
def _validate_run_id(cls, v: str) -> str:
if v and not v.isdigit():
raise ValueError(f"github_run_id must be numeric, got: {v!r}")
return v

github_head_ref: str = "" # Branch name for PRs

@field_validator("github_head_ref", mode="after")
@classmethod
def _validate_head_ref(cls, v: str) -> str:
if v and not re.fullmatch(r"[a-zA-Z0-9._/\-]+", v):
raise ValueError(
f"github_head_ref contains invalid characters for a branch name: {v!r}"
)
return v

github_workflow: str = "" # Name of the running workflow
github_step_summary: str = "" # Path to step summary file

Expand Down
65 changes: 65 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Tests for __main__.py orchestrator."""

from __future__ import annotations

import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
from python_security_auditing.__main__ import main

FIXTURES = Path(__file__).parent / "fixtures"


def test_main_succeeds_when_uv_lockfile_missing_and_no_bandit_issues(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
sarif_path = tmp_path / "results.sarif"
sarif_path.write_text((FIXTURES / "bandit_clean.sarif").read_text())
monkeypatch.setenv("PACKAGE_MANAGER", "uv")
monkeypatch.setenv("TOOLS", "bandit,pip-audit")
monkeypatch.setenv("BANDIT_SARIF_PATH", str(sarif_path))
monkeypatch.setenv("POST_PR_COMMENT", "false")
monkeypatch.chdir(tmp_path)

uv_exc = subprocess.CalledProcessError(2, "uv", stderr="No uv.lock found")

def mock_subprocess(cmd: list[str], **kwargs: object) -> MagicMock:
if cmd[0] == "uv":
raise uv_exc
return MagicMock(returncode=0, stderr="", stdout="[]")

with (
patch("python_security_auditing.runners.shutil.which", side_effect=lambda exe: exe),
patch("python_security_auditing.runners.subprocess.run", side_effect=mock_subprocess),
):
main() # should return normally without calling sys.exit(1)


def test_main_fails_when_bandit_blocks_despite_missing_lockfile(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
sarif_path = tmp_path / "results.sarif"
sarif_path.write_text((FIXTURES / "bandit_issues.sarif").read_text())
monkeypatch.setenv("PACKAGE_MANAGER", "uv")
monkeypatch.setenv("TOOLS", "bandit,pip-audit")
monkeypatch.setenv("BANDIT_SARIF_PATH", str(sarif_path))
monkeypatch.setenv("BANDIT_SEVERITY_THRESHOLD", "high")
monkeypatch.setenv("POST_PR_COMMENT", "false")
monkeypatch.chdir(tmp_path)

uv_exc = subprocess.CalledProcessError(2, "uv", stderr="No uv.lock found")

def mock_subprocess(cmd: list[str], **kwargs: object) -> MagicMock:
if cmd[0] == "uv":
raise uv_exc
return MagicMock(returncode=0, stderr="", stdout="[]")

with (
patch("python_security_auditing.runners.shutil.which", side_effect=lambda exe: exe),
patch("python_security_auditing.runners.subprocess.run", side_effect=mock_subprocess),
):
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1
Loading
Loading