Reusable platform for container image build, SBOM generation, vulnerability scanning, policy enforcement, signing, and monitoring. Shift-left approach: scan before publishing. Strict order: build → analyze → GATE → publish.
| # | Step | Tool | Explanation |
|---|---|---|---|
| 1 | Checkout code | actions/checkout |
Clones the application repo (Dockerfile, source code, custom policies). |
| 2 | Checkout toolchain | actions/checkout |
Clones the sdlc repo into .sdlc/ to access baseline policies and scripts. |
| 3 | Build image | docker/build-push-action |
Builds the container image locally (load: true, push: false). Nothing leaves the runner at this stage. |
| # | Step | Tool | Explanation |
|---|---|---|---|
| 4 | Generate SBOM | trivy image --format cyclonedx |
Scans the local image and produces a full inventory (OS packages, libraries, versions, licenses) in CycloneDX JSON format. The SBOM SHA256 hash and the image ID (aquasecurity:trivy:ImageID) are recorded at this point for integrity verification before attestation. |
| 5 | Verify image-SBOM alignment | docker inspect + jq |
Compares the actual Docker image ID with the image ID embedded in the SBOM. If they differ, the pipeline stops immediately — the SBOM must describe the exact image that was built. |
| 6 | Scan vulnerabilities | trivy image --exit-code |
Scans the image directly (not the SBOM) for HIGH and CRITICAL CVEs. Uses --exit-code 1 to block or 0 to warn without blocking. Direct image scan is the security gate: more reliable than scanning the SBOM because Trivy accesses filesystem metadata. |
| 7 | Scan SBOM | trivy sbom --exit-code 0 |
Scans the SBOM itself for vulnerabilities (advisory, non-blocking). This ensures the attested SBOM has been verified: what we sign = what we scanned. Any delta with step 6 reveals SBOM inventory gaps. Results are archived regardless of outcome. |
| 8 | Evaluate OPA policies | opa eval |
Evaluates the SBOM against Rego rules at two levels: deny (blocking — fails the pipeline) and warn (advisory — displays a warning). Baseline policies (sdlc/policies/) are automatically merged with custom policies from the app repo (policies/) if they exist. Example rules: blocked packages (known supply chain attacks), components without versions, unapproved licenses. |
══════════════════════════════════════════════════════
GATE: if step 5, 6 or 8 fails → PIPELINE STOPS
Nothing is published. The image stays local.
══════════════════════════════════════════════════════
| # | Step | Tool | Explanation |
|---|---|---|---|
| 9 | Login to registry | docker/login-action |
Authenticates to the container registry (GHCR, ACR, etc.) with the provided token. |
| 10 | Push image | docker push |
Pushes the image to the registry. At this point, we know it passed scanning and policies. |
| 11 | Resolve registry digest | docker inspect |
Retrieves the RepoDigest (sha256:...) from the registry after push. All subsequent signing and attestation operations target this immutable digest, not the mutable tag. |
| 12 | Verify SBOM integrity | sha256sum |
Recomputes the SHA256 of the SBOM file and compares it to the hash recorded at generation (step 4). If the file was modified between generation and attestation, the pipeline stops. |
| 13 | Sign digest | cosign sign --yes |
Signs the registry digest (not the tag) with Cosign. GitHub Actions uses keyless mode (OIDC via Sigstore). Azure DevOps uses Azure Key Vault KMS (azurekms://) as primary method with keyless as fallback. The signature proves the image was produced by this CI/CD pipeline and has not been tampered with. |
| 14 | Attest SBOM | cosign attest --type cyclonedx |
Cryptographically binds the SBOM to the registry digest via an In-Toto attestation. The SBOM attested is the exact same file generated in step 4 — never regenerated or modified (verified by step 12). This is the strongest guarantee: it proves that THIS SBOM describes exactly THIS image. |
| 15 | Attest SLSA provenance | actions/attest-build-provenance |
Generates and attests a SLSA build provenance predicate to the image digest. Records builder identity, source repo, revision, and build metadata. On GitHub Actions this uses the native attestation action; on Azure DevOps and local, a cosign-based provenance predicate is used via scripts/slsa-provenance.sh. |
| 16 | Verify all in registry (fail-closed) | cosign verify + cosign verify-attestation x2 |
Fail-closed: verifies all three artifacts (signature, SBOM attestation, SLSA provenance) on the same image@sha256:... digest. If any is missing or invalid, the pipeline stops. Identity constraints (--certificate-oidc-issuer + --certificate-identity-regexp) are enforced on every verify — including SLSA provenance, which is the proof of who built it. cosign tree is run first (debug) to show all referrers. All outputs are archived as CI artifacts in output/verify/ for audit trail. |
| 17 | Upload to Dependency-Track | DependencyTrack/gh-upload-sbom |
Sends the attested SBOM to Dependency-Track for continuous monitoring, linked to the registry digest (not the git SHA). Non-blocking (continue-on-error): DTrack is governance/monitoring, not a CI gate. If DTrack is down, the signed image still ships. Optional (skipped if dtrack-hostname is empty). |
Code + Dockerfile
|
v
[1-3] BUILD ──────────────> Local image
|
v
[4] SBOM ──────────────> sbom-image-trivy.json + SHA256 + ImageID
|
v
[5] IMAGE ↔ SBOM ─────> ImageID match?
| |
| OK | MISMATCH → STOP
v
[6] SCAN (trivy image) ─> HIGH/CRITICAL vulnerabilities?
| |
| OK | FAIL → STOP
v
[7] SCAN SBOM (trivy sbom) ─> Advisory (governance, archived)
|
v
[8] POLICY (OPA) ──────> deny / warn?
| |
| OK | FAIL → STOP
v
═══ GATE PASSED ═══
|
v
[9-10] PUSH ─────────────> Image in registry
|
v
[11] RESOLVE DIGEST ────> RepoDigest (sha256:...)
|
v
[12] VERIFY SBOM SHA256 ─> Untouched since step 4?
| |
| OK | MODIFIED → STOP
v
[13] SIGN ──────────────> Cosign signature on digest
|
v
[14] ATTEST SBOM ───────> SBOM bound to digest (In-Toto)
|
v
[15] ATTEST SLSA ───────> Build provenance bound to digest
|
v
[16] VERIFY ────────────> Signature + attestation in registry?
| |
| OK | FAIL → STOP
v
[17] DTRACK ────────────> Monitoring (non-blocking, linked to digest)
This section explains why the pipeline works the way it does. Each mechanism exists for a specific reason — understanding the rationale helps you make informed decisions when customizing or extending the pipeline.
The previous pipeline (poc-build-sign + poc-sbom) pushed the image first, then generated the SBOM and scanned it. This created a window where a vulnerable or non-compliant image was already in the registry. Shift-left means the image is analyzed locally before any publish — if it fails, nothing leaves the runner.
Two separate scans serve two different purposes:
-
trivy image(step 6) is the security gate. It scans the image directly, accessing filesystem metadata, OS package databases, and binary analysis. This is the most complete and reliable vulnerability assessment because Trivy sees everything the container runtime would see. This step is blocking (--exit-code 1). -
trivy sbom(step 7) is the governance scan. It scans the SBOM file that will be attested and published. This proves that the SBOM we sign has been verified — "what we sign = what we scanned". Any delta between step 6 and step 7 reveals gaps in the SBOM inventory (packages that Trivy sees in the image but are missing from the SBOM). This step is non-blocking (--exit-code 0) because SBOM-based scanning can produce false positives or miss packages that direct image scanning catches.
Tags are mutable — myimage:v1.0 can be overwritten at any time. Digests (sha256:abc123...) are immutable content-addressed references. Signing a tag provides no guarantee because the tag can be repointed to a different image after signing. The pipeline resolves the RepoDigest after push and all signing/attestation targets this digest. On Azure DevOps, if docker inspect cannot resolve the digest (timing issues with some registries), we fall back to az acr repository show and refuse to sign if no digest can be resolved — we never fall back to a mutable tag.
Invariant: The SBOM is generated, scanned, evaluated, and attested from the exact same image. Never regenerate or modify the SBOM between generation and attestation.
This is enforced by three mechanisms:
-
ImageID cross-check (step 5): The image ID embedded in the SBOM (
aquasecurity:trivy:ImageID) is compared withdocker inspectof the actual built image. If they differ, the pipeline stops. -
SHA256 hash (step 12): The SHA256 of the SBOM file is recorded at generation (step 4) and re-verified just before attestation. If the file was modified (even a single byte), the pipeline stops.
-
No rebuild between stages (Azure DevOps): Since Azure DevOps uses separate stages for BuildAndAnalyze and Publish, the image is transferred via
docker save→ artifact →docker loadto guarantee binary identity. The loaded image's ID is explicitly verified against the expected value.
The signing strategy follows a priority order: KMS > CI keyless > keypair.
All signing scripts (image-sign.sh, slsa-provenance.sh, sbom-attest.sh) use the same detection logic:
-
Azure Key Vault KMS (
azurekms://): IfCOSIGN_KMS_KEYis set. Recommended for enterprise. The private key never leaves the HSM, signing is audited in Azure, and the key can be rotated without changing the pipeline. -
Keyless (OIDC via Sigstore): If a CI OIDC provider is detected —
ACTIONS_ID_TOKEN_REQUEST_URL(GitHub Actions) orSYSTEM_OIDCREQUESTURI(Azure DevOps). The runner gets an ephemeral certificate from Fulcio. Keyless is never attempted blindly: the scripts check for CI-specific env vars first, so it never triggers an interactive browser login in local or e2e contexts. -
Keypair: If a
cosign.keyfile exists. Simplest but hardest to manage (key rotation, secure storage). Reserved for development, e2e tests, or air-gapped environments.
If none of the above is available, the script fails with a clear error listing the options.
In keyless mode, cosign verify uses --certificate-identity-regexp to filter which OIDC identities are accepted. A permissive value like ".*" would accept signatures from any pipeline on any organization — defeating the purpose of verification. The regexp should be as specific as possible:
- GitHub Actions:
"github.com/cuspofaries/"— scoped to the organization. GitHub's OIDC subject includes the repo name, so org-level scoping is already quite restrictive. - Azure DevOps:
"https://dev.azure.com/cuspofaries/sdlc/_build"— scoped to org + project + pipeline definitions. Scoping only to the org (cuspofaries/) would allow any pipeline in any project of that org to pass verification. Adding the project name (sdlc/) and_buildensures only pipelines from this specific project are accepted.
When porting to your organization, this is the first thing to change. See docs/azure-devops-porting.md for the full list.
Signing and attesting can silently fail — a --no-upload flag, a network glitch, or a registry persistence issue can result in a pipeline that declares success while the signature never made it to the registry. Step 16 runs three separate verifications on the same digest (image@sha256:..., never a mutable tag):
cosign verify— image signaturecosign verify-attestation --type cyclonedx— SBOM attestationcosign verify-attestation --type slsaprovenance— SLSA provenance
All three must pass. There is no "at least one" mode — if the SLSA provenance is missing, the pipeline fails even if the SBOM attestation is present. This is deliberate: the SBOM proves what is in the image, and the SLSA provenance proves who built it and from what source. Both are needed for a complete supply chain guarantee.
Identity constraints (--certificate-oidc-issuer + --certificate-identity-regexp) are enforced on every verification including SLSA provenance — this is the proof that the build came from the expected CI pipeline, not from an attacker with access to the registry.
A cosign tree command runs first (non-blocking, debug) to display all referrers (signature + attestations) attached to the digest — useful for troubleshooting when a verification fails.
All outputs are archived in output/verify/:
cosign-tree.log— referrer listing (debug)verify-signature.log— signature verificationverify-attestation-sbom.log— SBOM attestationverify-attestation-slsa.log— SLSA provenance
Dependency-Track is a governance and monitoring tool, not a CI gate. If DTrack is down, unreachable, or misconfigured, the signed and attested image should still ship — the security guarantees come from the pipeline gates (scan + policy + signature), not from DTrack. The SBOM is linked to the registry digest (not the git SHA) so that DTrack's inventory maps directly to the published artifact.
The DailyRescan stage (Azure DevOps) needs the original SBOM to rescan it with the latest CVE data. Rather than relying on a pipeline artifact (which can expire, be deleted, or become stale), the rescan extracts the SBOM from the cosign attestation attached to the image digest. This is the cryptographic source of truth — the attestation proves the SBOM has not been tampered with since the original pipeline run. If no attestation exists yet (first run), the stage falls back to the pipeline artifact.
Every cosign sign, cosign attest, cosign verify, and cosign verify-attestation call is preceded by an explicit echo of the target digest. This is an audit trail requirement: if an incident occurs, the CI logs provide an unambiguous record of exactly which digest was signed, attested, and verified. Verify outputs are stored as .log files because cosign produces human-readable text; JSON parsing is intentionally avoided to keep the verify step platform-agnostic. These files are archived in output/verify/ and uploaded as artifacts with 30-day retention. The artifact upload uses if: always() / condition: always() so that scan results and SBOM data are preserved even if the pipeline fails — critical for post-incident analysis.
All cosign signing and attestation operations upload entries to the Rekor transparency log by default. We deliberately removed --no-upload=true from all code paths (it was present in early iterations for keypair mode). Rekor provides a public, immutable, append-only log of all signatures — anyone can independently verify that a specific image was signed at a specific time by a specific identity. The --no-upload flag is documented as an option only for air-gapped or offline environments.
The OPA evaluation step loads policies from two sources simultaneously:
- Baseline policies (
sdlc/policies/): Maintained in this repo, applied to all consumer repos. These enforce universal rules (known supply chain attack packages, components without versions). - Custom policies (
policies/in the consumer repo): Project-specific rules (blocked libraries, license restrictions, org-specific requirements).
Both are passed to opa eval via -d flags and share the same package sbom namespace. Rules from both sources are automatically merged — no configuration needed. A consumer repo can add deny rules to block additional packages or warn rules for advisory checks without modifying the baseline.
The baseline policies (policies/sbom-compliance.rego) include:
| Level | Rule | Rationale |
|---|---|---|
| deny | Blocked packages (event-stream, colors, faker...) |
Known supply chain attacks or maintainer sabotage |
| deny | Components without version | Cannot track vulnerabilities without version |
| deny | Libraries without Package URL (purl) | Cannot cross-reference in vulnerability databases |
| deny | Copyleft licenses (GPL, AGPL, SSPL) in app libraries | Incompatible with proprietary distribution (OS packages excluded — expected in base images) |
| deny | Missing SBOM timestamp | Cannot verify freshness |
| deny | Zero components | SBOM generation likely failed |
| deny | Missing generation tool metadata | Cannot audit how SBOM was produced |
| deny | CycloneDX spec < 1.4 | Older specs lack required fields for compliance |
| warn | Unapproved licenses | Flagged for legal review, not blocking |
| warn | Missing license information | Traceability gap |
| warn | High component count (> 500) | Possible dependency bloat |
| warn | Deprecated/abandoned packages | Should be replaced |
| warn | Missing supplier/publisher metadata | Reduced traceability |
All tools are pinned to specific versions in Taskfile.yml (variables TRIVY_VERSION, COSIGN_VERSION, OPA_VERSION, CDXGEN_VERSION, ORAS_VERSION). This ensures:
- Reproducibility: same versions across dev, CI, and all consumer repos
- No surprise breaking changes: a new trivy/cosign release cannot silently change scan results or signing behavior
- Auditability: the exact tool versions are visible in
task install:verifyoutput
Renovate monitors these versions and opens PRs when updates are available, so pinning does not mean stale.
The Trivy installation step uses a retry loop with backoff (3 attempts, 5-second delay between failures). This guards against transient network failures during curl downloads in CI environments, where shared runners can experience intermittent connectivity issues. A single failed download does not fail the entire pipeline — only 3 consecutive failures do.
Business logic (digest resolution, signing mode detection, attestation, policy evaluation) lives in scripts/*.sh — never inline in Taskfile YAML. Each script follows the same contract:
set -euo pipefailat the top- Inputs via positional args and env vars (no hardcoded paths)
- Explicit logging of every digest, file, and mode before acting
- Exit 1 on failure (fail-closed)
The Taskfile only orchestrates: it calls scripts with the right variables. This prevents logic duplication and makes scripts testable independently of the task runner.
The pipeline is implemented on three platforms with the same logical flow:
| Platform | Implementation | Notes |
|---|---|---|
| GitHub Actions | .github/workflows/supply-chain-reusable.yml |
Single job, workflow_call reusable workflow |
| Azure DevOps | azure-pipelines/pipeline.yml |
Multi-stage (BuildAndAnalyze → Publish → DailyRescan) |
| Local / any CI | Taskfile.yml + scripts/ |
Portable tasks, called by both GH and ADO |
All three share the same order (build → analyze → gate → publish), the same tools (Trivy, Cosign, OPA), the same signing priority (KMS > CI keyless > keypair), and the same invariants (SBOM integrity, digest-only signing, post-publish verification). When a mechanism is added to one, it is added to all three. The validate-toolchain.yml workflow includes an end-to-end test (e2e-test job) that builds a test image, generates SBOM, runs all scans and policy checks, verifies the SBOM integrity invariant, then signs, attests, and verifies using a local registry. This catches integration regressions that unit-level checks would miss.
E2E test philosophy: as strict as prod, fail-closed. The e2e runs the exact same Taskfile tasks with the same defaults — no TRIVY_EXIT_CODE=0, no --ignore-unfixed, no relaxed identity regexp. If the test image has HIGH/CRITICAL CVEs, the e2e fails; fix the base image, don't relax the test.
Known relaxations (inherent to CI, each documented inline in the workflow):
| Relaxation | Why unavoidable | Where the real behavior is tested |
|---|---|---|
| Keypair signing (not keyless) | Keyless requires OIDC from a CI provider; scripts detect env vars (ACTIONS_ID_TOKEN_REQUEST_URL, SYSTEM_OIDCREQUESTURI) and only attempt keyless when available |
Consumer repos using supply-chain-reusable.yml with real registries |
Local registry:2 + COSIGN_ALLOW_INSECURE_REGISTRY |
No TLS without external certs in CI; this is the only env override | Consumer repos pushing to ghcr.io / ACR |
| Single runner (no save/load) | ADO multi-stage pattern is platform-specific | azure-pipelines/pipeline.yml with docker save/load + ImageID re-check |
Not relaxed (same as prod): TRIVY_EXIT_CODE=1, TRIVY_SEVERITY=HIGH,CRITICAL, OPA deny rules, digest resolution via tasks, same scripts, same policies.
Rule: every relaxation must answer WHY it can't be avoided and WHERE the real behavior is tested. If you can't answer both, don't relax.
SLSA (Supply chain Levels for Software Artifacts) provenance records who built an image, from what source, and how. The pipeline attests a SLSA provenance predicate to the image digest alongside the SBOM attestation:
- GitHub Actions: Uses
actions/attest-build-provenance@v2(native GitHub attestation, stored in the package registry). - Azure DevOps / local: Uses
scripts/slsa-provenance.shwhich generates a SLSA v0.2 predicate (builder ID, source repo, revision, build URL) and attests it via cosign with the same KMS > CI keyless > keypair priority.
This provides a verifiable chain from the published image back to the exact source commit and CI run that produced it.
The toolchain uses semantic versioning via git tags (v1.0.0, v1.2.3). Pushing a version tag triggers the release.yml workflow which:
- Validates the tag format (
vX.Y.Z) - Generates a changelog from commit history
- Creates a GitHub Release
- Updates a floating major tag (
v1) pointing to the latestv1.x.y
Consumer repos can pin to:
@v1— automatic minor/patch updates (recommended)@v1.2.0— exact version (maximum reproducibility)@main— latest development (not recommended for production)
When a vulnerability cannot be immediately fixed but is assessed as temporarily acceptable, the pipeline supports structured, time-bound, auditable exceptions via security-exceptions.yaml. This answers the auditor question: "How do you handle a temporarily acceptable vulnerability?"
Where does the file live? In the consumer repo (e.g. charly/), not in sdlc. Each project owns its own risk — a CVE acceptable for one app may not be for another. The sdlc repo provides the mechanisms (scripts + rego rules), the consumer repo provides the data (which CVEs are accepted, by whom, until when).
charly/ ← consumer repo
├── app/
│ └── Dockerfile
├── security-exceptions.yaml ← exceptions live HERE
├── policies/ ← optional custom OPA rules
└── .github/workflows/
└── build.yml ← passes exceptions-file to sdlc
The file is git-versioned in charly — every addition, modification, or removal goes through a PR, creating a complete audit trail (who added the exception, when, approved by whom).
# charly/security-exceptions.yaml
exceptions:
- id: CVE-2024-32002
package: "git"
reason: "Not exploitable in our context (no submodule clone)"
approved_by: "security@example.com"
expires: "2025-06-30"
ticket: "JIRA-1234"All 6 fields are mandatory. No permanent exceptions — expires is required.
The consumer workflow passes the file to the reusable workflow:
# charly/.github/workflows/build.yml
jobs:
supply-chain:
uses: cuspofaries/sdlc/.github/workflows/supply-chain-reusable.yml@v1
with:
context: ./app
image-name: charly
exceptions-file: security-exceptions.yaml # ← passed to both gates
secrets:
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}The mechanism operates at two independent gates:
-
Trivy gate:
scripts/trivy-exceptions.shreads the YAML and generates.trivyignorecontaining only non-expired CVE IDs. When an exception expires, it disappears from.trivyignoreand Trivy blocks it again automatically. -
OPA gate (defense in depth):
policies/security-exceptions.regoreads the same YAML (converted to JSON) and adds rules:- deny if an exception has missing or empty required fields
- deny if an exception has expired (catches stale files even if Trivy missed)
- warn if an exception expires within 7 days (renewal reminder)
- warn listing all active exceptions (audit visibility in every pipeline run)
Critical invariant: The SBOM file is never modified. Exceptions live at the gate level only. SBOM integrity (SHA256 + ImageID) is untouched.
The data flow:
security-exceptions.yaml (consumer repo, git-versioned)
|
+---> trivy-exceptions.sh ---> .trivyignore (non-expired CVEs)
| |
| trivy image --ignorefile .trivyignore
|
+---> sbom-policy.sh ---> yq -o json ---> OPA --data exceptions.json
|
+-- deny: expired, missing fields
+-- warn: expiring < 7 days
+-- warn: active exceptions (audit)
For local use with Taskfile: place security-exceptions.yaml at the project root and run task sbom:scan / task sbom:policy — the default EXCEPTIONS_FILE variable picks it up automatically. To override: task sbom:scan EXCEPTIONS_FILE=path/to/exceptions.yaml.
Baseline OPA policies are covered by unit tests in policies/sbom-compliance_test.rego (run via task opa:test or opa test policies/ -v). Tests verify both deny and warn rules using json.patch/json.remove on a minimal valid SBOM fixture. The validate-toolchain.yml CI runs these tests on every push.
The reusable GitHub Actions workflow exposes an image output containing the full image reference with digest (registry/owner/name@sha256:...). Downstream jobs can use this output to deploy, scan, or reference the exact image that was built, signed, and attested — without needing to resolve the digest themselves.
This pipeline provides the following verifiable guarantees:
| Guarantee | Mechanism | Failure mode |
|---|---|---|
| Nothing is published until scanned | Shift-left: build → analyze → GATE → publish | Pipeline stops at gate |
| SBOM describes the exact image | ImageID cross-check (step 5) | FATAL: Image ID mismatch |
| SBOM was not modified after scan | SHA256 recorded at generation, verified before attestation (step 12) | FATAL: SBOM was modified |
| Same binary across stages (ADO) | docker save → artifact → docker load + ImageID verification |
Loaded image does not match SBOM |
| Signatures target immutable digests | RepoDigest resolved after push, tag fallback refused | Cannot resolve registry digest |
| All 3 artifacts verified in registry (fail-closed) | cosign verify + verify-attestation --type cyclonedx + --type slsaprovenance on same digest (step 16) |
Pipeline stops if ANY is missing |
| Only this project's pipeline can pass verify | --certificate-identity-regexp scoped to org/project |
Signature from other pipelines rejected |
| SBOM content is cryptographically bound to image | In-Toto attestation via cosign attest --type cyclonedx |
Attestation tampering detectable |
| All signatures are publicly auditable | Rekor transparency log (no --no-upload) |
Independent verification possible |
| Build provenance is attested | SLSA provenance predicate attested to image digest | Builder, source, and revision are cryptographically bound |
| Expired security exceptions are blocked | Trivy gate (.trivyignore) + OPA deny (defense in depth) | EXPIRED on ... + Trivy blocks CVE |
| Active exceptions are auditable | OPA warn lists all active exceptions in every pipeline run | Audit trail in CI logs |
| No permanent exceptions | All 6 fields mandatory, expires required |
missing required field |
| Known-bad packages are blocked | OPA deny rules (baseline + custom) |
POLICY CHECK FAILED |
| Copyleft licenses are caught | OPA deny for GPL/AGPL/SSPL in app libraries (OS packages warn only) |
Copyleft license ... incompatible |
| Outdated SBOM specs are rejected | OPA deny for CycloneDX < 1.4 |
spec version too old |
| Pipeline chain is tested end-to-end | e2e-test job: build → SBOM → scan → policy → sign → attest → verify |
CI fails on any broken step |
| New CVEs are caught post-deploy | DailyRescan extracts SBOM from attestation, rescans with fresh data | Advisory (non-blocking) |
| DTrack failure doesn't block delivery | continue-on-error: true, linked to digest |
Signed image ships regardless |
This pipeline is designed to answer auditor questions with documented evidence. Four governance documents complement the technical guarantees above:
| Document | Content | Auditor question it answers |
|---|---|---|
| docs/executive-summary.md | 2-page slides-ready summary: business value, compliance mapping (CRA/NIS2/SSDF), evidence for auditors | "Give me the big picture in 5 minutes" |
| SECURITY.md | Vulnerability reporting, SLA, coordinated disclosure, safe harbor | "How do stakeholders report a security issue?" |
| docs/psirt-policy.md | Triage workflow, remediation SLA by severity, exception management, RACI | "What is your vulnerability response process?" |
| docs/access-governance.md | Least privilege model, KMS access, OIDC identity constraints, periodic review | "Who can sign images? How are keys managed?" |
| docs/logging-retention.md | Events logged, retention table, integrity guarantees, audit extraction procedure | "What is your log retention? How do you prove integrity?" |
Key principles across all documents:
- Digest-only — never sign a mutable tag, never reference a tag as source of truth
- Fail-closed — if a verification fails, the pipeline stops (no degraded mode)
- SBOM integrity invariant — the SBOM is never modified between generation and attestation (SHA256 + ImageID)
- KMS > CI keyless > keypair — signing priority, enforced by all scripts
- Double gate for exceptions — Trivy (
.trivyignore) + OPA (security-exceptions.rego), defense in depth
Every pipeline run produces verifiable artifacts proving compliance:
| Evidence | Location | Verification |
|---|---|---|
| Image signature | Registry referrers | cosign verify <image>@sha256:... |
| SBOM attestation | Registry referrers | cosign verify-attestation --type cyclonedx <image>@sha256:... |
| SLSA provenance | Registry referrers | cosign verify-attestation --type slsaprovenance <image>@sha256:... |
| Referrer listing | Registry | cosign tree <image>@sha256:... |
| Scan results | CI artifacts (output/) |
Download from pipeline run |
| OPA policy results | CI artifacts (output/) |
Download from pipeline run |
| Verify logs | CI artifacts (output/verify/) |
verify-signature.log, verify-attestation-sbom.log, verify-attestation-slsa.log |
| Transparency log | Rekor (public, append-only) | rekor-cli search --sha sha256:... |
| Vulnerability monitoring | Dependency-Track | Dashboard linked to registry digest |
| Exception audit trail | Git history | git log -- security-exceptions.yaml |
Any repo with a Dockerfile can consume this pipeline with a single workflow file:
# .github/workflows/build.yml
name: Supply Chain Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
packages: write
id-token: write
jobs:
supply-chain:
uses: cuspofaries/sdlc/.github/workflows/supply-chain-reusable.yml@v1
with:
context: ./app
image-name: my-app
exceptions-file: security-exceptions.yaml # optional — see "Security exceptions"
dtrack-hostname: dep-api.example.com # optional
secrets:
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DTRACK_API_KEY: ${{ secrets.DTRACK_API_KEY }} # optionalPin to @v1 for automatic minor/patch updates, or @v1.2.0 for exact version pinning.
| Input | Required | Default | Description |
|---|---|---|---|
context |
Yes | — | Path to Docker build context |
image-name |
Yes | — | Image name (without registry prefix) |
dockerfile |
No | Dockerfile |
Dockerfile path relative to context |
registry |
No | ghcr.io |
Container registry |
dtrack-hostname |
No | "" |
Dependency-Track hostname (skip if empty) |
trivy-severity |
No | HIGH,CRITICAL |
Severity filter for vulnerability scan |
trivy-exit-code |
No | "1" |
Exit code on findings (1 = fail, 0 = warn) |
exceptions-file |
No | "" |
Path to security-exceptions.yaml in caller repo (empty = no exceptions) |
airgap |
No | false |
Generate air-gap deployment package (cosign bundles + archive) |
Place a policies/ directory in your repo with .rego files using package sbom. They are automatically merged with baseline policies via OPA:
# policies/project-policies.rego
package sbom
import rego.v1
project_blocked_packages := {"moment", "request"}
deny contains msg if {
some component in input.components
component.name in project_blocked_packages
msg := sprintf("[project] Package '%s' is not allowed", [component.name])
}- Docker
- Task (go-task)
sudo task install
task install:verifytask pipeline:local \
IMAGE_NAME=my-app \
CONTEXT=../my-app-repo/appThis runs build → generate → scan → policy without pushing or signing.
task pipeline \
REGISTRY=ghcr.io/myorg \
IMAGE_NAME=my-app \
CONTEXT=../my-app-repo/app \
DTRACK_URL=http://localhost:8081 \
DTRACK_API_KEY=odt_xxx| Task | Description |
|---|---|
install |
Install all tools (trivy, cosign, cdxgen, opa, oras) |
install:verify |
Show installed tool versions |
build |
Build image locally (no push) |
sbom:generate |
Generate image SBOM (CycloneDX) |
sbom:generate:source |
Generate source SBOM (declared deps) |
sbom:scan |
Scan image for vulnerabilities (trivy image — security gate) |
sbom:scan:sbom |
Scan SBOM for vulnerabilities (trivy sbom — governance, advisory) |
sbom:policy |
Evaluate SBOM against OPA policies |
opa:test |
Run OPA unit tests on policy rules |
exceptions:validate |
Validate security-exceptions.yaml format and expiry |
push |
Push image to registry |
image:sign |
Sign image digest (cosign) |
image:verify |
Verify image signature |
sbom:attest |
Attest SBOM to image digest |
slsa:attest |
Attest SLSA build provenance to image digest |
sbom:attest:verify |
Verify signature and attestation are published in the registry |
sbom:sign:blob |
Sign SBOM as standalone file |
sbom:verify |
Verify SBOM signature and integrity |
sbom:store |
Store SBOM as OCI artifact in registry (ORAS) |
sbom:fetch |
Fetch SBOM from OCI registry (ORAS) |
sbom:upload |
Upload SBOM to Dependency-Track |
sbom:tamper:test |
Demo SBOM tampering detection |
airgap:export |
Package signed image + cosign bundles for air-gapped deployment |
airgap:verify |
Verify image signature + attestations from air-gap package (offline) |
pipeline |
Full pipeline (build → analyze → publish) |
pipeline:local |
Local pipeline (build → analyze only) |
dtrack:up |
Start local Dependency-Track |
dtrack:down |
Stop local Dependency-Track |
clean |
Remove generated files |
See docs/dependency-track.md for setup and integration details.
task dtrack:up # Start local instance
task sbom:upload # Upload SBOMAn Azure Pipelines template is available at azure-pipelines/pipeline.yml implementing the same pipeline with Azure-specific adaptations.
| Stage | Trigger | Description |
|---|---|---|
| BuildAndAnalyze | Every CI run | Build + SBOM generation + vulnerability scan + policy evaluation. Nothing is pushed. Image is saved as artifact via docker save for the next stage. |
| Publish | Only if BuildAndAnalyze succeeds | docker load from artifact (no rebuild), push, sign, attest, verify in registry, upload to DTrack. |
| DailyRescan | Scheduled (cron) | Extracts SBOM from cosign attestation (source of truth), rescans with latest CVE data, uploads fresh results to DTrack. |
- Image transfer between stages:
docker savein BuildAndAnalyze → pipeline artifact →docker loadin Publish. No rebuild, guaranteed binary identity. - Digest resolution:
docker inspectwithaz acr repository showas fallback. Refuses to sign if no immutable digest is resolved. - Signing: Azure Key Vault KMS (
azurekms://) as primary, keyless (OIDC viavstoken.dev.azure.com) as fallback. - Keyless identity:
--certificate-identity-regexpscoped to"https://dev.azure.com/cuspofaries/sdlc/_build"(org + project + pipeline scope). - SBOM integrity across stages: SHA256 + ImageID recorded as files in artifact, verified after
docker loadin Publish stage. - DailyRescan SBOM source: Extracted from cosign attestation (
cosign verify-attestation | jq), falls back to pipeline artifact.
See docs/azure-devops-porting.md for a complete checklist of files and lines to modify when deploying this pipeline in your own Azure DevOps organization (identity-regexp, service connections, variable group, KMS setup, etc.).
sdlc/
├── .github/workflows/
│ ├── supply-chain-reusable.yml ← Unified workflow (consumed by app repos)
│ ├── daily-rescan.yml ← Scheduled rescan with latest CVE data
│ ├── validate-toolchain.yml ← CI + end-to-end pipeline test
│ └── release.yml ← Semantic versioning (tag → GitHub Release)
├── azure-pipelines/
│ └── pipeline.yml ← Azure DevOps template
├── scripts/ ← Shell scripts (Taskfile orchestrates, scripts do the work)
│ ├── image-sign.sh
│ ├── image-verify.sh
│ ├── sbom-attest.sh
│ ├── sbom-integrity.sh
│ ├── sbom-generate-source.sh
│ ├── sbom-policy.sh
│ ├── trivy-exceptions.sh
│ ├── sbom-sign.sh
│ ├── sbom-tamper-test.sh
│ ├── sbom-upload-dtrack.sh
│ ├── sbom-verify.sh
│ ├── slsa-provenance.sh
│ ├── airgap-export.sh ← Package image + bundles for air-gap
│ └── airgap-verify.sh ← Offline verification on air-gapped env
├── policies/
│ ├── sbom-compliance.rego ← Baseline OPA policies
│ ├── sbom-compliance_test.rego ← OPA unit tests
│ ├── security-exceptions.rego ← Exception validation rules (CRA/NIS2)
│ └── security-exceptions_test.rego ← Exception tests
├── docs/
│ ├── access-governance.md ← Access control, KMS, RACI, periodic review
│ ├── airgap-deployment.md ← Air-gap deployment: export, transfer, offline verify
│ ├── azure-devops-porting.md ← Porting checklist for Azure DevOps
│ ├── executive-summary.md ← 2-page slides-ready summary for RSSI/auditors
│ ├── dependency-track.md
│ ├── dependency-track.fr.md
│ ├── logging-retention.md ← Log retention, integrity, audit extraction
│ └── psirt-policy.md ← Vulnerability response workflow, SLA, exceptions
├── docker-compose.dtrack.yml
├── Taskfile.yml
├── renovate.json
├── POSTMORTEM.md
├── README.md
└── SECURITY.md ← Vulnerability reporting policy
Before (caller needed 2 jobs):
jobs:
build:
uses: cuspofaries/poc-build-sign/.github/workflows/build-sign-reusable.yml@main
# ...
sbom:
needs: build
uses: cuspofaries/poc-sbom/.github/workflows/sbom-reusable.yml@main
# ...After (single job):
jobs:
supply-chain:
uses: cuspofaries/sdlc/.github/workflows/supply-chain-reusable.yml@v1
with:
context: ./app
image-name: my-app
secrets:
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}Key changes:
- Image is scanned before push (shift-left)
- SBOM is guaranteed to match the published digest
- Single atomic workflow — no race conditions between jobs
- Tool versions are managed in one place