diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f00aff..34bb55f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,9 @@ jobs: - uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0 with: python-version: '3.13' - - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 + - run: pip install pre-commit + - run: pre-commit run --all-files --show-diff-on-failure --color=always + test: runs-on: ubuntu-latest @@ -47,3 +49,18 @@ jobs: run: pip install zizmor - name: Run zizmor run: zizmor --min-severity medium .github/ + + security: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: ./ + with: + bandit_scan_dirs: src + package_manager: uv + post_pr_comment: ${{ github.event_name == 'pull_request' }} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f9f5641 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,74 @@ +# CLAUDE.md — python-security-auditing + +## What This Project Is + +A **GitHub Action** that runs **bandit** (static code analysis) and **pip-audit** (dependency vulnerability scanning) on Python repos, then consolidates results into a single PR comment, workflow step summary, and downloadable artifact. + +It is a composite action (`action.yml`) backed by a small Python package (`src/python_security_auditing/`). + +## Architecture + +``` +action.yml ← GitHub Action entry point (composite steps) +src/python_security_auditing/ + __main__.py ← Orchestrator: settings → runners → report → comment → exit + settings.py ← Pydantic-based config from env vars (GitHub Action inputs) + runners.py ← Tool invocation: SARIF parsing, pip-audit, package manager adapters + report.py ← Markdown report builder and threshold checker + pr_comment.py ← Upsert PR comment via `gh` CLI +``` + +**Flow:** `Settings` loads env vars → `runners` invokes tools and parses output → `report` builds markdown and checks thresholds → `pr_comment` posts/updates the PR comment → `__main__` exits 0 or 1. + +**Key boundaries:** +- `settings.py` — input/config boundary (reads env vars, validates via Pydantic) +- `runners.py` — external tool boundary (subprocess calls to bandit SARIF, pip-audit, package managers) +- `report.py` — pure logic (markdown generation, threshold checking — no I/O except step summary) +- `pr_comment.py` — GitHub API boundary (subprocess calls to `gh` CLI) + +## Build & Dev + +- **Build system:** Hatch (`hatchling`) +- **Python:** ≥ 3.13 +- **Dependencies:** `pydantic-settings`, `pip-audit` +- **Dev deps:** `pytest`, `pytest-mock`, `mypy` (strict), `ruff` + +### Common Commands + +```bash +# Install in dev mode +uv pip install -e ".[dev]" + +# Run tests +uv run pytest + +# Type checking (strict mode) +uv run mypy src/ + +# Lint and format +uv run ruff check src/ tests/ +uv run ruff format src/ tests/ +``` + +## Testing Conventions + +- Tests live in `tests/` and mirror module names: `test_settings.py`, `test_runners.py`, `test_report.py`. +- Test fixtures (JSON/SARIF samples) are in `tests/fixtures/`. +- External tool calls (`subprocess.run`) are mocked in tests — never invoke real `bandit`, `pip-audit`, or `gh` in unit tests. +- Settings are configured via `monkeypatch.setenv()` since `Settings` reads from env vars. +- Run the full suite after any change: `pytest`. + +## Code Style + +- **Formatter/linter:** Ruff (line-length 100, rules: E, F, I, UP) +- **Type checking:** mypy strict mode +- **Imports:** `from __future__ import annotations` in every module +- **Type annotations:** use `dict[str, Any]`, `list[...]`, `int | None` (modern syntax, no `Optional`/`Dict`/`List`) +- Match existing patterns — don't refactor surrounding code when making a change. + +## Key Design Decisions + +- **SARIF input for bandit:** Bandit runs in a separate composite step (`lhoupert/bandit-action`). This package only reads the SARIF output file — it does not invoke bandit directly. +- **PR comment is idempotent:** Uses a hidden HTML marker (``) to find and update the same comment on subsequent pushes. +- **Threshold logic:** `check_thresholds()` in `report.py` returns a boolean; the orchestrator translates that to `sys.exit(1)`. +- **Package manager adapters:** `generate_requirements()` normalizes all package managers to a `requirements.txt` file before passing to `pip-audit`. diff --git a/action.yml b/action.yml index 7138bd7..45832b7 100644 --- a/action.yml +++ b/action.yml @@ -41,15 +41,11 @@ runs: targets: ${{ inputs.bandit_scan_dirs }} level: ${{ inputs.bandit_severity_threshold }} - - name: Set up Python - uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0 + - name: Set up uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: python-version: '3.13' - - name: Install python-security-auditing - shell: bash - run: pip install "${{ github.action_path }}" - - name: Run security audit id: audit shell: bash @@ -65,11 +61,11 @@ runs: POST_PR_COMMENT: ${{ inputs.post_pr_comment }} GITHUB_TOKEN: ${{ inputs.github_token }} PR_NUMBER: ${{ github.event.pull_request.number }} - run: python -m python_security_auditing + run: uv run --with "${{ github.action_path }}" python -m python_security_auditing - name: Upload audit reports if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: security-audit-reports path: | diff --git a/src/python_security_auditing/runners.py b/src/python_security_auditing/runners.py index 44110ee..d39581d 100644 --- a/src/python_security_auditing/runners.py +++ b/src/python_security_auditing/runners.py @@ -119,7 +119,10 @@ def run_pip_audit(requirements_path: Path) -> list[dict[str, Any]]: raw = result.stdout.strip() if raw: - parsed: list[dict[str, Any]] = json.loads(raw) + parsed: Any = json.loads(raw) output_file.write_text(raw) - return parsed + # pip-audit 2.7+ wraps output in {"dependencies": [...], "fixes": [...]} + if isinstance(parsed, dict): + return list(parsed.get("dependencies", [])) + return list(parsed) return [] diff --git a/tests/test_runners.py b/tests/test_runners.py index 4accb3e..a1effbb 100644 --- a/tests/test_runners.py +++ b/tests/test_runners.py @@ -148,12 +148,15 @@ def test_read_bandit_sarif_falls_back_to_level_mapping(tmp_path: Path) -> None: def test_run_pip_audit_parses_json(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) - fixture_text = (FIXTURES / "pip_audit_fixable.json").read_text() + # pip-audit 2.7+ wraps output in {"dependencies": [...], "fixes": [...]} + 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: mock_run.return_value = MagicMock(returncode=1, stderr="", stdout=fixture_text) report = run_pip_audit(Path("requirements.txt")) + assert isinstance(report, list) assert len(report) == 2 assert report[0]["name"] == "requests" assert (tmp_path / "pip-audit-report.json").exists()