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
155 changes: 137 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,35 +1,112 @@
# python-security-auditing

Reusable GitHub Action that runs [bandit](https://bandit.readthedocs.io/) and [pip-audit](https://pypi.org/project/pip-audit/) on any Python repository. Posts findings as a PR comment and fails the job when blocking issues are found.
A reusable GitHub Action that runs **[bandit](https://bandit.readthedocs.io/)** (static code analysis) and **[pip-audit](https://pypi.org/project/pip-audit/)** (dependency vulnerability scanning) on any Python repository, then consolidates the results into a single PR comment, a workflow step summary, and a downloadable artifact.

## Usage
## Why this action instead of using bandit or pip-audit directly?

### Minimal (requirements.txt project)
| | `lhoupert/bandit-action` alone | `pypa/gh-action-pip-audit` alone | **this action** |
|---|---|---|---|
| Static code analysis (bandit) | ✅ | — | ✅ |
| Dependency vulnerability scan (pip-audit) | — | ✅ | ✅ |
| Unified PR comment | — | — | ✅ |
| Configurable blocking thresholds | partial | partial | ✅ |
| Multi-package-manager support | — | ✅ | ✅ |
| Workflow step summary | — | — | ✅ |
| Downloadable audit artifact | — | — | ✅ |

The core value is the **reporting layer**: instead of two separate actions producing separate outputs you have to check individually, you get one PR comment that is created on first run and updated in place on every subsequent run.

## What the PR comment looks like

When issues are found, the comment posted to the PR looks like this:

```
# Security Audit Report

## Bandit — Static Security Analysis

| Severity | Confidence | File | Line | Issue |
|---|---|---|---|---|
| 🔴 HIGH | HIGH | `src/app.py` | 2 | [B404] Consider possible security implications associated with subprocess module. |
| 🟡 MEDIUM | MEDIUM | `src/app.py` | 5 | [B602] subprocess call with shell=True identified, security issue. |

_2 issue(s) found, 1 at or above HIGH threshold._

## pip-audit — Dependency Vulnerabilities

| Package | Version | ID | Fix Versions | Description |
|---|---|---|---|---|
| requests | 2.25.0 | GHSA-j8r2-6x86-q33q | 2.31.0 | Unintended leak of Proxy-Authorization header ... |

_1 vulnerability/vulnerabilities found (1 fixable) across 1 package(s)._

---
**Result: ❌ Blocking issues found — see details above.**
```

When everything is clean:

```
## Bandit — Static Security Analysis
✅ No issues found.

## pip-audit — Dependency Vulnerabilities
✅ No vulnerabilities found.

---
**Result: ✅ No blocking issues found.**
```

The comment is idempotent — it is created once and updated in place on every push, so the PR thread stays clean.

## Quickstart

Add this to your workflow (e.g. `.github/workflows/security.yml`):

```yaml
- uses: developmentseed/python-security-auditing@v1
name: Security Audit

on:
pull_request:
push:
branches: [main]

jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: developmentseed/python-security-auditing@v1
```

### uv-based project
This runs both bandit and pip-audit with sensible defaults: blocks the job on HIGH-severity code issues and on dependency vulnerabilities that have a fix available.

## Usage examples

### uv project

```yaml
- uses: developmentseed/python-security-auditing@v1
with:
package_manager: uv
bandit_scan_dirs: 'src/,scripts/'
bandit_scan_dirs: 'src/'
```

### Poetry project, stricter thresholds
### Poetry project with stricter thresholds

Block on any bandit finding at MEDIUM or above, and on all known vulnerabilities regardless of whether a fix exists:

```yaml
- uses: developmentseed/python-security-auditing@v1
with:
package_manager: poetry
bandit_severity_threshold: MEDIUM
bandit_severity_threshold: medium
pip_audit_block_on: all
```

### Bandit only (no dependency audit)
### Bandit only (skip dependency audit)

Useful when you manage dependencies externally or run pip-audit in a separate job:

```yaml
- uses: developmentseed/python-security-auditing@v1
Expand All @@ -38,25 +115,67 @@ Reusable GitHub Action that runs [bandit](https://bandit.readthedocs.io/) and [p
bandit_scan_dirs: 'src/'
```

### Project in a subdirectory (monorepo)

```yaml
- uses: developmentseed/python-security-auditing@v1
with:
working_directory: services/api
package_manager: uv
bandit_scan_dirs: 'services/api/src/'
```

### Audit-only mode (never block the job)

Run the audit and post the comment for visibility, but don't fail CI:

```yaml
- uses: developmentseed/python-security-auditing@v1
with:
bandit_severity_threshold: low # report everything
pip_audit_block_on: none # never block
```

## How blocking works

The job fails (non-zero exit) when **either** tool finds issues above its configured threshold.

**Bandit threshold** (`bandit_severity_threshold`): findings at or above the threshold block the job.

| `bandit_severity_threshold` | Blocks on |
|---|---|
| `high` (default) | 🔴 HIGH only |
| `medium` | 🟡 MEDIUM and 🔴 HIGH |
| `low` | 🟢 LOW, 🟡 MEDIUM, and 🔴 HIGH |

**pip-audit threshold** (`pip_audit_block_on`):

| `pip_audit_block_on` | Blocks on |
|---|---|
| `fixable` (default) | Vulnerabilities with a fix available — you can act on these immediately |
| `all` | All known vulnerabilities, including those with no fix yet |
| `none` | Never blocks — audit runs but CI stays green |

## Inputs

| Input | Default | Description |
|---|---|---|
| `tools` | `bandit,pip-audit` | Comma-separated tools to run |
| `bandit_scan_dirs` | `.` | Comma-separated directories for bandit to scan |
| `bandit_severity_threshold` | `HIGH` | Minimum severity that blocks the job: `HIGH`, `MEDIUM`, or `LOW` |
| `pip_audit_block_on` | `fixable` | Block on: `fixable` (has a fix), `all`, or `none` |
| `package_manager` | `requirements` | How to resolve deps: `uv`, `pip`, `poetry`, `pipenv`, `requirements` |
| `requirements_file` | `requirements.txt` | Path when `package_manager=requirements` |
| `bandit_severity_threshold` | `high` | Minimum severity that blocks the job: `high`, `medium`, or `low` |
| `pip_audit_block_on` | `fixable` | When pip-audit findings block the job: `fixable`, `all`, or `none` |
| `package_manager` | `requirements` | How to resolve deps for pip-audit: `uv`, `pip`, `poetry`, `pipenv`, `requirements` |
| `requirements_file` | `requirements.txt` | Path to requirements file when `package_manager=requirements` |
| `working_directory` | `.` | Directory to run the audit from (useful for monorepos) |
| `post_pr_comment` | `true` | Post/update a PR comment with scan results |
| `github_token` | `${{ github.token }}` | Token for PR comments |
| `github_token` | `${{ github.token }}` | Token used for posting PR comments |

## Outputs

- **Step summary** — written to the workflow run summary.
- **PR comment** — upserted on every run (idempotent via `<!-- security-scan-results -->` marker).
- **Artifacts** — `bandit-report.json` and `pip-audit-report.json` uploaded as `security-audit-reports`.
- **Exit code** — non-zero when blocking issues are found.
- **PR comment** — created on first run, updated in place on every subsequent run (keyed on a hidden `<!-- security-scan-results -->` marker).
- **Step summary** — the same report is written to the workflow run summary, visible under the "Summary" tab.
- **Artifact** — `pip-audit-report.json` uploaded as `security-audit-reports` for download or downstream steps.
- **Exit code** — non-zero when blocking issues are found, so the job fails and branch protections can enforce it.

## Development

Expand Down
14 changes: 10 additions & 4 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ inputs:
description: Comma-separated directories for bandit to scan
default: .
bandit_severity_threshold:
description: Minimum bandit severity that blocks the job (HIGH, MEDIUM, LOW)
default: HIGH
description: Minimum bandit severity that blocks the job (high, medium, low)
default: high
pip_audit_block_on:
description: When to block on pip-audit findings — fixable, all, or none
default: fixable
Expand All @@ -34,6 +34,13 @@ 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: Set up Python
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
with:
Expand All @@ -50,8 +57,8 @@ 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 }}
PACKAGE_MANAGER: ${{ inputs.package_manager }}
REQUIREMENTS_FILE: ${{ inputs.requirements_file }}
Expand All @@ -66,7 +73,6 @@ runs:
with:
name: security-audit-reports
path: |
${{ inputs.working_directory }}/bandit-report.json
${{ inputs.working_directory }}/pip-audit-report.json
if-no-files-found: ignore

Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ license = { text = "MIT" }
requires-python = ">=3.13"
dependencies = [
"pydantic-settings>=2.0",
"bandit>=1.8",
"pip-audit>=2.7",
]

