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 installsportal/shared/requirements.txt— a range file (portal/kubernetes/docker/backend/Dockerfile:19) — whose ceilings are guarded byscripts/ci/check-python-cve-floors.mjsinstead. 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.ymlinstalls the pyproject extras as floors and explicitly excludesrequirements/locks/from its per-domainrequirements.txtloop. 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-hasheslock (#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 digestportal/kubernetes/docker/backend/Dockerfilebuilds 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.tomlfor each extra. - Runs from the repo root so
pyproject.tomlis picked up correctly. - Produces
requirements/locks/<extra>/requirements.txtwith full sha256 pinning. The path is not cosmetic: Trivy's pip analyzer and Syft's python cataloger both key on the FILENAME, andrequirements/<extra>.lockwas invisible to both — the scan reportednum=0and 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 extralocks/level is there because Trivy's default skip list is root-anchored and includesdev, so arequirements/dev/was silently dropped while its nine siblings scanned. - Accepts
--upgrade-package <name>(repeatable) and--upgrade; see §6.2. - Refuses
--nativeon 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-onlycoloramaand omitteduvloop, whichuvicorn[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.tomlchanges (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
mainthat touchesrequirements/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:
- Python lock files — one SBOM pair per extra with a non-empty lock (
dev,governance,functions,platform,portal,bff,postgres,copilot,streaming).baseis 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 insbom.ymla real assertion rather than one with a carve-out. - Portal container images —
portal-backend,portal-frontend(built locally fromportal/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.jsonsbom-python-<extra>-spdx.jsonsbom-image-<component>-cyclonedx.jsonsbom-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.0produces a signedcsa-inabox-python.intoto.jsonlattestation 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.0produces a signed attestation for each image by digest after it has been pushed toghcr.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.jsonlis 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 acrossportal,copilot). - An
azure-mgmt-*group separate fromazure-*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)¶
- Confirm the CVE ID and affected version range on the GitHub Advisory DB.
- Identify which extras pull in the vulnerable package. Grep
requirements/locks/*/requirements.txtfor the package name — the# viacomment in each lock shows the dependency chain. - 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):
-
Raise the floor in
pyproject.tomlto 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 needscryptography>=50.0.0,<51.0.0, notcryptography>=50.0.0,<49.0.0or a stale<49.0.0left in place.scripts/ci/check-python-cve-floors.mjsis the guard for exactly this mistake — it runs inloom-guardrailsand fails naming the package, the constraint, the advisory and the version that would satisfy it. Run it locally before pushing:
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:
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 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:
- Evaluate whether the vulnerable code path is reachable. Document the reachability analysis in the PR or advisory.
- If unreachable, add a time-boxed Trivy ignore with the CVE ID and an expiry date — track in
.trivyignorewith a comment linking to the upstream issue. - If reachable, mitigate at the application layer (WAF rule, feature flag, configuration change) and pin to the last-known-good version.
- Subscribe to the upstream advisory so we're notified when the patch lands.
6.3. Publish the fix¶
- After merge, the
sbom.ymlworkflow re-publishes SBOMs for the next build — these reflect the patched versions. - The Release pipeline issues new SLSA provenance for the patched artifacts.
- File a security advisory on GitHub if the CVE affected a published release (
Securitytab →Advisories→New). - 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-regexpmatching 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-validateopens 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:
- ← Previous: Troubleshooting
- → Next: Documentation home
- ⌂ Index: Documentation home