Skip to content

Security Scanning #1607

Security Scanning

Security Scanning #1607

Workflow file for this run

name: Security Scanning
on:
push:
branches: [ main, develop, 'feature/**', 'fix/**' ]
tags: [ 'v*' ]
pull_request:
branches: [ main, develop ]
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
permissions:
contents: read
# Collapse overlapping runs on the same ref. Main and scheduled runs
# (daily 00:00 UTC audit) are preserved.
concurrency:
group: security-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.event_name != 'schedule' }}
jobs:
security-audit:
name: Python Security Audit
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install bandit pip-audit
# `requirements-lock.txt` is the audit-time dependency closure for
# pip-audit's strict mode below. Without it, pip-audit only sees
# whatever happens to be in the GitHub Actions runner image —
# which drifts. Pinning to the lock guarantees the report is
# reproducible across runs.
pip install -r requirements-lock.txt
# pip-audit STRICT MODE on the LOCK FILE (INVARIANT-2 / audit
# Issue 8 + Copilot review #322): the previous `pip-audit --desc`
# invocation surfaced findings but did not fail CI; --strict flips
# the exit status to non-zero on ANY vulnerable package. Targeting
# `requirements-lock.txt` (instead of the live environment) means
# the audit closes over a pinned dependency set rather than over
# whatever transitive versions `pip install bandit pip-audit`
# happened to resolve in the CI runner image — so the gate is
# reproducible across reruns and across runner image rolls. The
# CVE ignore-list lives in INVARIANT-14's table; new --ignore-vuln
# entries must arrive with the documented justification. See
# nosec_disposition.md for the company-wide hygiene policy.
- name: Run pip-audit (strict, lock-file scoped)
run: |
pip-audit --strict --desc --requirement requirements-lock.txt
# Bandit FAIL-CLOSED (INVARIANT-2 / INVARIANT-13 / audit Issue 8):
# the previous `bandit -l` flagged Medium+ findings to stdout but
# never failed the job. We now run bandit twice:
# 1. JSON report — uploaded as the artefact below. Includes a
# `--baseline` flag if nosec_disposition.md publishes a
# pre-classified ignore list at .github/bandit-baseline.json.
# 2. Severity gate — `--severity-level medium` fails the job on
# any Medium/High not justified inline via `# nosec`. The
# `# nosec` justifications are themselves gated by
# tools/check_suppression_hygiene.py (INVARIANT-13), so the
# two checks combine to give us: "every Medium+ finding either
# passes a code-review approved nosec with tracking ID, or
# the build breaks."
- name: Run Bandit security linter (JSON report, non-blocking)
# `--exit-zero` makes this step strictly diagnostic — it writes
# the JSON artefact for downstream tooling and uploads. The
# severity gate below is the actual fail-closed enforcement.
# Without `--exit-zero` this step exits non-zero on any Low+
# finding, which would short-circuit the Medium+ gate and turn
# the JSON-emit step itself into an enforcement layer with a
# different (looser) threshold than the documented policy
# (Copilot review #322).
run: |
bandit -r ama_cryptography/ -f json -o bandit-report.json -l --exit-zero
- name: Run Bandit security linter (severity gate, fail-closed)
run: |
bandit -r ama_cryptography/ \
--severity-level medium \
--confidence-level medium \
--exit-zero \
> bandit-medium-plus.txt
# Emit the high/medium findings to the job log for reviewer
# visibility, then count nosec-unjustified findings — exit
# non-zero if any exist. We do not use bandit's native exit
# code here because the `-l` (low) baseline above already
# captures the same scope under a different cutoff; this is
# the dedicated medium+ enforcement pass.
echo "=== Bandit Medium/High findings (fail-closed below this threshold) ==="
cat bandit-medium-plus.txt
# `bandit-medium-plus.txt` reports the totals on a line like
# "Total issues (by severity):". A clean run shows
# "Medium: 0" and "High: 0"; anything else fails.
if grep -E '^\s*(Medium|High):\s*[1-9]' bandit-medium-plus.txt; then
echo "::error::Bandit reports unjustified Medium/High findings — see report above and either fix or justify inline per INVARIANT-13."
exit 1
fi
- name: Upload security reports
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: security-reports
path: |
bandit-report.json
bandit-medium-plus.txt
retention-days: 90
# INVARIANT-11: SBOM generation is required for release tags.
# Requires adding this job to required status checks via branch/tag protection rules.
sbom:
name: SBOM Generation (CycloneDX)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install "cyclonedx-bom==7.2.2" pip-audit
pip install -r requirements-lock.txt
- name: Generate Python SBOM (CycloneDX)
run: |
cyclonedx-py environment \
-o sbom-python.json \
--of JSON
- name: Generate dependency tree
run: |
pip list --format=json > pip-list.json
- name: Run dependency audit
run: |
pip-audit --format=json --output pip-audit.json
# SBOM DRIFT GATE (audit Issue 2):
#
# docs/compliance/sbom-c-library.json is the committed CycloneDX
# artefact for the C-library components. It is rendered by
# tools/generate_sbom.py from pyproject.toml — single source of
# truth for the package version. Before this PR, the SBOM was a
# heredoc inside this very workflow with `"version": "3.0.0"`
# hardcoded across 11 components. The package had since bumped
# to 3.1.0 — and the workflow even emitted a `::warning::SBOM
# C-library component uses static metadata` that no one was
# gated on. Drift on day one.
#
# The job now does TWO things:
#
# 1. Regenerates the SBOM from pyproject.toml and compares to
# the committed copy; any drift FAILS the workflow. A PR
# that bumps pyproject.toml without regenerating the SBOM
# cannot ship.
#
# 2. Cross-checks the rendered SBOM's metadata.component.version
# and every components[].version against the package version
# independently of bytewise equality, so a future formatting
# change cannot mask a stale version that happened to pass
# byte-equal because the formatter normalised something.
- name: Generate canonical C-library SBOM (no drift)
run: |
python tools/generate_sbom.py --check
cp docs/compliance/sbom-c-library.json sbom-c-library.json
- name: Assert SBOM versions match pyproject.toml
run: |
python - <<'PY'
import json
import re
import sys
from pathlib import Path
pkg_version = re.search(
r'^version\s*=\s*"([^"]+)"',
Path("pyproject.toml").read_text(encoding="utf-8"),
re.MULTILINE,
).group(1)
sbom = json.loads(Path("sbom-c-library.json").read_text(encoding="utf-8"))
metadata_version = sbom["metadata"]["component"]["version"]
if metadata_version != pkg_version:
sys.exit(
f"FAIL: SBOM metadata.component.version={metadata_version!r}, "
f"pyproject.toml=={pkg_version!r}"
)
for component in sbom["components"]:
if component["version"] != pkg_version:
sys.exit(
f"FAIL: SBOM component {component['name']!r} version="
f"{component['version']!r}, pyproject.toml=={pkg_version!r}"
)
expected_purl = f"pkg:generic/{component['name']}@{pkg_version}"
if component["purl"] != expected_purl:
sys.exit(
f"FAIL: SBOM component {component['name']!r} purl="
f"{component['purl']!r}, expected {expected_purl!r}"
)
print(f"OK: all {len(sbom['components'])} components pinned to {pkg_version}")
PY
- name: Upload SBOM artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sbom
path: |
sbom-python.json
sbom-c-library.json
pip-list.json
pip-audit.json
retention-days: 90
secret-scanning:
name: Secret Scanning
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: TruffleHog Secret Scanning
uses: trufflesecurity/trufflehog@37b77001d0174ebec2fcca2bd83ff83a6d45a3ab # v3.95.3
with:
path: ./
extra_args: --debug --only-verified