Expand Down
5 changes: 3 additions & 2 deletions src/python_security_auditing/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
from __future__ import annotations

import sys
from pathlib import Path
from typing import Any

from .pr_comment import upsert_pr_comment
from .report import build_markdown, check_thresholds, write_step_summary
from .runners import generate_requirements, run_bandit, run_pip_audit
from .runners import generate_requirements, read_bandit_sarif, run_pip_audit
from .settings import Settings


Expand All @@ -18,7 +19,7 @@ def main() -> None:
pip_audit_report: list[dict[str, Any]] = []

if "bandit" in settings.enabled_tools:
bandit_report = run_bandit(settings.scan_directories)
bandit_report = read_bandit_sarif(Path(settings.bandit_sarif_path))

if "pip-audit" in settings.enabled_tools:
requirements_path = generate_requirements(settings)
Expand Down
3 changes: 2 additions & 1 deletion src/python_security_auditing/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ def _bandit_section(report: dict[str, Any], settings: Settings) -> str:
]
lines.append(
f"\n_{len(results)} issue(s) found, "
f"{len(blocking_results)} at or above {settings.bandit_severity_threshold} threshold._\n"
f"{len(blocking_results)} at or above "
f"{settings.bandit_severity_threshold.upper()} threshold._\n"
)
return "\n".join(lines)

