Skip to content
19 changes: 18 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' }}
74 changes: 74 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 (`<!-- security-scan-results -->`) 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`.
12 changes: 4 additions & 8 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand Down
7 changes: 5 additions & 2 deletions src/python_security_auditing/runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
5 changes: 4 additions & 1 deletion tests/test_runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading