diff --git a/src/python_security_auditing/__main__.py b/src/python_security_auditing/__main__.py index c4a4e8f..1910a3b 100644 --- a/src/python_security_auditing/__main__.py +++ b/src/python_security_auditing/__main__.py @@ -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]] = [] diff --git a/src/python_security_auditing/pr_comment.py b/src/python_security_auditing/pr_comment.py index f4077ff..f5fcf1f 100644 --- a/src/python_security_auditing/pr_comment.py +++ b/src/python_security_auditing/pr_comment.py @@ -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"" @@ -23,9 +32,9 @@ def resolve_pr_number(settings: Settings) -> int | None: 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() [ - "gh", + _resolve_exe("gh"), "pr", "list", "--head", @@ -64,8 +73,8 @@ def upsert_pr_comment(markdown: str, settings: Settings) -> None: # 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"], capture_output=True, text=True, ) @@ -76,9 +85,9 @@ def upsert_pr_comment(markdown: str, settings: Settings) -> None: break if existing_id is not None: - subprocess.run( + subprocess.run( # nosec B603,B605 -- list args, full path via _resolve_exe() [ - "gh", + _resolve_exe("gh"), "api", "--method", "PATCH", @@ -89,7 +98,7 @@ def upsert_pr_comment(markdown: str, settings: Settings) -> None: 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], check=True, ) diff --git a/src/python_security_auditing/runners.py b/src/python_security_auditing/runners.py index e1eed65..8accbe2 100644 --- a/src/python_security_auditing/runners.py +++ b/src/python_security_auditing/runners.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import shutil import subprocess import sys import tempfile @@ -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. @@ -29,56 +38,88 @@ def generate_requirements(settings: Settings) -> Path: 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, + 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 + ) 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"], + check=False, capture_output=True, text=True, ) + try: + subprocess.run( # nosec B603,B605 -- list args, full path via _resolve_exe() + [ + _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 + ) + 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}", @@ -135,12 +176,12 @@ def run_pip_audit( ) -> 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() # pip-audit exits 1 when vulnerabilities are found — that is expected if result.returncode not in (0, 1): print( diff --git a/src/python_security_auditing/settings.py b/src/python_security_auditing/settings.py index 2d8c254..bb447f7 100644 --- a/src/python_security_auditing/settings.py +++ b/src/python_security_auditing/settings.py @@ -2,6 +2,8 @@ from __future__ import annotations +import re +from pathlib import Path from typing import Literal from pydantic import field_validator @@ -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 diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..d365574 --- /dev/null +++ b/tests/test_main.py @@ -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 diff --git a/tests/test_pr_comment.py b/tests/test_pr_comment.py index 20f494b..5b36edb 100644 --- a/tests/test_pr_comment.py +++ b/tests/test_pr_comment.py @@ -64,10 +64,13 @@ def test_upsert_creates_new_comment(monkeypatch: pytest.MonkeyPatch) -> None: s = Settings() list_response = MagicMock(returncode=0, stdout=json.dumps([])) - with patch( - "python_security_auditing.pr_comment.subprocess.run", - side_effect=[list_response, MagicMock()], - ) as mock_run: + with ( + patch("python_security_auditing.pr_comment.shutil.which", side_effect=lambda exe: exe), + patch( + "python_security_auditing.pr_comment.subprocess.run", + side_effect=[list_response, MagicMock()], + ) as mock_run, + ): upsert_pr_comment("# Report", s) create_call = mock_run.call_args_list[1] @@ -87,10 +90,13 @@ def test_upsert_updates_existing_comment(monkeypatch: pytest.MonkeyPatch) -> Non existing = [{"id": 99, "body": "\nold"}] list_response = MagicMock(returncode=0, stdout=json.dumps(existing)) - with patch( - "python_security_auditing.pr_comment.subprocess.run", - side_effect=[list_response, MagicMock()], - ) as mock_run: + with ( + patch("python_security_auditing.pr_comment.shutil.which", side_effect=lambda exe: exe), + patch( + "python_security_auditing.pr_comment.subprocess.run", + side_effect=[list_response, MagicMock()], + ) as mock_run, + ): upsert_pr_comment("# Report", s) patch_call = mock_run.call_args_list[1] @@ -110,13 +116,27 @@ def test_upsert_does_not_match_different_workflow(monkeypatch: pytest.MonkeyPatc other_workflow_comment = [{"id": 99, "body": "\nold"}] list_response = MagicMock(returncode=0, stdout=json.dumps(other_workflow_comment)) - with patch( - "python_security_auditing.pr_comment.subprocess.run", - side_effect=[list_response, MagicMock()], - ) as mock_run: + with ( + patch("python_security_auditing.pr_comment.shutil.which", side_effect=lambda exe: exe), + patch( + "python_security_auditing.pr_comment.subprocess.run", + side_effect=[list_response, MagicMock()], + ) as mock_run, + ): upsert_pr_comment("# Report", s) # Must create a new comment, not PATCH the existing one create_call = mock_run.call_args_list[1] cmd: list[str] = create_call[0][0] assert cmd[:3] == ["gh", "pr", "comment"] + + +def test_upsert_raises_when_gh_not_found(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("POST_PR_COMMENT", "true") + monkeypatch.setenv("GITHUB_TOKEN", "tok") + monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo") + monkeypatch.setenv("PR_NUMBER", "42") + s = Settings() + with patch("python_security_auditing.pr_comment.shutil.which", return_value=None): + with pytest.raises(FileNotFoundError, match="gh"): + upsert_pr_comment("# Report", s) diff --git a/tests/test_runners.py b/tests/test_runners.py index 5b18c98..4c0e22d 100644 --- a/tests/test_runners.py +++ b/tests/test_runners.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import subprocess from pathlib import Path from unittest.mock import MagicMock, patch @@ -29,7 +30,10 @@ def test_uv_mode_calls_uv_export(monkeypatch: pytest.MonkeyPatch, tmp_path: Path monkeypatch.setenv("PACKAGE_MANAGER", "uv") s = Settings() - with patch("python_security_auditing.runners.subprocess.run") as mock_run: + with ( + patch("python_security_auditing.runners.shutil.which", side_effect=lambda exe: exe), + patch("python_security_auditing.runners.subprocess.run") as mock_run, + ): mock_run.return_value = MagicMock(returncode=0) result = generate_requirements(s) @@ -46,7 +50,10 @@ def test_pip_mode_calls_pip_freeze(monkeypatch: pytest.MonkeyPatch) -> None: s = Settings() freeze_output = "requests==2.31.0\n" - with patch("python_security_auditing.runners.subprocess.run") as mock_run: + with ( + patch("python_security_auditing.runners.shutil.which", side_effect=lambda exe: exe), + patch("python_security_auditing.runners.subprocess.run") as mock_run, + ): mock_run.return_value = MagicMock(returncode=0, stdout=freeze_output) result = generate_requirements(s) @@ -59,7 +66,10 @@ def test_poetry_mode_calls_poetry_export(monkeypatch: pytest.MonkeyPatch) -> Non monkeypatch.setenv("PACKAGE_MANAGER", "poetry") s = Settings() - with patch("python_security_auditing.runners.subprocess.run") as mock_run: + with ( + patch("python_security_auditing.runners.shutil.which", side_effect=lambda exe: exe), + patch("python_security_auditing.runners.subprocess.run") as mock_run, + ): mock_run.return_value = MagicMock(returncode=0) result = generate_requirements(s) @@ -77,7 +87,10 @@ def test_pipenv_mode_calls_pipenv_requirements(monkeypatch: pytest.MonkeyPatch) s = Settings() pipenv_output = "requests==2.31.0\n" - with patch("python_security_auditing.runners.subprocess.run") as mock_run: + with ( + patch("python_security_auditing.runners.shutil.which", side_effect=lambda exe: exe), + patch("python_security_auditing.runners.subprocess.run") as mock_run, + ): mock_run.return_value = MagicMock(returncode=0, stdout=pipenv_output) result = generate_requirements(s) @@ -155,7 +168,10 @@ def test_run_pip_audit_parses_json(tmp_path: Path, monkeypatch: pytest.MonkeyPat deps = json.loads((FIXTURES / "pip_audit_fixable.json").read_text()) fixture_text = json.dumps({"dependencies": deps, "fixes": []}) - with patch("python_security_auditing.runners.subprocess.run") as mock_run: + with ( + patch("python_security_auditing.runners.shutil.which", side_effect=lambda exe: exe), + patch("python_security_auditing.runners.subprocess.run") as mock_run, + ): mock_run.return_value = MagicMock(returncode=1, stderr="", stdout=fixture_text) report = run_pip_audit(Path("requirements.txt")) @@ -170,7 +186,10 @@ def test_run_pip_audit_returns_empty_on_no_output( ) -> None: monkeypatch.chdir(tmp_path) - with patch("python_security_auditing.runners.subprocess.run") as mock_run: + with ( + patch("python_security_auditing.runners.shutil.which", side_effect=lambda exe: exe), + patch("python_security_auditing.runners.subprocess.run") as mock_run, + ): mock_run.return_value = MagicMock(returncode=0, stderr="", stdout="") report = run_pip_audit(Path("requirements.txt")) @@ -183,7 +202,10 @@ def test_run_pip_audit_uses_requirements_path( monkeypatch.chdir(tmp_path) req_path = tmp_path / "custom-reqs.txt" - with patch("python_security_auditing.runners.subprocess.run") as mock_run: + with ( + patch("python_security_auditing.runners.shutil.which", side_effect=lambda exe: exe), + patch("python_security_auditing.runners.subprocess.run") as mock_run, + ): mock_run.return_value = MagicMock(returncode=0, stderr="", stdout="[]") run_pip_audit(req_path) @@ -191,3 +213,108 @@ def test_run_pip_audit_uses_requirements_path( assert str(req_path) in cmd assert "-f" in cmd assert "json" in cmd + + +def test_run_pip_audit_command_includes_no_deps( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + with ( + patch("python_security_auditing.runners.shutil.which", side_effect=lambda exe: exe), + patch("python_security_auditing.runners.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0, stderr="", stdout="[]") + run_pip_audit(Path("requirements.txt")) + cmd = mock_run.call_args[0][0] + assert "--no-deps" in cmd + + +# --------------------------------------------------------------------------- +# generate_requirements — missing lockfile handling +# --------------------------------------------------------------------------- + + +def test_generate_requirements_uv_returns_empty_on_missing_lockfile( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PACKAGE_MANAGER", "uv") + s = Settings() + exc = subprocess.CalledProcessError(2, "uv", stderr="No uv.lock found") + with ( + patch("python_security_auditing.runners.shutil.which", side_effect=lambda exe: exe), + patch("python_security_auditing.runners.subprocess.run", side_effect=exc), + ): + result = generate_requirements(s) + assert result.exists() + assert result.stat().st_size == 0 + + +def test_generate_requirements_poetry_returns_empty_on_missing_lockfile( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PACKAGE_MANAGER", "poetry") + s = Settings() + export_exc = subprocess.CalledProcessError(1, "poetry", stderr="poetry.lock not found") + with ( + patch("python_security_auditing.runners.shutil.which", side_effect=lambda exe: exe), + patch("python_security_auditing.runners.subprocess.run") as mock_run, + ): + # self add uses check=False so a non-zero return is silently ignored; + # export raises CalledProcessError to simulate a missing lockfile + mock_run.side_effect = [MagicMock(returncode=1), export_exc] + result = generate_requirements(s) + assert result.exists() + assert result.stat().st_size == 0 + + +def test_generate_requirements_pipenv_returns_empty_on_missing_lockfile( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PACKAGE_MANAGER", "pipenv") + s = Settings() + exc = subprocess.CalledProcessError(1, "pipenv", stderr="Pipfile.lock not found") + with ( + patch("python_security_auditing.runners.shutil.which", side_effect=lambda exe: exe), + patch("python_security_auditing.runners.subprocess.run", side_effect=exc), + ): + result = generate_requirements(s) + assert result.exists() + assert result.stat().st_size == 0 + + +def test_generate_requirements_uv_warns_on_missing_lockfile( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setenv("PACKAGE_MANAGER", "uv") + s = Settings() + exc = subprocess.CalledProcessError(2, "uv", stderr="No uv.lock found") + with ( + patch("python_security_auditing.runners.shutil.which", side_effect=lambda exe: exe), + patch("python_security_auditing.runners.subprocess.run", side_effect=exc), + ): + generate_requirements(s) + assert "uv export failed" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# generate_requirements / run_pip_audit — tool-not-found handling +# --------------------------------------------------------------------------- + + +def test_generate_requirements_raises_when_tool_not_found( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PACKAGE_MANAGER", "uv") + s = Settings() + with patch("python_security_auditing.runners.shutil.which", return_value=None): + with pytest.raises(FileNotFoundError, match="uv"): + generate_requirements(s) + + +def test_run_pip_audit_raises_when_tool_not_found( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + with patch("python_security_auditing.runners.shutil.which", return_value=None): + with pytest.raises(FileNotFoundError, match="pip-audit"): + run_pip_audit(Path("requirements.txt")) diff --git a/tests/test_settings.py b/tests/test_settings.py index 66a9c09..07d9089 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,6 +1,7 @@ """Tests for settings.py — env var parsing and computed properties.""" import pytest +from pydantic import ValidationError from python_security_auditing.settings import Settings @@ -96,3 +97,107 @@ def test_github_workflow(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("GITHUB_WORKFLOW", "CI") s = Settings() assert s.github_workflow == "CI" + + +# --------------------------------------------------------------------------- +# Path traversal validation +# --------------------------------------------------------------------------- + + +def test_bandit_sarif_path_rejects_traversal(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BANDIT_SARIF_PATH", "../etc/passwd") + with pytest.raises(ValidationError): + Settings() + + +def test_requirements_file_rejects_traversal(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("REQUIREMENTS_FILE", "../../etc/shadow") + with pytest.raises(ValidationError): + Settings() + + +def test_github_step_summary_rejects_traversal(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_STEP_SUMMARY", "/tmp/../../etc/hosts") + with pytest.raises(ValidationError): + Settings() + + +# --------------------------------------------------------------------------- +# github_repository format validation +# --------------------------------------------------------------------------- + + +def test_github_repository_accepts_valid(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_REPOSITORY", "my-org/my-repo") + s = Settings() + assert s.github_repository == "my-org/my-repo" + + +def test_github_repository_rejects_traversal(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_REPOSITORY", "../../secret-org/secret-repo") + with pytest.raises(ValidationError): + Settings() + + +def test_github_repository_rejects_extra_slashes(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo/extra") + with pytest.raises(ValidationError): + Settings() + + +def test_github_repository_empty_is_allowed(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_REPOSITORY", "") + s = Settings() + assert s.github_repository == "" + + +# --------------------------------------------------------------------------- +# github_run_id numeric validation +# --------------------------------------------------------------------------- + + +def test_github_run_id_accepts_numeric(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_RUN_ID", "12345678") + s = Settings() + assert s.github_run_id == "12345678" + + +def test_github_run_id_rejects_non_numeric(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_RUN_ID", "123; rm -rf /") + with pytest.raises(ValidationError): + Settings() + + +def test_github_run_id_empty_is_allowed(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_RUN_ID", "") + s = Settings() + assert s.github_run_id == "" + + +# --------------------------------------------------------------------------- +# github_head_ref branch name validation +# --------------------------------------------------------------------------- + + +def test_github_head_ref_accepts_dependabot_branch(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_HEAD_REF", "dependabot/pip/requests-2.32.0") + s = Settings() + assert s.github_head_ref == "dependabot/pip/requests-2.32.0" + + +def test_github_head_ref_empty_is_allowed(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_HEAD_REF", "") + s = Settings() + assert s.github_head_ref == "" + + +def test_github_head_ref_rejects_semicolon(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_HEAD_REF", "main; rm -rf /") + with pytest.raises(ValidationError): + Settings() + + +def test_github_head_ref_rejects_newline(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_HEAD_REF", "feature/branch\ninjected") + with pytest.raises(ValidationError): + Settings() diff --git a/uv.lock b/uv.lock index 849f544..f14620d 100644 --- a/uv.lock +++ b/uv.lock @@ -566,7 +566,7 @@ wheels = [ [[package]] name = "python-security-auditing" -version = "0.4.2" +version = "0.4.3" source = { editable = "." } dependencies = [ { name = "pip-audit" },