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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ If you run this action from more than one workflow on the same PR with `comment_

## How blocking works

The job fails (non-zero exit) when **either** tool finds issues above its configured threshold.
The job fails (non-zero exit) when **either** tool finds issues above its configured threshold. It also fails when pip-audit cannot run, for example when the export fails or the requirements file is missing.

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

Expand All @@ -330,7 +330,7 @@ The job fails (non-zero exit) when **either** tool finds issues above its config
| `bandit_scan_dirs` | `.` | Comma-separated directories for bandit to scan (relative to `working_directory`) |
| `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` |
| `package_manager` | `requirements` | How to resolve deps for pip-audit: `uv`, `poetry`, `pipenv`, `requirements`. For a pip environment, run `pip freeze > requirements.txt` first and use `requirements` |
| `requirements_file` | `requirements.txt` | Path to requirements file when `package_manager=requirements` |
| `working_directory` | `.` | Directory to run the audit from (useful for monorepos) |
| `comment_on` | `never` | When to post a PR comment: `never`, `blocking` (only when issues block the job), or `always` |
Expand Down
4 changes: 2 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
description: When to block on pip-audit findings — fixable, all, or none
default: fixable
package_manager:
description: How to resolve dependencies for pip-audit (uv, pip, poetry, pipenv, requirements)
description: How to resolve dependencies for pip-audit (uv, poetry, pipenv, requirements)
default: requirements
requirements_file:
description: Path to requirements file when package_manager=requirements
Expand Down Expand Up @@ -63,7 +63,7 @@
- name: Run Bandit (static security analysis)
if: contains(inputs.tools, 'bandit')
continue-on-error: true
uses: lhoupert/bandit-action@18022d5292d04b21fae1bfa44597b94402ba7365
uses: lhoupert/bandit-action@e2d48932beda5cc8c50cb45ed39f8d873ef2d365
with:
targets: ${{ steps.resolve-targets.outputs.targets }}

Expand Down
77 changes: 74 additions & 3 deletions integration-tests/tests/test_validate_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
- TestParseSarif
- TestParsePipAudit
- TestValidateTest (pure logic, no I/O)
- TestCheckAudited
- TestGenerateReport
- TestMain (integration, uses tmp_path + monkeypatch)
"""
Expand Down Expand Up @@ -248,6 +249,46 @@ def test_expected_pip_audit_package_absent_returns_error(self) -> None:
assert any("requests" in e for e in errors)


# ---------------------------------------------------------------------------
# check_audited
# ---------------------------------------------------------------------------


class TestCheckAudited:
"""A pip-audit case without a report, or with nothing audited, must not pass as clean."""

def _write(self, tmp_path: Path, deps: list[dict[str, Any]]) -> Path:
path = tmp_path / "pip-audit-report.json"
path.write_text(json.dumps({"dependencies": deps, "fixes": []}))
return path

def test_missing_report_returns_error(self) -> None:
errors = vr.check_audited({"expected_conclusion": "success"}, None)

assert any("did not run" in e for e in errors)

@pytest.mark.parametrize(
"deps",
[[], [{"name": "my-app", "skip_reason": "Dependency not found on PyPI"}]],
)
def test_zero_audited_dependencies_returns_error(
self, tmp_path: Path, deps: list[dict[str, Any]]
) -> None:
errors = vr.check_audited({"expected_conclusion": "success"}, self._write(tmp_path, deps))

assert any("did not run" in e for e in errors)

def test_audited_dependency_returns_no_error(self, tmp_path: Path) -> None:
path = self._write(tmp_path, [{"name": "requests", "version": "2.32.0", "vulns": []}])

assert vr.check_audited({"expected_conclusion": "success"}, path) == []

def test_pip_audit_disabled_needs_no_report(self) -> None:
expected = {"expected_conclusion": "failure", "pip_audit_disabled": True}

assert vr.check_audited(expected, None) == []


# ---------------------------------------------------------------------------
# generate_report
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -314,16 +355,20 @@ def test_happy_path_returns_zero(self, tmp_path: Path, monkeypatch: pytest.Monke
}
self._write_expected_yml(tmp_path, tests)

# Test 01: clean SARIF, no pip-audit artifact
clean_audit = {"dependencies": [{"name": "requests", "version": "2.32.0", "vulns": []}]}

# Test 01: clean SARIF, clean pip-audit report
art01 = tmp_path / "artifacts" / "security-audit-01"
art01.mkdir(parents=True)
(art01 / "results.sarif").write_text(json.dumps({"runs": [{"results": []}]}))
(art01 / "pip-audit-report.json").write_text(json.dumps(clean_audit))

# Test 02: B602 finding in SARIF
# Test 02: B602 finding in SARIF; pip-audit report nested, as artifact upload does
art02 = tmp_path / "artifacts" / "security-audit-02"
art02.mkdir(parents=True)
(art02 / "02-case").mkdir(parents=True)
sarif02 = {"runs": [{"results": [{"ruleId": "B602", "level": "error"}]}]}
(art02 / "results.sarif").write_text(json.dumps(sarif02))
(art02 / "02-case" / "pip-audit-report.json").write_text(json.dumps(clean_audit))

needs = {"test-01": {"result": "success"}, "test-02": {"result": "failure"}}
monkeypatch.setenv("NEEDS_JSON", json.dumps(needs))
Expand All @@ -335,6 +380,32 @@ def test_happy_path_returns_zero(self, tmp_path: Path, monkeypatch: pytest.Monke

assert vr.main() == 0

def test_missing_pip_audit_report_returns_one(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
tests = {
"01": {
"name": "clean",
"expected_conclusion": "success",
"bandit_findings": [],
"pip_audit_findings": [],
},
}
self._write_expected_yml(tmp_path, tests)
art01 = tmp_path / "artifacts" / "security-audit-01"
art01.mkdir(parents=True)
(art01 / "results.sarif").write_text(json.dumps({"runs": [{"results": []}]}))

monkeypatch.setenv("NEEDS_JSON", json.dumps({"test-01": {"result": "success"}}))
monkeypatch.setattr(vr, "EXPECTED_COUNT", 1)
monkeypatch.setattr(vr, "ARTIFACTS_DIR", tmp_path / "artifacts")
monkeypatch.setattr(vr, "EXPECTED_RESULTS_PATH", tmp_path / "expected_results.yml")
monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False)
monkeypatch.chdir(tmp_path)

assert vr.main() == 1
assert "did not run" in (tmp_path / "validation-report.md").read_text()

def test_conclusion_mismatch_returns_one(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
Expand Down
27 changes: 19 additions & 8 deletions integration-tests/validate_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,20 @@ def validate_test(
return errors


def check_audited(expected: dict[str, Any], pip_audit_path: Path | None) -> list[str]:
"""Error when a pip-audit case has no report or audited zero dependencies.

The action used to report a crashed or empty audit as clean, so a case
without a report would otherwise pass as "no vulns".
"""
if expected.get("pip_audit_disabled", False):
return []
deps = json.loads(pip_audit_path.read_bytes())["dependencies"] if pip_audit_path else []
if not any("skip_reason" not in dep for dep in deps):
return ["pip-audit: no report or zero dependencies audited (the audit did not run)"]
return []


# ---------------------------------------------------------------------------
# Report generation
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -251,21 +265,18 @@ def main() -> int:
if sarif_path.exists():
bandit_findings = parse_sarif(sarif_path)

pip_audit_path = artifact_dir / "pip-audit-report.json"
if pip_audit_path.exists():
# Artifact upload uses least common ancestor, so the file may be nested
# e.g. artifacts/security-audit-08/08-poetry-src-both/pip-audit-report.json
pip_audit_path = next(artifact_dir.rglob("pip-audit-report.json"), None)
if pip_audit_path:
pip_audit_findings = parse_pip_audit(pip_audit_path)
else:
# Artifact upload uses least common ancestor, so the file may be nested
# e.g. artifacts/security-audit-08/08-poetry-src-both/pip-audit-report.json
nested = next(artifact_dir.rglob("pip-audit-report.json"), None)
if nested:
pip_audit_findings = parse_pip_audit(nested)

all_bandit[num] = bandit_findings
all_pip_audit[num] = pip_audit_findings

conclusion = conclusions.get(num, "missing")
errors = validate_test(num, exp, conclusion, bandit_findings, pip_audit_findings)
errors += check_audited(exp, pip_audit_path)
all_errors[num] = errors

status = "✅" if not errors else "❌"
Expand Down
31 changes: 23 additions & 8 deletions src/python_security_auditing/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@
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
from .runners import (
PIP_AUDIT_REPORT,
AuditError,
generate_requirements,
read_bandit_sarif,
run_pip_audit,
)
from .settings import Settings


Expand All @@ -21,6 +27,7 @@ def main() -> None:

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

if "bandit" in settings.enabled_tools:
if settings.debug:
Expand All @@ -39,23 +46,31 @@ def main() -> None:
f"[debug] generating requirements for package_manager={settings.package_manager}",
file=sys.stderr,
)
requirements_path = generate_requirements(settings)
if settings.debug:
print(f"[debug] running pip-audit on {requirements_path}", file=sys.stderr)
pip_audit_report = run_pip_audit(requirements_path, settings)
PIP_AUDIT_REPORT.unlink(missing_ok=True) # never upload an earlier run's report
try:
requirements_path = generate_requirements(settings)
if settings.debug:
print(f"[debug] running pip-audit on {requirements_path}", file=sys.stderr)
pip_audit_report = run_pip_audit(requirements_path, settings)
except (AuditError, FileNotFoundError) as exc:
pip_audit_error = str(exc)
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)
markdown = build_markdown(bandit_report, pip_audit_report, settings, pip_audit_error)
write_step_summary(markdown, settings)
emit_annotations(bandit_report, pip_audit_report, settings)
emit_annotations(bandit_report, pip_audit_report, settings, pip_audit_error)

has_blocking = check_thresholds(bandit_report, pip_audit_report, settings)
has_blocking = bool(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)

if pip_audit_error:
sys.exit(f"pip-audit did NOT run, so dependencies were NOT audited.\n{pip_audit_error}")
if has_blocking:
sys.exit(1)

Expand Down
17 changes: 11 additions & 6 deletions src/python_security_auditing/annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@
_SEVERITY_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}


def _escape(text: str) -> str:
"""Escape workflow-command message data."""
return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")


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

Expand All @@ -37,15 +43,14 @@ def _sort_key(r: dict[str, Any]) -> int:
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")
)
text = _escape(result.get("issue_text", ""))
print(f"::{level} file={fname},line={line}::[{test_id}] {text}")

if "pip-audit" in settings.enabled_tools:
if pip_audit_error:
print(f"::error::pip-audit did NOT run: {_escape(pip_audit_error)}")
elif not any("skip_reason" not in pkg for pkg in pip_audit_report):
print("::warning::pip-audit audited 0 dependencies, so nothing was checked")
for pkg in pip_audit_report:
if not pkg.get("vulns"):
continue
Expand Down
19 changes: 16 additions & 3 deletions src/python_security_auditing/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ def build_markdown(
bandit_report: dict[str, Any],
pip_audit_report: list[dict[str, Any]],
settings: Settings,
pip_audit_error: str = "",
) -> str:
"""Build a full markdown security report."""
sections: list[str] = ["# Security Audit Report\n"]
Expand All @@ -29,9 +30,9 @@ def build_markdown(
sections.append(_bandit_section(bandit_report, settings))

if "pip-audit" in settings.enabled_tools:
sections.append(_pip_audit_section(pip_audit_report, settings))
sections.append(_pip_audit_section(pip_audit_report, settings, pip_audit_error))

blocking = check_thresholds(bandit_report, pip_audit_report, settings)
blocking = check_thresholds(bandit_report, pip_audit_report, settings) or bool(pip_audit_error)
sections.append("---\n")
if blocking:
sections.append("**Result: ❌ Blocking issues found — see details above.**\n")
Expand Down Expand Up @@ -103,7 +104,7 @@ def _bandit_section(report: dict[str, Any], settings: Settings) -> str:
return "\n".join(lines)


def _pip_audit_section(report: list[dict[str, Any]], settings: Settings) -> str:
def _pip_audit_section(report: list[dict[str, Any]], settings: Settings, error: str) -> str:
vulnerable = [pkg for pkg in report if pkg.get("vulns")]
security_url = (
f"https://github.com/{settings.github_repository}/security/dependabot"
Expand All @@ -117,6 +118,18 @@ def _pip_audit_section(report: list[dict[str, Any]], settings: Settings) -> str:
)
lines = [heading]

if error:
lines.append(
f"❌ pip-audit did NOT run, so dependencies were NOT audited.\n\n```\n{error}\n```\n"
)
return "\n".join(lines)

audited = sum("skip_reason" not in pkg for pkg in report)
lines.append(f"_Dependencies audited: {audited}, skipped: {len(report) - audited}._\n")
if not audited:
lines.append("⚠️ No dependencies were audited.\n")
return "\n".join(lines)

if not vulnerable:
lines.append("✅ No vulnerabilities found.\n")
return "\n".join(lines)
Expand Down
Loading
Loading