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: 4 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@ updates:
interval: weekly
commit-message:
prefix: "ci"
cooldown:
default-days: 7

- package-ecosystem: pip
directory: /
schedule:
interval: weekly
commit-message:
prefix: "deps"
cooldown:
default-days: 7
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ on:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
with:
python-version: '3.13'
Expand All @@ -19,6 +24,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
with:
python-version: '3.13'
Expand All @@ -31,6 +38,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
with:
python-version: '3.13'
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,16 @@ jobs:
# Move major version tag (e.g. v1) after a release is cut
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
if: ${{ steps.release.outputs.release_created }}
with:
persist-credentials: false
- name: Tag major version
if: ${{ steps.release.outputs.release_created }}
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git"
git tag -fa "v${{ steps.release.outputs.major }}" \
-m "Release v${{ steps.release.outputs.tag_name }}"
git push origin "v${{ steps.release.outputs.major }}" --force
4 changes: 3 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ repos:
additional_dependencies:
- pydantic-settings>=2.0
- bandit>=1.8
- pytest>=8.0

- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.23.1
Expand All @@ -24,4 +25,5 @@ repos:
rev: '1.9.4'
hooks:
- id: bandit
args: [-r, src/]
args: [-r, src/, --skip, "B404,B603,B607"]
pass_filenames: false
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "python-security-auditing"
version = "0.1.0"
description = "Reusable GitHub Action for Python security auditing with bandit and pip-audit"
license = { text = "MIT" }
requires-python = ">=3.11"
requires-python = ">=3.13"
dependencies = [
"pydantic-settings>=2.0",
"bandit>=1.8",
Expand All @@ -33,7 +33,7 @@ line-length = 100
select = ["E", "F", "I", "UP"]

[tool.mypy]
python_version = "3.11"
python_version = "3.13"
strict = true

[tool.pytest.ini_options]
Expand Down
15 changes: 3 additions & 12 deletions src/python_security_auditing/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,7 @@ def _bandit_section(report: dict[str, Any], settings: Settings) -> str:
lines.append("✅ No issues found.\n")
return "\n".join(lines)

lines.append(
"| Severity | Confidence | File | Line | Issue |\n"
"|---|---|---|---|---|\n"
)
lines.append("| Severity | Confidence | File | Line | Issue |\n" "|---|---|---|---|---|\n")
for r in results:
sev = r.get("issue_severity", "")
conf = r.get("issue_confidence", "")
Expand Down Expand Up @@ -82,8 +79,7 @@ def _pip_audit_section(report: list[dict[str, Any]], settings: Settings) -> str:
return "\n".join(lines)

lines.append(
"| Package | Version | ID | Fix Versions | Description |\n"
"|---|---|---|---|---|\n"
"| Package | Version | ID | Fix Versions | Description |\n" "|---|---|---|---|---|\n"
)
for pkg in vulnerable:
name = pkg.get("name", "")
Expand All @@ -95,12 +91,7 @@ def _pip_audit_section(report: list[dict[str, Any]], settings: Settings) -> str:
lines.append(f"| {name} | {version} | {vid} | {fix_versions} | {desc} |")

total_vulns = sum(len(pkg.get("vulns", [])) for pkg in vulnerable)
fixable = sum(
1
for pkg in vulnerable
for v in pkg.get("vulns", [])
if v.get("fix_versions")
)
fixable = sum(1 for pkg in vulnerable for v in pkg.get("vulns", []) if v.get("fix_versions"))
lines.append(
f"\n_{total_vulns} vulnerability/vulnerabilities found "
f"({fixable} fixable) across {len(vulnerable)} package(s)._\n"
Expand Down
2 changes: 1 addition & 1 deletion src/python_security_auditing/runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def run_bandit(scan_dirs: list[str]) -> dict[str, Any]:
)

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


Expand Down
78 changes: 49 additions & 29 deletions tests/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,60 +4,62 @@

import json
from pathlib import Path
from typing import Any, cast

import pytest

from python_security_auditing.report import build_markdown, check_thresholds, write_step_summary
from python_security_auditing.settings import Settings

FIXTURES = Path(__file__).parent / "fixtures"


def load(name: str) -> object:
def load(name: str) -> Any:
return json.loads((FIXTURES / name).read_text())


@pytest.fixture()
def bandit_clean() -> dict: # type: ignore[type-arg]
return load("bandit_clean.json") # type: ignore[return-value]
def bandit_clean() -> dict[str, Any]:
return cast(dict[str, Any], load("bandit_clean.json"))


@pytest.fixture()
def bandit_issues() -> dict: # type: ignore[type-arg]
return load("bandit_issues.json") # type: ignore[return-value]
def bandit_issues() -> dict[str, Any]:
return cast(dict[str, Any], load("bandit_issues.json"))


@pytest.fixture()
def pip_clean() -> list: # type: ignore[type-arg]
return load("pip_audit_clean.json") # type: ignore[return-value]
def pip_clean() -> list[Any]:
return cast(list[Any], load("pip_audit_clean.json"))


@pytest.fixture()
def pip_fixable() -> list: # type: ignore[type-arg]
return load("pip_audit_fixable.json") # type: ignore[return-value]
def pip_fixable() -> list[Any]:
return cast(list[Any], load("pip_audit_fixable.json"))


@pytest.fixture()
def pip_unfixable() -> list: # type: ignore[type-arg]
return load("pip_audit_unfixable.json") # type: ignore[return-value]
def pip_unfixable() -> list[Any]:
return cast(list[Any], load("pip_audit_unfixable.json"))


# ---------------------------------------------------------------------------
# check_thresholds
# ---------------------------------------------------------------------------


def test_clean_no_blocking(bandit_clean: dict, pip_clean: list) -> None: # type: ignore[type-arg]
def test_clean_no_blocking(bandit_clean: dict[str, Any], pip_clean: list[Any]) -> None:
s = Settings()
assert check_thresholds(bandit_clean, pip_clean, s) is False


def test_bandit_high_blocks(bandit_issues: dict, pip_clean: list) -> None: # type: ignore[type-arg]
def test_bandit_high_blocks(bandit_issues: dict[str, Any], pip_clean: list[Any]) -> None:
s = Settings() # threshold=HIGH
assert check_thresholds(bandit_issues, pip_clean, s) is True


def test_bandit_medium_does_not_block_at_high_threshold(bandit_issues: dict, pip_clean: list) -> None: # type: ignore[type-arg]
def test_bandit_medium_does_not_block_at_high_threshold(
bandit_issues: dict[str, Any], pip_clean: list[Any]
) -> None:
"""bandit_issues has HIGH and MEDIUM; only HIGH should block when threshold=HIGH."""
s = Settings()
# Remove HIGH results so only MEDIUM remain
Expand All @@ -68,7 +70,9 @@ def test_bandit_medium_does_not_block_at_high_threshold(bandit_issues: dict, pip
assert check_thresholds(medium_only, pip_clean, s) is False


def test_bandit_medium_blocks_at_medium_threshold(bandit_issues: dict, pip_clean: list, monkeypatch: pytest.MonkeyPatch) -> None: # type: ignore[type-arg]
def test_bandit_medium_blocks_at_medium_threshold(
bandit_issues: dict[str, Any], pip_clean: list[Any], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("BANDIT_SEVERITY_THRESHOLD", "MEDIUM")
s = Settings()
medium_only = {
Expand All @@ -78,29 +82,39 @@ def test_bandit_medium_blocks_at_medium_threshold(bandit_issues: dict, pip_clean
assert check_thresholds(medium_only, pip_clean, s) is True


def test_pip_fixable_blocks_by_default(bandit_clean: dict, pip_fixable: list) -> None: # type: ignore[type-arg]
def test_pip_fixable_blocks_by_default(
bandit_clean: dict[str, Any], pip_fixable: list[Any]
) -> None:
s = Settings() # pip_audit_block_on=fixable
assert check_thresholds(bandit_clean, pip_fixable, s) is True


def test_pip_unfixable_does_not_block_on_fixable(bandit_clean: dict, pip_unfixable: list) -> None: # type: ignore[type-arg]
def test_pip_unfixable_does_not_block_on_fixable(
bandit_clean: dict[str, Any], pip_unfixable: list[Any]
) -> None:
s = Settings() # pip_audit_block_on=fixable
assert check_thresholds(bandit_clean, pip_unfixable, s) is False


def test_pip_unfixable_blocks_on_all(bandit_clean: dict, pip_unfixable: list, monkeypatch: pytest.MonkeyPatch) -> None: # type: ignore[type-arg]
def test_pip_unfixable_blocks_on_all(
bandit_clean: dict[str, Any], pip_unfixable: list[Any], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("PIP_AUDIT_BLOCK_ON", "all")
s = Settings()
assert check_thresholds(bandit_clean, pip_unfixable, s) is True


def test_pip_fixable_does_not_block_on_none(bandit_clean: dict, pip_fixable: list, monkeypatch: pytest.MonkeyPatch) -> None: # type: ignore[type-arg]
def test_pip_fixable_does_not_block_on_none(
bandit_clean: dict[str, Any], pip_fixable: list[Any], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("PIP_AUDIT_BLOCK_ON", "none")
s = Settings()
assert check_thresholds(bandit_clean, pip_fixable, s) is False


def test_bandit_only_tool_skips_pip(bandit_issues: dict, pip_fixable: list, monkeypatch: pytest.MonkeyPatch) -> None: # type: ignore[type-arg]
def test_bandit_only_tool_skips_pip(
bandit_issues: dict[str, Any], pip_fixable: list[Any], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("TOOLS", "bandit")
s = Settings()
# pip-audit not in enabled tools, so fixable vulns should not block
Expand All @@ -109,15 +123,19 @@ def test_bandit_only_tool_skips_pip(bandit_issues: dict, pip_fixable: list, monk
assert result is True


def test_pip_only_tool_skips_bandit(bandit_issues: dict, pip_fixable: list, monkeypatch: pytest.MonkeyPatch) -> None: # type: ignore[type-arg]
def test_pip_only_tool_skips_bandit(
bandit_issues: dict[str, Any], pip_fixable: list[Any], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("TOOLS", "pip-audit")
s = Settings()
# bandit not in enabled tools, bandit HIGH issues should not block
result = check_thresholds(bandit_issues, pip_fixable, s)
assert result is True # pip-audit fixable issues do block


def test_pip_only_no_bandit_blocking(bandit_issues: dict, pip_clean: list, monkeypatch: pytest.MonkeyPatch) -> None: # type: ignore[type-arg]
def test_pip_only_no_bandit_blocking(
bandit_issues: dict[str, Any], pip_clean: list[Any], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("TOOLS", "pip-audit")
s = Settings()
assert check_thresholds(bandit_issues, pip_clean, s) is False
Expand All @@ -128,41 +146,43 @@ def test_pip_only_no_bandit_blocking(bandit_issues: dict, pip_clean: list, monke
# ---------------------------------------------------------------------------


def test_markdown_contains_header(bandit_clean: dict, pip_clean: list) -> None: # type: ignore[type-arg]
def test_markdown_contains_header(bandit_clean: dict[str, Any], pip_clean: list[Any]) -> None:
s = Settings()
md = build_markdown(bandit_clean, pip_clean, s)
assert "# Security Audit Report" in md


def test_markdown_clean_result(bandit_clean: dict, pip_clean: list) -> None: # type: ignore[type-arg]
def test_markdown_clean_result(bandit_clean: dict[str, Any], pip_clean: list[Any]) -> None:
s = Settings()
md = build_markdown(bandit_clean, pip_clean, s)
assert "No blocking issues found" in md
assert "✅" in md


def test_markdown_blocking_result(bandit_issues: dict, pip_clean: list) -> None: # type: ignore[type-arg]
def test_markdown_blocking_result(bandit_issues: dict[str, Any], pip_clean: list[Any]) -> None:
s = Settings()
md = build_markdown(bandit_issues, pip_clean, s)
assert "Blocking issues found" in md
assert "❌" in md


def test_markdown_bandit_table(bandit_issues: dict, pip_clean: list) -> None: # type: ignore[type-arg]
def test_markdown_bandit_table(bandit_issues: dict[str, Any], pip_clean: list[Any]) -> None:
s = Settings()
md = build_markdown(bandit_issues, pip_clean, s)
assert "B404" in md
assert "src/app.py" in md


def test_markdown_pip_table(bandit_clean: dict, pip_fixable: list) -> None: # type: ignore[type-arg]
def test_markdown_pip_table(bandit_clean: dict[str, Any], pip_fixable: list[Any]) -> None:
s = Settings()
md = build_markdown(bandit_clean, pip_fixable, s)
assert "requests" in md
assert "GHSA-j8r2-6x86-q33q" in md


def test_markdown_run_url(bandit_clean: dict, pip_clean: list, monkeypatch: pytest.MonkeyPatch) -> None: # type: ignore[type-arg]
def test_markdown_run_url(
bandit_clean: dict[str, Any], pip_clean: list[Any], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo")
monkeypatch.setenv("GITHUB_RUN_ID", "999")
s = Settings()
Expand Down
13 changes: 9 additions & 4 deletions tests/test_runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from unittest.mock import MagicMock, patch

import pytest

from python_security_auditing.runners import generate_requirements, run_bandit, run_pip_audit
from python_security_auditing.settings import Settings

Expand Down Expand Up @@ -103,7 +102,9 @@ def test_run_bandit_parses_json(tmp_path: Path, monkeypatch: pytest.MonkeyPatch)
assert report["results"][0]["issue_severity"] == "HIGH"


def test_run_bandit_returns_empty_on_missing_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def test_run_bandit_returns_empty_on_missing_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)

with patch("python_security_auditing.runners.subprocess.run") as mock_run:
Expand Down Expand Up @@ -146,7 +147,9 @@ def test_run_pip_audit_parses_json(tmp_path: Path, monkeypatch: pytest.MonkeyPat
assert (tmp_path / "pip-audit-report.json").exists()


def test_run_pip_audit_returns_empty_on_no_output(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def test_run_pip_audit_returns_empty_on_no_output(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)

with patch("python_security_auditing.runners.subprocess.run") as mock_run:
Expand All @@ -156,7 +159,9 @@ def test_run_pip_audit_returns_empty_on_no_output(tmp_path: Path, monkeypatch: p
assert report == []


def test_run_pip_audit_uses_requirements_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def test_run_pip_audit_uses_requirements_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
req_path = tmp_path / "custom-reqs.txt"

Expand Down
Loading
Loading