Security Scanning #1607
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |