CoursesSoftware supply chain securityA DevSecOps maturity roadmap

A DevSecOps maturity roadmap

Where to start, and what "good" looks like.

Advanced12 min · lesson 18 of 18

A building inspector does not show up once, at the end, and bless a finished house. They come in stages. The foundation is checked after it is poured and before the framing goes up. The wiring is checked while it is still open in the walls, before the drywall hides it. Each stage has to pass before the next one is allowed to cover it up, and nothing gets an occupancy permit until every stage has been signed off. Supply-chain security is built the same way. You do not buy the whole thing in one purchase order. You add one control, make it actually hold, and only then build the next control on top of it.

So the real question is not what the finished chain looks like. It is what you pour first, and how you know it set. The controls that cost the least stop the most common attacks, so they come first. You get most of your risk reduction from a handful of cheap, boring habits long before you touch cryptographic signing, build attestations, or admission control. This lesson walks through the order those controls arrive in, and it gives you one plain test for whether each control is real yet. Does the machine refuse the bad input on its own? If the control only prints a warning that a human has to notice, it is not real yet.

The order controls arrive in
1Foundation
protect the default branch, secret and dependency scanning as gating pipeline stages, pin dependencies and base images, harden runners
2Provenance
sign every artifact, generate a bill of materials per build, record a signed statement of how it was built
3Enforcement
verify signatures at admission, block anything unsigned or unknown
4Operate
rescan the bills of materials you kept, so a new bug is a query and not a fire drill

Start With the Cheap Controls

The default branch of your repository is the master copy that ships. Branch protection is the rule that says nobody changes the master copy alone: a second person has to sign off, and a checklist of automated checks has to pass, before anything merges. It is the cheapest control you own and it stops the most boring, most common attack, which is an unreviewed change going straight into what you release. You can read the current state of that rule with gh, the GitHub command-line tool, and it is worth running on your busiest repository right now.

terminal
gh api repos/acme/payments-api/branches/main/protection \
--jq '{reviews: .required_pull_request_reviews.required_approving_review_count,
enforce_admins: .enforce_admins.enabled,
checks: .required_status_checks.contexts}'
output
{
"reviews": 2,
"enforce_admins": true,
"checks": [
"ci/secret-scan",
"ci/dependency-scan"
]
}

Two required reviews, the rule applies even to admins, and two automated checks must pass. That is a foundation that has set. If instead the command answers with a 404 and the words "Branch not protected," you have found your Monday: an open default branch means one leaked token can push whatever it likes into your release line, with no second pair of eyes and no gate. The two checks named above are the next two controls in the foundation. One scans every change for secrets. The other scans every change for known-vulnerable dependencies. Both must be gating stages in your continuous integration pipeline (the automated system that builds and tests every change), which means a failure blocks the merge rather than printing a warning nobody reads. Here is what the secret scanner catches when someone commits an access key by accident.

terminal
gitleaks detect --source . --redact -v
output
│╲
│ ○
○ ░
░ gitleaks
Finding: AWS_SECRET_ACCESS_KEY=REDACTED
Secret: REDACTED
RuleID: generic-api-key
Entropy: 4.286445
File: deploy/staging.env
Line: 12
Commit: 9c4f1e2b7a3d...
Author: Sam Rivera
Date: 2026-06-02T14:07:11Z
Fingerprint: 9c4f1e2b7a3d...:deploy/staging.env:generic-api-key:12
2:07PM INF 1 commits scanned.
2:07PM INF scan completed in 84.2ms
2:07PM WRN leaks found: 1

That non-zero exit is what fails the CI stage and stops the merge. Now the runners, which are the machines that actually run your builds. A compromised build machine is the worst kind of break, because it sits downstream of review: it can tamper with an artifact after all the humans have approved the source. Two habits harden it cheaply. Make runners ephemeral, so each job gets a fresh machine that is destroyed afterward and carries nothing over from the last job. And keep no long-lived cloud keys on the box. Instead, let each job mint a short-lived credential through OIDC (OpenID Connect, a standard way for one machine to prove who it is to another without a shared password). You can check the second habit directly on the runner.

terminal
# On the build runner: is there a standing cloud key sitting in the environment?
systemctl show actions.runner.acme.gha-07.service -p Environment
printenv | grep -iE 'AWS_SECRET|GCP_KEY|AZURE_CLIENT_SECRET'
output
Environment=RUNNER_ALLOW_RUNASROOT=0

