A DevSecOps maturity roadmap
Where to start, and what "good" looks like.
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.
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.
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}'
{"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.
gitleaks detect --source . --redact -v
○│╲│ ○○ ░░ gitleaksFinding: AWS_SECRET_ACCESS_KEY=REDACTEDSecret: REDACTEDRuleID: generic-api-keyEntropy: 4.286445File: deploy/staging.envLine: 12Commit: 9c4f1e2b7a3d...Author: Sam RiveraEmail: [email protected]Date: 2026-06-02T14:07:11ZFingerprint: 9c4f1e2b7a3d...:deploy/staging.env:generic-api-key:122:07PM INF 1 commits scanned.2:07PM INF scan completed in 84.2ms2: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.
# On the build runner: is there a standing cloud key sitting in the environment?systemctl show actions.runner.acme.gha-07.service -p Environmentprintenv | grep -iE 'AWS_SECRET|GCP_KEY|AZURE_CLIENT_SECRET'
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.
# A tag can be repointed at new bytes; a digest is those exact bytes.FROM python:3.12-slim@sha256:3e6f0e6b1c...c9COPY requirements.txt .# Every wheel must match a recorded hash, or the install aborts.RUN pip install --require-hashes --no-deps -r requirements.txt
docker inspect --format '{{index .RepoDigests 0}}' \registry.acme.internal/payments-api:2.4.1
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.
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
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.
syft registry.acme.internal/payments-api:2.4.1 -o spdx-json=payments-api.sbom.json
✔ Loaded image payments-api:2.4.1✔ Parsed image sha256:5b7e2c9f...✔ Cataloged contents b2c3d4e5...├── ✔ Packages [412 packages]├── ✔ File digests [1204 files]└── ✔ Executables [88 executables]
grype sbom:payments-api.sbom.json
✔ Vulnerability DB [updated]✔ Scanned for vulnerabilities [2 vulnerability matches]├── by severity: 1 critical, 1 medium└── by status: 1 fixed, 1 not-fixedNAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITYlibcurl4 7.88.1-10 7.88.1-10+deb12u5 deb CVE-2023-38545 Criticalopenssl 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.
apiVersion: policy.sigstore.dev/v1beta1kind: ClusterImagePolicymetadata:name: require-acme-signaturespec:images:- glob: "registry.acme.internal/**"authorities:- keyless:identities:- issuer: https://token.actions.githubusercontent.comsubjectRegExp: "https://github.com/acme/.+/.github/workflows/.+"
# 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
Error from server (BadRequest): admission webhook "policy.sigstore.dev" denied the request:validation failed: failed policy: require-acme-signature: spec.containers[0].imageregistry.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.
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.
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?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.