Skip to content
Supply-chain security — SBOM, signing, dependency scanning, OIDC

Supply-Chain Security

CSA-0032 — reference for the CSA-in-a-Box supply-chain guarantees. This document is the single place to look when answering:

  • How do I regenerate the dependency lock files?
  • Where are the SBOMs published?
  • How do I verify SLSA provenance on a release artifact?
  • How do I run Trivy locally?
  • A CVE just landed in a dependency — what do I do?

Overview

Control Workflow Artifact Audience
Pinned, hash-verified Python deps (local) scripts/update-locks.sh requirements/locks/*/requirements.txt SBOM + Trivy (see §1.1)
CycloneDX + SPDX SBOM (Python + images) .github/workflows/sbom.yml Workflow artifacts + Release assets SOC, customers, auditors
CVE scanning (CRITICAL gate) .github/workflows/trivy.yml PR comment, Code Scanning SARIF Reviewers
SLSA Level 3 build provenance .github/workflows/slsa-provenance.yml Signed *.intoto.jsonl on Release Downstream consumers
Loom image sign + verify-before-roll build-fiab-images-acr-tasks.yml, loom-roll-and-validate.yml, gov-console-roll.yml, full-app-deploy-commercial.yml Keyless cosign signatures in ACR (see §7) SOC, deploy operators
Weekly dependency upgrades .github/dependabot.yml PRs labeled dependencies Maintainers

1. Regenerating the lock files

The lock files in requirements/locks/*/requirements.txt are a fully-resolved, hash-pinned snapshot of each pyproject.toml optional-dependency extra. They are generated by pip-compile (from the pip-tools package) directly from pyproject.toml — each optional-dependency extra gets its own lock.

1.1. What the locks actually feed — and what they do NOT

Stated precisely, because the earlier version of this table claimed an audience these files do not have.

They feed:

  • SBOM generation (sbom.yml) — one CycloneDX + SPDX pair per non-empty extra.
  • Trivy filesystem CVE scanning (trivy.yml) — the CRITICAL gate.

They are NOT consumed by:

  • Any Dockerfile. All 37 tracked Dockerfiles were enumerated: not one references requirements/locks. The shipped portal backend installs portal/shared/requirements.txt — a range file (portal/kubernetes/docker/backend/Dockerfile:19) — whose ceilings are guarded by scripts/ci/check-python-cve-floors.mjs instead. Wiring an image to a lock would be a real improvement; until someone does it, this document does not claim it.
  • The Python test lane. test.yml installs the pyproject extras as floors and explicitly excludes requirements/locks/ from its per-domain requirements.txt loop. 495 exact pins landing in that one shared environment would silently decide what the suite measures — the #2615 defect — and the guard for it read zero pins from a --generate-hashes lock (#3485).

So a green Trivy lock scan is evidence about the declared dependency set, not about the running container. The image is covered separately by the Trivy image scans in the same workflow.

# One-time setup: Docker. The compile runs inside a digest-pinned Linux
# container, so no local pip-tools install is needed.

# Regenerate every lock (recommended)
./scripts/update-locks.sh

# Regenerate a single extra
./scripts/update-locks.sh portal

# Regenerate a subset
./scripts/update-locks.sh portal dev governance

# Raise a floor whose fix needs a transitive dependency to move with it
./scripts/update-locks.sh portal --upgrade-package cryptography --upgrade-package msal

# Print the exact commands and run none of them
./scripts/update-locks.sh --print-plan portal

