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
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ 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
runners.py ← Tool invocation: bandit and its SARIF, pip-audit, package manager adapters
report.py ← Markdown report builder and threshold checker
pr_comment.py ← Upsert PR comment via `gh` CLI
```
Expand All @@ -22,15 +22,15 @@ src/python_security_auditing/

**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)
- `runners.py` — external tool boundary (subprocess calls to bandit, 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`
- **Dependencies:** `pydantic-settings`, `pip-audit`, `bandit[sarif]`
- **Dev deps:** `pytest`, `pytest-mock`, `mypy` (strict), `ruff`

### Common Commands
Expand Down Expand Up @@ -68,7 +68,7 @@ uv run ruff format src/ tests/

## 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.
- **Bandit runs as a subprocess:** `run_bandit()` runs the bandit CLI as a subprocess (`python -m bandit`, from the package's own environment), writes `results.sarif` at the workspace root (artifact and Code Scanning upload), and reads it back. It fails closed: a crash or no file scanned raises `AuditError`; skipped files block the job and their SARIF is not uploaded.
- **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`.
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ permissions:
security-events: write
```

If you don't need Code Scanning integration, `contents: read` alone is sufficient.
In a private repository, the Code Scanning upload also needs `actions: read`, because `upload-sarif` reads the workflow run. The upload is best effort: without these permissions it fails without failing the job, and findings still appear in the annotations and the summary. If you don't need Code Scanning integration, `contents: read` alone is sufficient.

## Usage examples

Expand Down Expand Up @@ -212,6 +212,17 @@ When your source code spans more than one directory, pass a comma-separated list
bandit_scan_dirs: 'src/,scripts/'
```

### Configuring bandit

Bandit reads a `.bandit` file (INI, `[bandit]` section, for example `exclude` or `skips`). It does not read `[tool.bandit]` in `pyproject.toml`. Bandit only looks for `.bandit` inside the scanned directories, and it fails if it finds more than one. So keep a single `.bandit`: at the working-directory root when you scan the default `.`, otherwise in one of the `bandit_scan_dirs`.

Bandit runs on Python 3.13. A file it cannot parse fails the job, because its findings would otherwise be missing; the other files' findings are still reported. With the default `bandit_scan_dirs: '.'`, this includes files in a virtual environment or `node_modules` inside the working directory. To skip files on purpose, list them under `exclude`. That list replaces bandit's default excludes, so repeat them. Use `*/name/*` globs for directories: when bandit scans `.`, a plain name such as `.venv` does not match.

```ini
[bandit]
exclude = */.git/*,*/__pycache__/*,*/.tox/*,*/.eggs/*,*.egg,*/.venv/*,path/to/file.py
```

### Project in a subdirectory (monorepo)

Set `working_directory` to the project root within the repo. All relative paths (scan dirs, requirements file) are resolved from there:
Expand Down Expand Up @@ -343,8 +354,8 @@ The job fails (non-zero exit) when **either** tool finds issues above its config
- **Annotations** — always emitted. Bandit findings appear as inline annotations on the PR "Files changed" tab (keyed to file and line). pip-audit findings appear as summary-level annotations. No email notifications are generated.
- **Step summary** — the full report is written to the workflow run summary, visible under the "Summary" tab.
- **PR comment** — opt-in via `comment_on: blocking` or `comment_on: always`. Created on first run, updated in place on every subsequent run. The comment is keyed on a hidden `<!-- security-scan-results::{workflow-name} -->` marker, so multiple workflows on the same PR each maintain their own separate comment.
- **Artifact** — `pip-audit-report.json` and `results.sarif` uploaded under the name set by `artifact_name` (default: `security-audit-reports`) for download or downstream steps. The `results.sarif` file is the bandit SARIF report; it is also uploaded to GitHub Code Scanning automatically by the underlying `lhoupert/bandit-action` step, making findings visible in the repository's Security tab when the job has `security-events: write` permission.
- **Exit code** — non-zero when blocking issues are found, so the job fails and branch protections can enforce it.
- **Artifact** — `pip-audit-report.json` and `results.sarif` uploaded under the name set by `artifact_name` (default: `security-audit-reports`) for download or downstream steps. The `results.sarif` file is the bandit SARIF report; it is also uploaded to GitHub Code Scanning with `github/codeql-action/upload-sarif`, making findings visible in the repository's Security tab when the job has `security-events: write` permission.
- **Exit code** — non-zero when blocking issues are found, or when a tool could not run fully (for bandit: a missing scan dir, a file it cannot parse, or no Python file to scan), so the job fails and branch protections can enforce it.

## Development

Expand Down
43 changes: 19 additions & 24 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,32 +40,11 @@ inputs:
runs:
using: composite
steps:
- name: Resolve bandit targets
# A report left by an earlier job (self-hosted runner) must not be uploaded as this one's.
- name: Remove an earlier bandit report
if: contains(inputs.tools, 'bandit')
id: resolve-targets
shell: bash
env:
WORKING_DIRECTORY: ${{ inputs.working_directory }}
BANDIT_SCAN_DIRS: ${{ inputs.bandit_scan_dirs }}
run: |
if [[ "$WORKING_DIRECTORY" == "." ]]; then
echo "targets=$BANDIT_SCAN_DIRS" >> "$GITHUB_OUTPUT"
else
resolved=""
IFS=',' read -ra parts <<< "$BANDIT_SCAN_DIRS"
for part in "${parts[@]}"; do
[[ "$part" == "." ]] && t="$WORKING_DIRECTORY" || t="$WORKING_DIRECTORY/$part"
resolved="${resolved:+$resolved }$t"
done
echo "targets=$resolved" >> "$GITHUB_OUTPUT"
fi

- name: Run Bandit (static security analysis)
if: contains(inputs.tools, 'bandit')
continue-on-error: true
uses: lhoupert/bandit-action@e2d48932beda5cc8c50cb45ed39f8d873ef2d365
with:
targets: ${{ steps.resolve-targets.outputs.targets }}
run: rm -f -- "$GITHUB_WORKSPACE/results.sarif"

- name: Set up uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
Expand All @@ -79,6 +58,7 @@ runs:
working-directory: ${{ inputs.working_directory }}
env:
TOOLS: ${{ inputs.tools }}
BANDIT_SCAN_DIRS: ${{ inputs.bandit_scan_dirs }}
BANDIT_SEVERITY_THRESHOLD: ${{ inputs.bandit_severity_threshold }}
BANDIT_SARIF_PATH: ${{ github.workspace }}/results.sarif
PIP_AUDIT_BLOCK_ON: ${{ inputs.pip_audit_block_on }}
Expand All @@ -91,6 +71,21 @@ runs:
RUNNER_DEBUG: ${{ runner.debug }}
run: uv run --no-project --with "$GITHUB_ACTION_PATH" python -m python_security_auditing

# results.sarif exists only if bandit scanned every file in this job: the first step removes
# an earlier one, and the audit deletes it when bandit fails or skips files (an empty or
# partial report would close open alerts). Runs before the artifact upload, so a failed
# upload cannot skip it.
- name: Upload bandit SARIF to Code Scanning
if: contains(inputs.tools, 'bandit') && hashFiles('results.sarif') != ''
continue-on-error: true # needs `security-events: write`; the audit result stands without it
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
sarif_file: ${{ github.workspace }}/results.sarif
# A job may upload one SARIF per category, so each working_directory gets its own.
# For '.' it stays empty (= no category, the default analysis), as the bandit fork's
# upload did, so existing alerts keep matching.
category: ${{ inputs.working_directory != '.' && format('bandit/{0}', inputs.working_directory) || '' }}

- name: Upload ${{ inputs.artifact_name }}
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
Expand Down
20 changes: 20 additions & 0 deletions integration-tests/tests/test_validate_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,26 @@ def test_pip_audit_disabled_needs_no_report(self) -> None:
assert vr.check_audited(expected, None) == []


# ---------------------------------------------------------------------------
# check_scanned
# ---------------------------------------------------------------------------


class TestCheckScanned:
"""The action deletes results.sarif when bandit did not run or skipped files."""

def test_missing_sarif_returns_error(self, tmp_path: Path) -> None:
errors = vr.check_scanned(tmp_path / "results.sarif")

assert any("no results.sarif" in e for e in errors)

def test_present_sarif_returns_no_error(self, tmp_path: Path) -> None:
path = tmp_path / "results.sarif"
path.write_text(json.dumps({"runs": [{"results": []}]}))

assert vr.check_scanned(path) == []


# ---------------------------------------------------------------------------
# generate_report
# ---------------------------------------------------------------------------
Expand Down
12 changes: 12 additions & 0 deletions integration-tests/validate_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,17 @@ def check_audited(expected: dict[str, Any], pip_audit_path: Path | None) -> list
return []


def check_scanned(sarif_path: Path) -> list[str]:
"""Error when a case (all of them run bandit) has no results.sarif.

The action deletes it when bandit did not run or skipped files, so a case
without one would otherwise pass as "no findings".
"""
if sarif_path.exists():
return []
return ["bandit: no results.sarif (bandit did not run, or skipped files)"]


# ---------------------------------------------------------------------------
# Report generation
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -277,6 +288,7 @@ def main() -> int:
conclusion = conclusions.get(num, "missing")
errors = validate_test(num, exp, conclusion, bandit_findings, pip_audit_findings)
errors += check_audited(exp, pip_audit_path)
errors += check_scanned(sarif_path)
all_errors[num] = errors

status = "✅" if not errors else "❌"
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ requires-python = ">=3.13"
dependencies = [
"pydantic-settings>=2.14.2",
"pip-audit>=2.7",
"bandit[sarif]>=1.9.4",
]

[project.optional-dependencies]
Expand Down
30 changes: 19 additions & 11 deletions src/python_security_auditing/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from __future__ import annotations

import sys
from pathlib import Path
from typing import Any

from .annotations import emit_annotations
Expand All @@ -13,7 +12,7 @@
PIP_AUDIT_REPORT,
AuditError,
generate_requirements,
read_bandit_sarif,
run_bandit,
run_pip_audit,
)
from .settings import Settings
Expand All @@ -27,14 +26,14 @@ def main() -> None:

bandit_report: dict[str, Any] = {}
pip_audit_report: list[dict[str, Any]] = []
bandit_error = ""
pip_audit_error = ""

if "bandit" in settings.enabled_tools:
if settings.debug:
print(
f"[debug] reading bandit SARIF from {settings.bandit_sarif_path}", file=sys.stderr
)
bandit_report = read_bandit_sarif(Path(settings.bandit_sarif_path))
try:
bandit_report = run_bandit(settings)
except AuditError as exc:
bandit_error = str(exc)
if settings.debug:
print(
f"[debug] bandit findings: {len(bandit_report.get('results', []))}", file=sys.stderr
Expand All @@ -57,20 +56,29 @@ def main() -> None:
if settings.debug:
print(f"[debug] pip-audit findings: {len(pip_audit_report)}", file=sys.stderr)

markdown = build_markdown(bandit_report, pip_audit_report, settings, pip_audit_error)
markdown = build_markdown(
bandit_report, pip_audit_report, settings, pip_audit_error, bandit_error
)
write_step_summary(markdown, settings)
emit_annotations(bandit_report, pip_audit_report, settings, pip_audit_error)
emit_annotations(bandit_report, pip_audit_report, settings, pip_audit_error, bandit_error)

has_blocking = bool(pip_audit_error) or check_thresholds(
has_blocking = bool(bandit_error or pip_audit_error) or check_thresholds(
bandit_report, pip_audit_report, settings
)

if settings.github_token and settings.comment_on != "never":
if settings.comment_on == "always" or has_blocking:
upsert_pr_comment(markdown, settings)

failures = []
if bandit_error:
failures.append(f"bandit did NOT run, so the code was NOT scanned.\n{bandit_error}")
if pip_audit_error:
sys.exit(f"pip-audit did NOT run, so dependencies were NOT audited.\n{pip_audit_error}")
failures.append(
f"pip-audit did NOT run, so dependencies were NOT audited.\n{pip_audit_error}"
)
if failures:
sys.exit("\n".join(failures))
if has_blocking:
sys.exit(1)

Expand Down
17 changes: 16 additions & 1 deletion src/python_security_auditing/annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from typing import Any

from .report import bandit_skipped_message
from .settings import Settings

_SEVERITY_TO_LEVEL: dict[str, str] = {
Expand All @@ -19,11 +20,17 @@ def _escape(text: str) -> str:
return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")


def _escape_property(text: str) -> str:
"""Escape a workflow-command property value (file=...), where ':' and ',' are separators."""
return _escape(text).replace(":", "%3A").replace(",", "%2C")


def emit_annotations(
bandit_report: dict[str, Any],
pip_audit_report: list[dict[str, Any]],
settings: Settings,
pip_audit_error: str = "",
bandit_error: str = "",
) -> None:
"""Print GitHub Actions workflow commands to stdout.

Expand All @@ -32,6 +39,14 @@ def emit_annotations(
No email notifications are generated by annotations.
"""
if "bandit" in settings.enabled_tools:
if bandit_error:
print(f"::error::bandit did NOT run: {_escape(bandit_error)}")
if bandit_report.get("errors"):
print(f"::error::{_escape(bandit_skipped_message(bandit_report['errors']))}")
if bandit_report.get("files_read", 0) is None:
print(
"::warning::bandit reported no file metrics, so the scanned file count is unknown"
)
results: list[dict[str, Any]] = bandit_report.get("results", [])

def _sort_key(r: dict[str, Any]) -> int:
Expand All @@ -40,7 +55,7 @@ def _sort_key(r: dict[str, Any]) -> int:
for result in sorted(results, key=_sort_key):
sev = result.get("issue_severity", "LOW")
level = _SEVERITY_TO_LEVEL.get(sev, "notice")
fname = result.get("filename", "")
fname = _escape_property(result.get("filename", ""))
line = result.get("line_number", 0)
test_id = result.get("test_id", "")
text = _escape(result.get("issue_text", ""))
Expand Down
Loading
Loading