NIST P-256/384/521, ML-KEM/ML-DSA parameter sets, and key interoperability formats #1766
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
| # Copyright (C) 2025-2026 Steel Security Advisors LLC | |
| # SPDX-License-Identifier: Apache-2.0 | |
| 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Set up Python | |
| uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.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. Suppression | |
| # hygiene (`# nosec`) is enforced by tools/check_suppression_hygiene.py | |
| # (INVARIANT-13). | |
| - 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 a pre-classified ignore list is published | |
| # 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Set up Python | |
| uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.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 (lock-file scoped) | |
| # Scope to requirements-lock.txt — the same pinned dependency set the | |
| # SBOM describes — instead of auditing the whole runner environment. | |
| # A bare `pip-audit` here reported vulnerabilities in packages AMA does | |
| # not ship (GitHub runner preinstalls such as pip / pyjwt / urllib3), | |
| # failing the SBOM job for reasons unrelated to and unfixable from this | |
| # repository, and producing an evidence artefact that did not describe | |
| # the audited artefact. Matches the "Run pip-audit (strict, lock-file | |
| # scoped)" step in the security-scan job above. | |
| run: | | |
| pip-audit --format=json --output pip-audit.json \ | |
| --requirement requirements-lock.txt | |
| # 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| fetch-depth: 0 | |
| - name: TruffleHog Secret Scanning | |
| uses: trufflesecurity/trufflehog@00155c9dc586f34d189adc83d3ac2698c2ec551f # v3.95.8 | |
| with: | |
| path: ./ | |
| extra_args: --debug --only-verified | |
| security-gate: | |
| name: Security Gate | |
| # Single aggregating status check for this workflow. Branch protection | |
| # should require ONLY this context instead of the individual job names: | |
| # adding, renaming, or matrix-expanding a job then updates this needs: | |
| # list under code review rather than drifting the branch-protection | |
| # config out of sync (required-context drift). `needs:` is | |
| # workflow-local, so every workflow carries its own gate. | |
| # | |
| # if: always() so the gate resolves to a definitive red/green even when a | |
| # dependency fails — otherwise it would report as `skipped` and leave the | |
| # PR stuck "waiting for status". | |
| if: always() | |
| needs: | |
| - security-audit | |
| - sbom | |
| - secret-scanning | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 3 | |
| steps: | |
| - name: Fail if any required job did not succeed | |
| # STRICT: anything other than `success` is a failure, so a `skipped` or | |
| # `cancelled` dependency cannot report green. Every job in this | |
| # workflow is unconditional (no job-level `if:`), so on any trigger | |
| # they are all expected to run to completion — a skip here would mean | |
| # something silently did not happen, which is exactly what this gate | |
| # exists to catch. | |
| if: ${{ contains(needs.*.result, 'failure') | |
| || contains(needs.*.result, 'cancelled') | |
| || contains(needs.*.result, 'skipped') }} | |
| run: | | |
| echo "::error::Security Gate failed — a required job did not succeed." | |
| echo "Dependency results: ${{ join(needs.*.result, ', ') }}" | |
| exit 1 | |
| - name: All required jobs succeeded | |
| run: echo "Security Gate passed — all required jobs are green." |