From f95d3026aba2c372b171bd0ef10768d61626c1c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Houpert?= <10154151+lhoupert@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:56:59 +0000 Subject: [PATCH 01/15] ci: add self-audtiting job in ci workflow --- .github/workflows/ci.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f00aff..b51ea03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,3 +47,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' }} From 22be56773d92623b5e1ff0c797f9c71c661d759c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Houpert?= <10154151+lhoupert@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:12:43 +0000 Subject: [PATCH 02/15] test: disable bandit --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 7138bd7..583afc8 100644 --- a/action.yml +++ b/action.yml @@ -42,7 +42,7 @@ runs: level: ${{ inputs.bandit_severity_threshold }} - name: Set up Python - uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: '3.13' From b88d19077e7a71db95f62625ce82a4a5def64705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Houpert?= <10154151+lhoupert@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:13:27 +0000 Subject: [PATCH 03/15] test: disable bandit --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b51ea03..d4aa371 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,6 @@ jobs: persist-credentials: false - uses: ./ with: - bandit_scan_dirs: src + tools: pip-audit package_manager: uv post_pr_comment: ${{ github.event_name == 'pull_request' }} From 9bc89fe141ad60477b6197edcb82b14aa4c38816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Houpert?= <10154151+lhoupert@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:15:45 +0000 Subject: [PATCH 04/15] ci: bump action version --- .github/workflows/ci.yml | 2 +- action.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4aa371..b51ea03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,6 @@ jobs: persist-credentials: false - uses: ./ with: - tools: pip-audit + bandit_scan_dirs: src package_manager: uv post_pr_comment: ${{ github.event_name == 'pull_request' }} diff --git a/action.yml b/action.yml index 583afc8..d97f51a 100644 --- a/action.yml +++ b/action.yml @@ -69,7 +69,7 @@ runs: - 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: | From 80eacb88b343645d0cbe79054fd3f393f397c9e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Houpert?= <10154151+lhoupert@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:17:22 +0000 Subject: [PATCH 05/15] test: disable action temporarly --- action.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/action.yml b/action.yml index d97f51a..5873fea 100644 --- a/action.yml +++ b/action.yml @@ -34,12 +34,12 @@ inputs: runs: using: composite steps: - - name: Run Bandit (static security analysis) - if: contains(inputs.tools, 'bandit') - uses: lhoupert/bandit-action@18022d5292d04b21fae1bfa44597b94402ba7365 - with: - targets: ${{ inputs.bandit_scan_dirs }} - level: ${{ inputs.bandit_severity_threshold }} + # - name: Run Bandit (static security analysis) + # if: contains(inputs.tools, 'bandit') + # uses: lhoupert/bandit-action@18022d5292d04b21fae1bfa44597b94402ba7365 + # with: + # targets: ${{ inputs.bandit_scan_dirs }} + # level: ${{ inputs.bandit_severity_threshold }} - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 From d9a7fce981c981719c4b8e63b48311fc9f5e088e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Houpert?= <10154151+lhoupert@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:17:59 +0000 Subject: [PATCH 06/15] test: 2 --- action.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/action.yml b/action.yml index 5873fea..25e003d 100644 --- a/action.yml +++ b/action.yml @@ -67,14 +67,14 @@ runs: PR_NUMBER: ${{ github.event.pull_request.number }} run: python -m python_security_auditing - - name: Upload audit reports - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 - with: - name: security-audit-reports - path: | - ${{ inputs.working_directory }}/pip-audit-report.json - if-no-files-found: ignore + # - name: Upload audit reports + # if: always() + # uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + # with: + # name: security-audit-reports + # path: | + # ${{ inputs.working_directory }}/pip-audit-report.json + # if-no-files-found: ignore - name: Fail if blocking issues found if: steps.audit.outcome == 'failure' From b6373cb40024085ef93066ae71183fcef55cb873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Houpert?= <10154151+lhoupert@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:18:47 +0000 Subject: [PATCH 07/15] test: 3 --- action.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/action.yml b/action.yml index 25e003d..eda8c3f 100644 --- a/action.yml +++ b/action.yml @@ -41,10 +41,10 @@ runs: # targets: ${{ inputs.bandit_scan_dirs }} # level: ${{ inputs.bandit_severity_threshold }} - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 - with: - python-version: '3.13' + # - name: Set up Python + # uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + # with: + # python-version: '3.13' - name: Install python-security-auditing shell: bash From dcc332cc6ed8da5d1009d9a438a7dbaeaa12ba53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Houpert?= <10154151+lhoupert@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:23:46 +0000 Subject: [PATCH 08/15] ci: test --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b51ea03..d4aa371 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,6 @@ jobs: persist-credentials: false - uses: ./ with: - bandit_scan_dirs: src + tools: pip-audit package_manager: uv post_pr_comment: ${{ github.event_name == 'pull_request' }} From b86175e6a92f560cab0bfbee32c1b3c18dff328c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Houpert?= <10154151+lhoupert@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:27:52 +0000 Subject: [PATCH 09/15] ci: fix it --- .github/workflows/ci.yml | 6 ++-- CLAUDE.md | 74 ++++++++++++++++++++++++++++++++++++++++ action.yml | 36 +++++++++---------- 3 files changed, 96 insertions(+), 20 deletions(-) create mode 100644 CLAUDE.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4aa371..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 @@ -59,6 +61,6 @@ jobs: persist-credentials: false - uses: ./ with: - tools: pip-audit + 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 eda8c3f..d97f51a 100644 --- a/action.yml +++ b/action.yml @@ -34,17 +34,17 @@ inputs: runs: using: composite steps: - # - name: Run Bandit (static security analysis) - # if: contains(inputs.tools, 'bandit') - # uses: lhoupert/bandit-action@18022d5292d04b21fae1bfa44597b94402ba7365 - # with: - # targets: ${{ inputs.bandit_scan_dirs }} - # level: ${{ inputs.bandit_severity_threshold }} + - name: Run Bandit (static security analysis) + if: contains(inputs.tools, 'bandit') + uses: lhoupert/bandit-action@18022d5292d04b21fae1bfa44597b94402ba7365 + with: + targets: ${{ inputs.bandit_scan_dirs }} + level: ${{ inputs.bandit_severity_threshold }} - # - name: Set up Python - # uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 - # with: - # python-version: '3.13' + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: '3.13' - name: Install python-security-auditing shell: bash @@ -67,14 +67,14 @@ runs: PR_NUMBER: ${{ github.event.pull_request.number }} run: python -m python_security_auditing - # - name: Upload audit reports - # if: always() - # uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 - # with: - # name: security-audit-reports - # path: | - # ${{ inputs.working_directory }}/pip-audit-report.json - # if-no-files-found: ignore + - name: Upload audit reports + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: security-audit-reports + path: | + ${{ inputs.working_directory }}/pip-audit-report.json + if-no-files-found: ignore - name: Fail if blocking issues found if: steps.audit.outcome == 'failure' From 7631fbd1da4c50abf35826b57d8bb8f2dbe42a2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Houpert?= <10154151+lhoupert@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:32:09 +0000 Subject: [PATCH 10/15] fix: uv install --- action.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/action.yml b/action.yml index d97f51a..039fbc3 100644 --- a/action.yml +++ b/action.yml @@ -41,14 +41,14 @@ runs: targets: ${{ inputs.bandit_scan_dirs }} level: ${{ inputs.bandit_severity_threshold }} - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + - name: Set up uv + uses: astral-sh/setup-uv@d4b2f3b6ecc6e670655d9a3a1e1c89729714b4b7 # v5 with: python-version: '3.13' - name: Install python-security-auditing shell: bash - run: pip install "${{ github.action_path }}" + run: uv pip install --system "${{ github.action_path }}" - name: Run security audit id: audit From a5a44241440ac7e77fc5c725f4a59206508fcc93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Houpert?= <10154151+lhoupert@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:34:53 +0000 Subject: [PATCH 11/15] ci: fix astral-uv version --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 039fbc3..a12497a 100644 --- a/action.yml +++ b/action.yml @@ -42,7 +42,7 @@ runs: level: ${{ inputs.bandit_severity_threshold }} - name: Set up uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e670655d9a3a1e1c89729714b4b7 # v5 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: python-version: '3.13' From aa3250e15d3667fdd6a6fe30ff5cccca0c2e9373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Houpert?= <10154151+lhoupert@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:39:47 +0000 Subject: [PATCH 12/15] ci: fix python install --- action.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/action.yml b/action.yml index a12497a..45832b7 100644 --- a/action.yml +++ b/action.yml @@ -46,10 +46,6 @@ runs: with: python-version: '3.13' - - name: Install python-security-auditing - shell: bash - run: uv pip install --system "${{ github.action_path }}" - - name: Run security audit id: audit shell: bash @@ -65,7 +61,7 @@ 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() From 94e4f5bedeeb1ea8e7a1b668ee22126931042c36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Houpert?= <10154151+lhoupert@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:48:58 +0000 Subject: [PATCH 13/15] fix: bug of using old pip audit format --- src/python_security_auditing/runners.py | 5 ++++- tests/test_runners.py | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/python_security_auditing/runners.py b/src/python_security_auditing/runners.py index 44110ee..d24533f 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) + # pip-audit 2.7+ wraps output in {"dependencies": [...], "fixes": [...]} + if isinstance(parsed, dict): + return parsed.get("dependencies", []) return parsed return [] diff --git a/tests/test_runners.py b/tests/test_runners.py index 4accb3e..65c4dc3 100644 --- a/tests/test_runners.py +++ b/tests/test_runners.py @@ -148,7 +148,9 @@ 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) From 1d1f33dd1f62e1397c0f92d398d1ca4a6bd83c5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Houpert?= <10154151+lhoupert@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:52:11 +0000 Subject: [PATCH 14/15] test: fix --- tests/test_runners.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_runners.py b/tests/test_runners.py index 65c4dc3..a1effbb 100644 --- a/tests/test_runners.py +++ b/tests/test_runners.py @@ -156,6 +156,7 @@ def test_run_pip_audit_parses_json(tmp_path: Path, monkeypatch: pytest.MonkeyPat 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() From cdc6a71eeb061e320cd50d75c2eec3b20f1bae37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Houpert?= <10154151+lhoupert@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:53:48 +0000 Subject: [PATCH 15/15] chore: fix mypy --- src/python_security_auditing/runners.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/python_security_auditing/runners.py b/src/python_security_auditing/runners.py index d24533f..d39581d 100644 --- a/src/python_security_auditing/runners.py +++ b/src/python_security_auditing/runners.py @@ -123,6 +123,6 @@ def run_pip_audit(requirements_path: Path) -> list[dict[str, Any]]: output_file.write_text(raw) # pip-audit 2.7+ wraps output in {"dependencies": [...], "fixes": [...]} if isinstance(parsed, dict): - return parsed.get("dependencies", []) - return parsed + return list(parsed.get("dependencies", [])) + return list(parsed) return []