The grep prints nothing. There is no static secret to steal, because the runner federates per job through OIDC and the credential expires minutes later. The last foundation control is pinning. A version tag like python:3.12-slim is a label that can be repointed at a different image tomorrow, the same way a nameplate on a door can be swapped. A digest cannot: it is a fingerprint of the exact bytes. Pin the base image to a digest, and require your dependency installs to match a hash, so an attacker who repoints a tag or swaps a package upstream changes the fingerprint and your build refuses it.

Dockerfile
# A tag can be repointed at new bytes; a digest is those exact bytes.
FROM python:3.12-slim@sha256:3e6f0e6b1c...c9
COPY requirements.txt .
# Every wheel must match a recorded hash, or the install aborts.
RUN pip install --require-hashes --no-deps -r requirements.txt
terminal
docker inspect --format '{{index .RepoDigests 0}}' \
registry.acme.internal/payments-api:2.4.1
output
registry.acme.internal/payments-api@sha256:5b7e2c9f1a...f3

Prove Where Things Came From

Once the foundation has set, you add a layer that is about proof rather than prevention. Think of a wax seal on a letter. Anyone can write a letter, but only the person with the signet ring can seal it, and a broken seal tells you the letter was opened on the way. Signing does that for an artifact: the pipeline presses its seal onto the finished image, and later anyone can check the seal came from that pipeline and the bytes have not changed since. Cosign, from the Sigstore project, does the signing and the checking. With keyless signing there is no private key to guard; the identity in the seal is the exact workflow that built it, proven through OIDC. This is what verifying that seal looks like.

terminal
cosign verify \
--certificate-identity-regexp 'https://github.com/acme/.+/.github/workflows/.+' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
registry.acme.internal/payments-api:2.4.1
output
Verification for registry.acme.internal/payments-api:2.4.1 --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The code-signing certificate was verified using trusted certificate authority certificates
[{"critical":{"identity":{"docker-reference":"registry.acme.internal/payments-api"},"image":{"docker-manifest-digest":"sha256:5b7e2c9f..."},"type":"cosign container image signature"},"optional":{"Issuer":"https://token.actions.githubusercontent.com","Subject":"https://github.com/acme/payments-api/.github/workflows/release.yml@refs/tags/v2.4.1"}}]

The Subject line is the payoff: this image was sealed by the release workflow of the payments-api repository, at tag v2.4.1, and nothing else could have produced that seal. Alongside the seal you generate an SBOM (a software bill of materials, an itemized packing list of every library and package baked into the build). Syft writes the list; Grype reads it against a vulnerability database.

