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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,4 @@ jobs:
with:
bandit_scan_dirs: src
package_manager: uv
post_pr_comment: ${{ github.event_name == 'pull_request' }}
comment_on: never #${{ github.event_name == 'pull_request' && 'always' || 'never' }}
2 changes: 1 addition & 1 deletion .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ jobs:
bandit_scan_dirs: ${{ matrix.bandit_scan_dirs }}
bandit_severity_threshold: ${{ matrix.bandit_severity_threshold }}
pip_audit_block_on: ${{ matrix.pip_audit_block_on }}
post_pr_comment: 'false'
comment_on: never
artifact_name: security-audit-${{ matrix.id }}

# --- Record outcome so the validate job can reconstruct NEEDS_JSON ---
Expand Down
88 changes: 53 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/developmentseed/action-python-security-auditing/badge)](https://scorecard.dev/viewer/?uri=github.com/developmentseed/action-python-security-auditing)

A 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 a Python repository, then puts the results in one PR comment, the workflow step summary, and a downloadable artifact.
A 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 a Python repository, then surfaces findings as inline PR annotations, a workflow step summary, and a downloadable artifact.

## When this might be useful

Expand All @@ -11,8 +11,9 @@ Running bandit and pip-audit directly — or using the official focused actions
This action exists for workflows where you want **both** scanners behind **one** step and **one** place to read the outcome. It is a thin wrapper around the same tools, not a different kind of analysis. The things it adds on top of running the tools individually:

- **Single step, unified report** — one action replaces two, with no need to coordinate SARIF uploads or chain step outputs between jobs.
- **One PR comment for both scanners** — created on the first run and updated in place on every subsequent push, so the PR thread stays clean. Neither official action provides this out of the box.
- **Workflow step summary** — the same report is written to the "Summary" tab of the workflow run.
- **Inline PR annotations** — bandit findings appear as inline annotations on the "Files changed" tab, pointing directly to the affected file and line. pip-audit findings appear as summary-level annotations. Annotations generate no email notifications, so they don't add to developer fatigue on active PRs.
- **Workflow step summary** — the full report is written to the "Summary" tab of the workflow run.
- **Optional PR comment** — set `comment_on: blocking` or `comment_on: always` to post a unified PR comment as well. The comment is created once and updated in place on every push, so the PR thread stays clean. Disabled by default to avoid notification noise.
- **Block on fixable-only vulnerabilities** — `pip_audit_block_on: fixable` (the default) fails CI only when a patched version exists, so you can act on it immediately; unfixable CVEs are reported but don't block. The official pip-audit action does not have this mode.
- **Automatic requirements export** — pass `package_manager: uv|poetry|pipenv` and the action runs the appropriate export command before invoking pip-audit. With the official pip-audit action, you must add a separate step to export first.

Expand Down Expand Up @@ -44,7 +45,7 @@ jobs:
with:
inputs: requirements.txt
# Note: no built-in "fixable-only" blocking mode
# Note: findings appear only in the Actions log — no PR comment
# Note: no unified report, no inline PR annotations
```

**Using this action (equivalent result, one step):**
Expand All @@ -55,7 +56,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # for the unified PR comment
security-events: write
steps:
- uses: actions/checkout@v4
Expand All @@ -64,12 +64,35 @@ jobs:
package_manager: uv # export handled automatically
bandit_scan_dirs: 'src/'
pip_audit_block_on: fixable # only block when a fix exists
# Posts a unified PR comment and step summary automatically
# Inline PR annotations and step summary are written automatically
```

## What the PR comment looks like
## Feedback channels

When issues are found, the comment posted to the PR looks like this:
### Inline annotations (default — no email notifications)

Bandit findings are emitted as inline workflow annotations that appear directly on the affected file and line in the PR "Files changed" tab:

```
::error file=src/app.py,line=2::[B404] Consider possible security implications associated with subprocess module.
::warning file=src/app.py,line=5::[B602] subprocess call with shell=True identified, security issue.
```

pip-audit findings appear as summary-level annotations (no file/line available):

```
::warning::pip-audit: requests@2.25.0 — GHSA-j8r2-6x86-q33q (fix: 2.31.0)
```

Annotation severity maps to bandit severity: HIGH → error, MEDIUM → warning, LOW → notice. Annotations are always emitted and generate no email notifications.

### Step summary (default)

The full report is written to the workflow run "Summary" tab on every run.

### PR comment (opt-in via `comment_on`)

Set `comment_on: blocking` or `comment_on: always` to also post a PR comment. When issues are found, the comment looks like this:

```
# Security Audit Report
Expand All @@ -95,22 +118,7 @@ _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.

Each section also includes a direct link: the Bandit section links to the repository's GitHub Code Scanning page, and the pip-audit section links to the Dependabot security alerts page. These links appear when GitHub repository context is available (i.e. when running inside a GitHub Actions workflow).
The comment is idempotent — created once and updated in place on every push, so the PR thread stays clean. Each section includes a direct link to the repository's GitHub Code Scanning page (Bandit) and Dependabot security alerts page (pip-audit).

## Quickstart

Expand All @@ -136,16 +144,24 @@ This runs both bandit and pip-audit with sensible defaults: blocks the job on HI

## Required permissions

The action needs these permissions on the job:
The default configuration (annotations + step summary, no PR comment) only needs:

```yaml
permissions:
contents: read
security-events: write # upload bandit SARIF to GitHub Code Scanning
```

When `comment_on` is set to `blocking` or `always`, add:

```yaml
permissions:
contents: read
pull-requests: write # post/update the PR comment (when post_pr_comment: true)
security-events: write # upload bandit SARIF to GitHub Code Scanning
pull-requests: write # post/update the PR comment
security-events: write
```

If you only use `post_pr_comment: false` and don't care about Code Scanning integration, `contents: read` alone is sufficient.
If you don't need Code Scanning integration, `contents: read` alone is sufficient.

## Usage examples

Expand Down Expand Up @@ -244,14 +260,15 @@ Block on any bandit finding at MEDIUM or above, and on all known vulnerabilities

### Gradual adoption (audit-only, never block)

Add the action first as an observer: it posts findings to the PR comment and step summary without ever failing the job. Tighten the thresholds once your team has addressed the backlog:
Add the action first as an observer: findings appear as inline annotations and in the step summary without ever failing the job. Tighten the thresholds once your team has addressed the backlog:

```yaml
- uses: developmentseed/action-python-security-auditing@12efad3bddc3efd3668cf6ac6799f94837f4fb3d # v0.5.0
with:
package_manager: uv
bandit_severity_threshold: low # report everything
pip_audit_block_on: none # never block
comment_on: always # optionally post findings to the PR comment too
```

### Scheduled scan on the default branch
Expand All @@ -278,12 +295,12 @@ jobs:
- uses: developmentseed/action-python-security-auditing@12efad3bddc3efd3668cf6ac6799f94837f4fb3d # v0.5.0
with:
package_manager: uv
post_pr_comment: false # no PR to comment on for scheduled runs
# comment_on defaults to never — no PR comment is posted for scheduled runs
```

### Multiple workflows posting separate PR comments

If you run this action from more than one workflow on the same PR (e.g. a general security workflow and a focused API service workflow), each workflow automatically gets its own PR comment. No configuration is needed — the comment is keyed on the workflow name, so the two comments stay independent and update in place separately.
If you run this action from more than one workflow on the same PR with `comment_on: blocking` or `comment_on: always`, each workflow automatically gets its own PR comment. No extra configuration is needed — the comment is keyed on the workflow name, so the two comments stay independent and update in place separately.

## How blocking works

Expand Down Expand Up @@ -316,15 +333,16 @@ The job fails (non-zero exit) when **either** tool finds issues above its config
| `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 used for posting PR comments |
| `comment_on` | `never` | When to post a PR comment: `never`, `blocking` (only when issues block the job), or `always` |
| `github_token` | `${{ github.token }}` | Token used for posting PR comments (only needed when `comment_on` is not `never`) |
| `artifact_name` | `security-audit-reports` | Name of the uploaded artifact |
| `debug` | `false` | Enable verbose debug logging; also activates automatically when re-running a workflow with "Enable debug logging" |

## Outputs

- **PR comment** — 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.
- **Step summary** — the same report is written to the workflow run summary, visible under the "Summary" tab.
- **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.

Expand Down
8 changes: 4 additions & 4 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ inputs:
requirements_file:
description: Path to requirements file when package_manager=requirements
default: requirements.txt
post_pr_comment:
description: Post or update a PR comment with the scan results
default: 'true'
comment_on:
description: When to post a PR comment — never, blocking (only when issues block the job), or always
default: 'never'
github_token:
description: GitHub token used for posting PR comments
default: ${{ github.token }}
Expand Down Expand Up @@ -84,7 +84,7 @@ runs:
PIP_AUDIT_BLOCK_ON: ${{ inputs.pip_audit_block_on }}
PACKAGE_MANAGER: ${{ inputs.package_manager }}
REQUIREMENTS_FILE: ${{ inputs.requirements_file }}
POST_PR_COMMENT: ${{ inputs.post_pr_comment }}
COMMENT_ON: ${{ inputs.comment_on }}
GITHUB_TOKEN: ${{ inputs.github_token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
INPUT_DEBUG: ${{ inputs.debug }}
Expand Down
11 changes: 8 additions & 3 deletions src/python_security_auditing/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pathlib import Path
from typing import Any

from .annotations import emit_annotations
from .pr_comment import upsert_pr_comment
from .report import build_markdown, check_thresholds, write_step_summary
from .runners import generate_requirements, read_bandit_sarif, run_pip_audit
Expand Down Expand Up @@ -47,11 +48,15 @@ def main() -> None:

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

if settings.post_pr_comment and settings.github_token:
upsert_pr_comment(markdown, settings)
has_blocking = check_thresholds(bandit_report, pip_audit_report, settings)

if 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)

if has_blocking:
sys.exit(1)


Expand Down
57 changes: 57 additions & 0 deletions src/python_security_auditing/annotations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Emit GitHub Actions workflow annotations for security findings."""

from __future__ import annotations

from typing import Any

from .settings import Settings

_SEVERITY_TO_LEVEL: dict[str, str] = {
"HIGH": "error",
"MEDIUM": "warning",
"LOW": "notice",
}
_SEVERITY_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}


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

These produce inline annotations on the PR 'Files changed' tab (for bandit,
which has file+line) and summary-level annotations (for pip-audit).
No email notifications are generated by annotations.
"""
if "bandit" in settings.enabled_tools:
results: list[dict[str, Any]] = bandit_report.get("results", [])

def _sort_key(r: dict[str, Any]) -> int:
return _SEVERITY_ORDER.get(r.get("issue_severity", "LOW"), 2)

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", "")
line = result.get("line_number", 0)
test_id = result.get("test_id", "")
text = (
result.get("issue_text", "")
.replace("%", "%25")
.replace("\r", "%0D")
.replace("\n", "%0A")
)
print(f"::{level} file={fname},line={line}::[{test_id}] {text}")

if "pip-audit" in settings.enabled_tools:
for pkg in pip_audit_report:
if not pkg.get("vulns"):
continue
name = pkg.get("name", "")
version = pkg.get("version", "")
for vuln in pkg["vulns"]:
vid = vuln.get("id", "")
fix = ", ".join(vuln.get("fix_versions", [])) or "no fix available"
print(f"::warning::pip-audit: {name}@{version} — {vid} (fix: {fix})")
2 changes: 1 addition & 1 deletion src/python_security_auditing/pr_comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import json
import shutil
import subprocess

Check notice on line 7 in src/python_security_auditing/pr_comment.py

View workflow job for this annotation

GitHub Actions / Security audit

[B404] Consider possible security implications associated with the subprocess module.
import sys

from .settings import Settings
Expand Down Expand Up @@ -32,7 +32,7 @@
if not settings.github_head_ref or not settings.github_repository:
return None

result = subprocess.run( # nosec B603,B605 -- list args, full path via _resolve_exe()

Check notice on line 35 in src/python_security_auditing/pr_comment.py

View workflow job for this annotation

GitHub Actions / Security audit

[B603] subprocess call - check for execution of untrusted input.
[
_resolve_exe("gh"),
"pr",
Expand All @@ -59,7 +59,7 @@

Skips silently if no PR is found or if posting is disabled.
"""
if not settings.post_pr_comment or not settings.github_token:
if not settings.github_token:
return

pr_number = resolve_pr_number(settings)
Expand All @@ -73,7 +73,7 @@

# Find an existing comment with our marker
existing_id: int | None = None
list_result = subprocess.run( # nosec B603,B605 -- list args, full path via _resolve_exe()

Check notice on line 76 in src/python_security_auditing/pr_comment.py

View workflow job for this annotation

GitHub Actions / Security audit

[B603] subprocess call - check for execution of untrusted input.
[_resolve_exe("gh"), "api", f"repos/{repo}/issues/{pr_number}/comments"],
capture_output=True,
text=True,
Expand All @@ -85,7 +85,7 @@
break

if existing_id is not None:
subprocess.run( # nosec B603,B605 -- list args, full path via _resolve_exe()

Check notice on line 88 in src/python_security_auditing/pr_comment.py

View workflow job for this annotation

GitHub Actions / Security audit

[B603] subprocess call - check for execution of untrusted input.
[
_resolve_exe("gh"),
"api",
Expand All @@ -98,7 +98,7 @@
check=True,
)
else:
subprocess.run( # nosec B603,B605 -- list args, full path via _resolve_exe()

Check notice on line 101 in src/python_security_auditing/pr_comment.py

View workflow job for this annotation

GitHub Actions / Security audit

[B603] subprocess call - check for execution of untrusted input.
[_resolve_exe("gh"), "pr", "comment", str(pr_number), "--body", body, "--repo", repo],
check=True,
)
2 changes: 1 addition & 1 deletion src/python_security_auditing/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def debug(self) -> bool:
requirements_file: str = "requirements.txt"

# PR comment config
post_pr_comment: bool = True
comment_on: Literal["never", "blocking", "always"] = "never"
github_token: str = ""

# GitHub context (standard env vars set by GitHub Actions)
Expand Down
Loading
Loading