Expand Down
52 changes: 38 additions & 14 deletions src/python_security_auditing/runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,22 +62,46 @@ def generate_requirements(settings: Settings) -> Path:
return out_path


def run_bandit(scan_dirs: list[str]) -> dict[str, Any]:
"""Run bandit, write bandit-report.json, return parsed report."""
output_file = Path("bandit-report.json")
cmd = ["bandit", "-r", *scan_dirs, "-f", "json", "-o", str(output_file)]

result = subprocess.run(cmd, capture_output=True, text=True)
# bandit exits 1 when issues are found — that is expected, not an error
if result.returncode not in (0, 1):
print(
f"bandit exited with unexpected code {result.returncode}:\n{result.stderr}",
file=sys.stderr,
_SARIF_LEVEL_TO_SEVERITY: dict[str, str] = {
"error": "HIGH",
"warning": "MEDIUM",
"note": "LOW",
"none": "LOW",
}


def read_bandit_sarif(sarif_path: Path) -> dict[str, Any]:
"""Read results.sarif produced by lhoupert/bandit-action, return bandit-style report dict."""
if not sarif_path.exists():
return {"results": [], "errors": []}

sarif: dict[str, Any] = json.loads(sarif_path.read_text())
sarif_results: list[dict[str, Any]] = sarif.get("runs", [{}])[0].get("results", [])
results: list[dict[str, Any]] = []
for sarif_result in sarif_results:
props: dict[str, Any] = sarif_result.get("properties", {})
severity = props.get("issue_severity") or _SARIF_LEVEL_TO_SEVERITY.get(
sarif_result.get("level", "none"), "LOW"
)
locations: list[dict[str, Any]] = sarif_result.get("locations", [])
filename = ""
line_number = 0
if locations:
phys = locations[0].get("physicalLocation", {})
filename = phys.get("artifactLocation", {}).get("uri", "")
line_number = phys.get("region", {}).get("startLine", 0)
results.append(
{
"issue_severity": severity,
"issue_confidence": props.get("issue_confidence", ""),
"issue_text": sarif_result.get("message", {}).get("text", ""),
"filename": filename,
"line_number": line_number,
"test_id": sarif_result.get("ruleId", ""),
}
)

if output_file.exists():
return dict(json.loads(output_file.read_text()))
return {"results": [], "errors": []}
return {"results": results, "errors": []}


def run_pip_audit(requirements_path: Path) -> list[dict[str, Any]]:
Expand Down
13 changes: 5 additions & 8 deletions src/python_security_auditing/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ class Settings(BaseSettings):
# Tool selection
tools: str = "bandit,pip-audit"

# Bandit config
bandit_scan_dirs: str = "."
bandit_severity_threshold: Literal["HIGH", "MEDIUM", "LOW"] = "HIGH"
# Bandit config — scan dirs and threshold are passed directly to lhoupert/bandit-action;
# the Python module only reads the SARIF output and uses the threshold for reporting.
bandit_severity_threshold: Literal["high", "medium", "low"] = "high"
bandit_sarif_path: str = "results.sarif"

# pip-audit config
pip_audit_block_on: Literal["fixable", "all", "none"] = "fixable"
Expand All @@ -42,13 +43,9 @@ class Settings(BaseSettings):
def enabled_tools(self) -> list[str]:
return [t.strip() for t in self.tools.split(",") if t.strip()]

@property
def scan_directories(self) -> list[str]:
return [d.strip() for d in self.bandit_scan_dirs.split(",") if d.strip()]

@property
def blocking_severities(self) -> list[str]:
"""All severities at or above the configured threshold."""
all_severities = ["LOW", "MEDIUM", "HIGH"]
threshold_idx = all_severities.index(self.bandit_severity_threshold)
threshold_idx = all_severities.index(self.bandit_severity_threshold.upper())
return all_severities[threshold_idx:]
13 changes: 13 additions & 0 deletions tests/fixtures/bandit_clean.sarif
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"version": "2.1.0",
"runs": [
{
"tool": {
"driver": {
"name": "Bandit"
}
},
"results": []
}
]
}
Loading
Loading