terminal
syft registry.acme.internal/payments-api:2.4.1 -o spdx-json=payments-api.sbom.json
output
✔ Loaded image payments-api:2.4.1
✔ Parsed image sha256:5b7e2c9f...
✔ Cataloged contents b2c3d4e5...
├── ✔ Packages [412 packages]
├── ✔ File digests [1204 files]
└── ✔ Executables [88 executables]
terminal
grype sbom:payments-api.sbom.json
output
✔ Vulnerability DB [updated]
✔ Scanned for vulnerabilities [2 vulnerability matches]
├── by severity: 1 critical, 1 medium
└── by status: 1 fixed, 1 not-fixed
NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY
libcurl4 7.88.1-10 7.88.1-10+deb12u5 deb CVE-2023-38545 Critical
openssl 3.0.11-1 (won't fix) deb CVE-2024-0727 Medium

Here CVE stands for Common Vulnerabilities and Exposures, the public ID number given to a known security bug. Keep every SBOM you generate, keyed to its image digest, and this scan stops being a thing you run in a panic. When a new CVE lands next month, you re-run Grype against SBOMs you already have, and in seconds you know which images in production contain the affected package. That is the difference between a query and a crisis.

Make the Cluster Say No

Everything so far is groundwork if nothing checks it at the moment of deployment. A signed image that nobody verifies is a locked door with the key taped to the frame. Enforcement is the guard at admission, which in Kubernetes (the system that schedules and runs your containers across a fleet of machines) is the checkpoint every new workload passes through before the cluster agrees to start it. You install a policy that says: for images from our registry, only run ones carrying a valid seal from our release workflow. This is that policy, using the Sigstore policy-controller.

cluster-image-policy.yaml
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
name: require-acme-signature
spec:
images:
- glob: "registry.acme.internal/**"
authorities:
- keyless:
identities:
- issuer: https://token.actions.githubusercontent.com
subjectRegExp: "https://github.com/acme/.+/.github/workflows/.+"
terminal
# A well-meaning engineer hand-builds a hotfix and pushes it straight to the
# registry, skipping the pipeline that would have signed it.
kubectl run payments-hotfix --image=registry.acme.internal/payments-api:2.4.1-hotfix
output
Error from server (BadRequest): admission webhook "policy.sigstore.dev" denied the request:
validation failed: failed policy: require-acme-signature: spec.containers[0].image
registry.acme.internal/payments-api:2.4.1-hotfix is not allowed by any of the authorities:
no matching signatures

That rejection is the whole point of the roadmap made visible. The cluster refused an unsigned image with no human in the loop. Now "you cannot deploy something unsigned" is a fact the machine enforces, not a rule people are asked to remember.

Turn on enforcement in audit mode first
If you flip signature verification to enforce across every namespace at once, the first thing you block is your own cluster: kube-system, your ingress controller, your monitoring stack, none of which you signed. Start the ClusterImagePolicy in warn mode (set spec.mode: warn), watch the admission logs for what it would have denied, add explicit exemptions or a separate authority for trusted third-party images, then switch to enforce one namespace at a time. Otherwise your first enforced control is also your first outage.

What Good Looks Like

A mature supply chain has one property you can state in a sentence: every artifact running in production traces back to reviewed source, was built by a pipeline you trust, carries a seal and an attestation (a signed statement about how and where it was built), was verified at admission, is inventoried by an SBOM, and gets rescanned as new bugs surface. The word that matters in that sentence is verified. Each of those links is enforced by a machine, not left to anyone's discipline. You cannot deploy something unsigned. You can answer "what is in production and where did it come from?" in minutes, from the digest to the seal to the SBOM. And a new CVE is a lookup against lists you already keep. That is roughly what the graded SLSA standard (Supply-chain Levels for Software Artifacts, a checklist for how tamper-resistant a build process is) measures at its higher levels. The bar is not whether you produce provenance, the record of where an artifact came from and how it was built. The bar is whether something checks that provenance before anything runs.

The trap at this level is staring at the full chain, deciding it is too big, and doing nothing. That is the wrong frame. Every link you secure and then enforce cuts real risk on its own. Branch protection alone is a genuine win. Signing alone, once something verifies it, is another. A partial chain that the machine actually enforces beats a complete design that lives only on a whiteboard. Adopt one control, prove it refuses the bad input, then climb to the next. An enforced control is one the machine says no for you; anything less is a suggestion.

Quick check
01Your pipeline signs every image and writes an SBOM for each build, but the cluster still starts whatever image a deployment names. Where does that leave the signing control on the roadmap?
Incorrect — How much effort a control took to build is not what grades it. The roadmap asks whether a machine turns bad input away on its own, and here nothing does.
Incorrect — Signing is cheap groundwork that keeps its value the moment something inspects it. Tearing it out only puts you further from a chain you can enforce.
Correct — A seal nobody inspects protects nothing. The control becomes real when admission rejects an unsigned image by itself, the way the policy-controller webhook denies the hotfix run.
Incorrect — An SBOM answers what is inside an image. It never decides whether that image may start, so it solves a different problem from a deploy-time gate.
02The roadmap deliberately puts branch protection, secret scanning and dependency scanning ahead of signing, attestations and admission control. What drives that order?
Incorrect — There is no such tooling dependency between them. You could sign and verify images tomorrow on a repository with no scanners at all; the order is a choice about payoff.
Incorrect — Cosign and the rest of Sigstore are open source and free to run. Licensing plays no part in where the roadmap places them.
Incorrect — No external body sets this sequence. It comes from which control removes the most risk for the least work, not from a certification queue.
Correct — An unreviewed change walking straight into a release is the ordinary way things go wrong, and a second reviewer plus two gating scans stops it for almost nothing.
03On a build runner, systemctl show actions.runner.acme.gha-07.service -p Environment prints only Environment=RUNNER_ALLOW_RUNASROOT=0, and the printenv | grep -iE 'AWS_SECRET|GCP_KEY|AZURE_CLIENT_SECRET' after it prints nothing at all. What have you learned?
Incorrect — Both commands report the machine as it stands, and nothing here makes the answer depend on job timing. Treat the empty result as the finding, not as a failed check.
Correct — That bare environment is the hardened state you want. The job proves its identity through OIDC and gets a credential measured in minutes, so taking the runner hands an attacker nothing lasting.
Incorrect — systemd printed the one variable that is set and would have printed any others the same way. Nothing is being hidden from you.
Incorrect — Nothing in either result points at a key on disk, and the design described here mints a credential per job rather than parking one anywhere.

Pick the loudest broken link and fix that one this week. Run the branch-protection check on your busiest repository right now; if it answers with "Branch not protected," you already know where to pour the first slab, and every control after it has something to stand on.

Try this

Run gitleaks detect --source . --redact -v on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: turn on enforcement in audit mode first. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related