The script:

  • Compiles inside python:3.12-slim@sha256:46cb7cc2… — the same digest portal/kubernetes/docker/backend/Dockerfile builds FROM — with a pinned pip-tools version, so the resolve is a property of the repo rather than of the contributor's laptop.
  • Invokes python -m piptools compile --generate-hashes --strip-extras --allow-unsafe --no-emit-index-url --extra=<name> pyproject.toml for each extra.
  • Runs from the repo root so pyproject.toml is picked up correctly.
  • Produces requirements/locks/<extra>/requirements.txt with full sha256 pinning. The path is not cosmetic: Trivy's pip analyzer and Syft's python cataloger both key on the FILENAME, and requirements/<extra>.lock was invisible to both — the scan reported num=0 and the published SBOMs had 0 components, for as long as the locks existed (#3485). Measured 2026-08-15: .lock → Trivy 0 / Syft 0; <extra>.requirements.txt → Trivy 0 / Syft 13; <extra>/requirements.txt → both. The extra locks/ level is there because Trivy's default skip list is root-anchored and includes dev, so a requirements/dev/ was silently dropped while its nine siblings scanned.
  • Accepts --upgrade-package <name> (repeatable) and --upgrade; see §6.2.
  • Refuses --native on a non-Linux host. pip-tools has no universal resolve mode, so a Windows compile really does produce a different lock — the committed portal lock carried the win32-only colorama and omitted uvloop, which uvicorn[standard] uses on Linux. Reproduced 2026-08-15: a native Windows compile of [portal] still differs by exactly that pair (#3491).

When to regenerate:

  • Any time pyproject.toml changes (new dep, version bump, new extra).
  • When Dependabot proposes an upgrade PR — regenerate the locks locally and push them to the same PR branch.
  • After a CVE-response upgrade (see §6).

Never hand-edit requirements/locks/*/requirements.txt — it is a mechanical output. Edit pyproject.toml and re-run scripts/update-locks.sh.

2. SBOM publication

The sbom.yml workflow runs:

  • On every push to main that touches requirements/locks/*/requirements.txt, pyproject.toml, or the portal Dockerfiles.
  • On every published GitHub Release.
  • On manual workflow_dispatch.

It emits two SBOM formats — CycloneDX (JSON) and SPDX (JSON) — for each of two surfaces:

  1. Python lock files — one SBOM pair per extra with a non-empty lock (dev, governance, functions, platform, portal, bff, postgres, copilot, streaming). base is deliberately absent: the bare package declares no runtime dependencies, so a 0-component SBOM for it would be TRUE — and every other extra having a non-empty lock is what makes the component floor in sbom.yml a real assertion rather than one with a carve-out.
  2. Portal container images — portal-backend, portal-frontend (built locally from portal/kubernetes/docker/*/Dockerfile).

Where to find the SBOMs

Location How Retention
Workflow run artifacts Actions → "SBOM" run → artifacts 90 days
GitHub Release assets Releases page → expand release Permanent

Artifact naming pattern:

  • sbom-python-<extra>-cyclonedx.json
  • sbom-python-<extra>-spdx.json
  • sbom-image-<component>-cyclonedx.json
  • sbom-image-<component>-spdx.json

Validating an SBOM locally

# CycloneDX validator
pip install cyclonedx-bom
cyclonedx-py validate --input-file sbom-python-portal-cyclonedx.json

# SPDX tools
pip install spdx-tools
pyspdxtools --infile sbom-python-portal-spdx.json

3. SLSA Level 3 provenance

The slsa-provenance.yml workflow runs on every published Release (on: release: types: [published]). It uses the official slsa-framework/slsa-github-generator reusable workflows:

  • Python artifacts — generator_generic_slsa3.yml@v2.1.0 produces a signed csa-inabox-python.intoto.jsonl attestation for the sdist and wheel. Sigstore Fulcio + Rekor provide keyless signing; no secret signing key is stored in the repo.
  • Portal container images — generator_container_slsa3.yml@v2.1.0 produces a signed attestation for each image by digest after it has been pushed to ghcr.io/<repo>/portal-{backend,frontend}:<tag>.

Verifying provenance on a release artifact

# Install slsa-verifier
go install github.com/slsa-framework/slsa-verifier/v2/cli/slsa-verifier@latest

# Verify a Python artifact
slsa-verifier verify-artifact csa_inabox-0.1.0-py3-none-any.whl \
    --provenance-path csa-inabox-python.intoto.jsonl \
    --source-uri github.com/<owner>/csa-inabox \
    --source-tag v0.1.0

# Verify a container image
slsa-verifier verify-image \
    ghcr.io/<owner>/csa-inabox/portal-backend@sha256:<digest> \
    --source-uri github.com/<owner>/csa-inabox \
    --source-tag v0.1.0

A PASS means the artifact was built by the exact workflow in this repo at the specified tag, on GitHub-hosted runners, with the exact source commit that the tag points to. Any tampering — modified bytes, forged signatures, swapped registry — is rejected.

What SLSA Level 3 gives you

  • Non-falsifiable — provenance is produced by an isolated builder (the SLSA reusable workflow) that the calling repo cannot influence.
  • Signed — every *.intoto.jsonl is Sigstore-signed against a short-lived Fulcio certificate; the signature is logged in the public Rekor transparency log.
  • Reproducible source mapping — the attestation binds the artifact hash to the exact commit, workflow file path, and trigger event.

4. Running Trivy locally

The trivy.yml workflow scans three surfaces on every PR. You can reproduce each scan locally before opening a PR.

# Install
brew install aquasecurity/trivy/trivy         # macOS
# or
scoop install trivy                            # Windows
# or
sudo apt install trivy                         # Debian/Ubuntu

# 1. Filesystem scan — the one that gates PRs on CRITICAL.
trivy fs --severity CRITICAL --exit-code 1 \
    --ignore-unfixed --vuln-type library \
    requirements/

# 1b. Prove the scan actually PARSED the locks. Without this, `num=0` reads
#     exactly like "no vulnerabilities" — which is how that gate passed for
#     its entire existence (#3485).
trivy fs --list-all-pkgs --format json --output /tmp/trivy-fs.json requirements/
node scripts/ci/check-lock-scan-coverage.mjs --trivy /tmp/trivy-fs.json

# 2. Docker image scan (build locally first).
docker build -t csa/portal-backend:local \
    -f portal/kubernetes/docker/backend/Dockerfile .
trivy image --severity CRITICAL --exit-code 1 \
    --ignore-unfixed csa/portal-backend:local

# 3. Config scan (Dockerfile + k8s).
trivy config --severity HIGH,CRITICAL portal/kubernetes/

PR gating rules

Severity Lock files Container images Config
CRITICAL Blocks PR Blocks PR Reports only
HIGH PR comment + SARIF SARIF Reports only
MEDIUM and below Workflow summary Workflow summary Ignored

The HIGH PR comment is idempotent — the workflow updates an existing comment rather than spamming a new one each run. SARIF output is surfaced in GitHub Code Scanning alongside CodeQL.

5. Dependabot configuration

.github/dependabot.yml watches three ecosystems:

  • pip (root, weekly) — Python deps with an azure-* group.
  • github-actions (root, weekly) — pins action SHAs.
  • npm (portal/react-webapp, weekly) — frontend deps.

When Dependabot opens a Python PR, run ./scripts/update-locks.sh locally and force-push the regenerated requirements/locks/*/requirements.txt to the same PR branch so the lock stays in sync with the pyproject.toml bump.

Suggested further groupings (non-blocking)

  • An opentelemetry-* group to batch OTel instrumentation bumps (shipped across portal, copilot).
  • An azure-mgmt-* group separate from azure-* so management-plane churn doesn't dominate PR review.
  • A pytest-* group to batch the dev test stack.

These are quality-of-life improvements and are not required for CSA-0032 closure.

6. CVE incident response

When a CVE is disclosed against a dep CSA-in-a-Box ships (detected by Trivy, CodeQL, a Dependabot security alert, or an external report):

6.1. Triage (within 1 business day)

  1. Confirm the CVE ID and affected version range on the GitHub Advisory DB.
  2. Identify which extras pull in the vulnerable package. Grep requirements/locks/*/requirements.txt for the package name — the # via comment in each lock shows the dependency chain.
  3. Classify severity using CVSS + reachability:
    • Critical — remotely exploitable, reachable from the portal or an Azure Function entry point. Mitigate within 72 hours.
    • High — exploitable but not network-reachable. Mitigate within 7 days.
    • Medium/Low — track in the next Dependabot batch.

6.2. Remediate

Option A — patched version available (the common case):

  1. Raise the floor in pyproject.toml to the fixed version and raise the upper bound with it when the fix landed in a new major. Then verify the fixed version actually falls inside the resulting range — if it does not, the constraint makes the advisory unfixable by construction and the alert will never clear no matter how often the locks are regenerated. Example: a fix in 50.0.0 needs cryptography>=50.0.0,<51.0.0, not cryptography>=50.0.0,<49.0.0 or a stale <49.0.0 left in place. scripts/ci/check-python-cve-floors.mjs is the guard for exactly this mistake — it runs in loom-guardrails and fails naming the package, the constraint, the advisory and the version that would satisfy it. Run it locally before pushing:

    node scripts/ci/check-python-cve-floors.mjs
    # reproduce either historical incident on demand:
    node scripts/ci/check-python-cve-floors.mjs --rev b6677c3a^
    

When a new advisory lands, add it to that file's ADVISORIES table with the advisory database's own first-patched version. That is what makes the next occurrence a red check instead of a review that reads fine. 2. If the package is resolved transitively by an extra that declares no constraint on it, declare one there too. A lock regenerated with no declared floor re-pins by "newest at compile time" with nothing holding it, so the next unrelated regeneration is free to drift back down. That is how six locks came to pin cryptography==46.0.7 with four open advisories against it (#3492). 3. Regenerate the affected locks:

./scripts/update-locks.sh portal copilot  # only the impacted extras
If this reports ResolutionImpossible, a package already pinned in the lock caps the one being bumped (e.g. msal==1.36.0 requires cryptography<49). pip-compile reuses the existing lock as constraints, so "no solution exists" and "the script forbade the solution" produce the same message. The script diagnoses this: it names the versions from the resolver error that really are pins in that lock and prints the re-run, which is
./scripts/update-locks.sh portal --upgrade-package cryptography --upgrade-package msal
That releases exactly those packages and freezes every other pin, so the diff stays reviewable. pip-tools does not record --upgrade-package in the lock's header, so the canonical command line in the file is unchanged and a later plain update-locks.sh run stays consistent with it.

If the script instead reports that none of the versions named is a pin in the lock, the conflict is genuinely between the declared requirements — --upgrade-package will not help and pyproject.toml is what has to change. 4. Confirm the regenerated lock actually pins the fixed version — grep '^<pkg>==' requirements/locks/*/requirements.txt. A green regeneration that left the pin where it was is the failure this step exists to catch. 5. Open a PR titled fix(security): bump <pkg> to <ver> for CVE-YYYY-NNNN. 6. Confirm the Trivy CRITICAL gate now passes on the PR. 7. Merge and cut a patch release (vX.Y.Z+1).

Option B — no patch available yet:

  1. Evaluate whether the vulnerable code path is reachable. Document the reachability analysis in the PR or advisory.
  2. If unreachable, add a time-boxed Trivy ignore with the CVE ID and an expiry date — track in .trivyignore with a comment linking to the upstream issue.
  3. If reachable, mitigate at the application layer (WAF rule, feature flag, configuration change) and pin to the last-known-good version.
  4. Subscribe to the upstream advisory so we're notified when the patch lands.

6.3. Publish the fix

  1. After merge, the sbom.yml workflow re-publishes SBOMs for the next build — these reflect the patched versions.
  2. The Release pipeline issues new SLSA provenance for the patched artifacts.
  3. File a security advisory on GitHub if the CVE affected a published release (Security tab → Advisories → New).
  4. Update the CHANGELOG under ### Security.

6.4. Post-mortem (for Critical CVEs only)

Within 2 weeks of remediation, open an ADR covering:

  • Timeline from disclosure → patch merge → release.
  • Whether existing controls (Dependabot, Trivy, CodeQL) detected the issue, and if not, why.
  • Any changes to the supply-chain pipeline to catch the next one earlier.

7. CSA Loom image signing + verify-before-roll (SC1)

The Loom container images that actually run (the loom-* Container Apps) do not ship through GHCR/releases — they are built server-side into a private-endpoint Azure Container Registry by az acr build and rolled onto Azure Container Apps. SC1 wires enforcement into that REAL deploy path:

Stage Workflow What happens
Build gate build-fiab-images-acr-tasks.yml, full-app-deploy-commercial.yml, gov-console-roll.yml Trivy image scan (CRITICAL CVEs fail the build, --ignore-unfixed), then keyless cosign sign of the manifest digest
Roll gate loom-roll-and-validate.yml (Commercial), gov-console-roll.yml (Gov), full-app-deploy-commercial.yml (verify-images job) cosign verify of the exact digest being rolled, before az containerapp update — unsigned/unknown-identity images are refused with an actionable error

Key management: keyless (none to manage)

Signing is keyless via GitHub Actions OIDC → Sigstore Fulcio (short-lived signing certificate) + Rekor (transparency log). There is no long-lived signing key to store, rotate, or leak. The workflows carry permissions: id-token: write for the OIDC exchange. Verification pins:

  • --certificate-oidc-issuer https://token.actions.githubusercontent.com
  • --certificate-identity-regexp matching ONLY the three trusted build workflows in this repository (build-fiab-images-acr-tasks.yml | full-app-deploy-commercial.yml | gov-console-roll.yml).

Because signing happens strictly AFTER the Trivy CRITICAL gate in the same job, a valid signature also attests "scanned at build time".

Manual verification

az acr login --name <acr>           # data plane must be reachable (see below)
DIGEST=$(az acr repository show --name <acr> --image loom-console:<sha> --query digest -o tsv)
cosign verify "<acr>.azurecr.io/loom-console@${DIGEST}" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  --certificate-identity-regexp \
  '^https://github\.com/<owner>/<repo>/\.github/workflows/(build-fiab-images-acr-tasks\.yml|full-app-deploy-commercial\.yml|gov-console-roll\.yml)@'

Private-registry mechanics

The Loom ACRs are publicNetworkAccess=Disabled at rest. Signatures are OCI artifacts stored in the same registry as the image, so both signing and verification are data-plane operations:

  • The build workflows sign inside their existing temporary-open window (acr_enable → build → sign → acr_restore).
  • loom-roll-and-validate opens the ACR only if it is locked, verifies, and re-locks only if it opened it (never clobbering a concurrent build's window). If the data plane stays unreachable it degrades to a loud warning — unreachable ≠ unsigned, and an infra hiccup must never permanently block an emergency roll.
  • The ACR content-trust (Docker Content Trust / Notary v1) and quarantine policies remain disabled — they are a different, legacy signing scheme that cosign does not use. The "ACR temporarily allows unsigned" toggle is a tracked drift item for the V5 scheduled what-if lane.

Emergency valves (mirror skip_uat)

Workflow Dispatch input Repo variable Effect
build-fiab-images-acr-tasks skip_supply_chain LOOM_BUILD_SKIP_SUPPLY_CHAIN Skip scan+sign (image is UNSIGNED)
loom-roll-and-validate skip_signature_verify LOOM_ROLL_SKIP_VERIFY Skip verify-before-roll
gov-console-roll skip_supply_chain — (dispatch-only workflow) Skip scan+sign+verify
full-app-deploy-commercial skip_supply_chain — (dispatch-only workflow) Skip scan+sign+verify

Every skip emits a loud ::warning::. An image built with the build valve is unsigned and will be refused by the roll gates unless their valve is also used — the two valves are deliberately independent.

IL5 design note (in-enclave ACR, offline verification)

In an IL5/air-gapped enclave the runner cannot reach public Sigstore (Fulcio/Rekor). The design there is: (1) sign at build time outside the enclave (or on the connected side of a cross-domain transfer) and import the image + its .sig OCI artifact together; (2) verify offline against the bundled signature with cosign verify --insecure-ignore-tlog pinned to an enclave-held trusted root, or switch to key-based signing with the key in the enclave's Key Vault (cosign sign --key azurekms://…, Gov/IL5 KMS URI) so no transparency-log egress is needed. The verify-before-roll step shape is identical — only the trust material changes.

8. Responsible disclosure

If you believe you have found a vulnerability in CSA-in-a-Box itself (not a dependency), report it privately per .github/SECURITY.md. Do not open a public issue or PR.


Last updated: CSA-0032 closure. Owners: Platform Security — @CODEOWNERS:.github/workflows/


See also: