CoursesCompliance as codeCompliance in DevSecOps

Compliance in DevSecOps

Controls built into the pipeline and platform.

Advanced30 min · lesson 15 of 15

It is quarter-end, and a SOC 2 auditor sends you one email. SOC 2 is the audit that checks how well a company looks after customer data. The email says: prove that no infrastructure reached production this quarter without passing your encryption and public-access checks, and show me the signed record for the exact build that shipped release 2026.7. When compliance is bolted on afterwards, that email costs you a week of screenshots, Slack archaeology, and quiet hope that the evidence covers the whole period. When compliance is code, the answer is one file you already produced, because the pipeline that enforced the controls also signed the proof. This capstone builds that pipeline end to end. It scans your infrastructure, tests it against policy you wrote yourself, and signs the passing result, wired so that a failure at any step stops the release before it ships.

One pipeline, three tools, one gate

Three tools, wired in a line, all of them from earlier in this course. Checkov is the building inspector with the standard checklist. It reads infrastructure as code (the text files that describe your cloud, in Terraform or CloudFormation) and grades it against a big library of rules already mapped to CIS, PCI and SOC 2, which is to say the Center for Internet Security hardening benchmarks, the payment card standard, and the customer-data audit. Conftest covers the rules that standard checklist never heard of. It runs your Rego policies (Rego is the small rule language that Open Policy Agent uses) against config files: Terraform, Kubernetes manifests, Dockerfiles. Treat them as unit tests for the controls that are specific to your company. Cosign is the notary. It cryptographically signs a statement saying this build was evaluated against control set X and passed, so an auditor or a downstream team can trust the result without re-running everything. What turns three reports into a gate is the exit code, the small number a command hands back when it finishes: zero means fine, anything else means trouble. Each tool returns non-zero the moment a control fails, the CI job fails with it (CI is continuous integration, the automation that runs on every push), and a non-compliant change therefore cannot advance to build, push, or deploy. Here is the sample repository the pipeline runs against.

sample repo layout
$ tree -L 2 compliance-pipeline/
compliance-pipeline/
├── .gitlab-ci.yml # the pipeline: 3 stages, gated on exit codes
├── Dockerfile # app image, built only after config passes
├── app/
│ └── main.py
├── infra/ # Terraform — scanned by Checkov AND Conftest
│ ├── s3.tf
│ └── rds.tf
├── k8s/
│ └── deployment.yaml # manifest — tested by Conftest
├── policy/ # your Rego controls (from a shared library)
│ ├── terraform.rego
│ └── kubernetes.rego
└── evidence/
└── .gitkeep # compliance.json predicate lands here, cosign signs it

Three stages run in order: compliance, build, evidence. CI treats them as locked doors in a row. Build never starts unless every job in compliance exited zero. Evidence never starts unless build succeeded. That ordering is deliberate, and the ordering is itself a control. The cheap configuration checks go first so failures land fast and cost minutes. You only pay to build and push an image once its infrastructure and manifests are provably clean. And you only ever sign an attestation for an artifact that actually passed, so the signature can never vouch for a non-compliant build.

All of that rests on the exit code being allowed to travel. The quickest way to make a compliance pipeline quietly useless is a step that swallows failure: wrapping the command in || true, running Checkov with --soft-fail, or marking the job allow_failure: true. The first two make the tool exit zero. The last lets the job go red without ever blocking the pipeline. The findings still print, so the logs look busy and healthy, and the release ships anyway. Read your pipeline file hunting for anything that forces success. A gate that cannot fail is not a gate. It is a report with a green checkmark on it.

.gitlab-ci.yml — the runnable pipeline
stages: [compliance, build, evidence]
variables:
IMAGE: $CI_REGISTRY_IMAGE
checkov:
stage: compliance
image: bridgecrew/checkov:3.2.334
script:
- checkov -d infra/ --compact --framework terraform -o json > checkov.json
artifacts:
paths: [checkov.json]
when: always # keep the evidence even when the job fails
# a failed check exits non-zero -> job fails -> pipeline stops here
conftest:
stage: compliance
image: openpolicyagent/conftest:v0.56.0
script:
- conftest test infra/ k8s/ --policy policy/ -o json | tee conftest.json
artifacts:
paths: [conftest.json]
when: always
build:
stage: build # only runs if BOTH compliance jobs exited 0
image: gcr.io/kaniko-project/executor:v1.23.2-debug
script:
- /kaniko/executor --destination "$IMAGE:$CI_COMMIT_SHORT_SHA" --digest-file digest.txt
artifacts:
paths: [digest.txt]
attest:
stage: evidence # only runs if a compliant image was built
image: bitnami/cosign:2.4.1
id_tokens:
SIGSTORE_ID_TOKEN:
aud: sigstore # keyless OIDC identity for THIS pipeline
script:
- jq -s '{checkov:.[0], conftest:.[1], pipeline:env.CI_PIPELINE_URL}'
checkov.json conftest.json > evidence/compliance.json
- cosign attest --yes --type custom
--predicate evidence/compliance.json "$IMAGE@$(cat digest.txt)"

Run it stage by stage

You do not need CI to watch the gate work. Every step runs on your laptop, which is the point: a developer gets the same verdict before pushing and fixes it the way they would fix a failing unit test. Install Checkov and scan the infrastructure directory first.

step 1 — install and run the IaC scan
$ pip install checkov==3.2.334
$ checkov -d infra/ --compact --framework terraform ; echo "exit=$?"
step 1 — Checkov output (2 controls fail, exit 1)
_ _
___| |__ ___ ___| | _______ __
/ __| '_ \ / _ \/ __| |/ / _ \ \ / /
| (__| | | | __/ (__| < (_) \ V /
\___|_| |_|\___|\___|_|\_\___/ \_/
By Prisma Cloud | version: 3.2.334
terraform scan results:
Passed checks: 34, Failed checks: 2, Skipped checks: 0
Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"
FAILED for resource: aws_s3_bucket.data
File: /infra/s3.tf:1-6
Check: CKV_AWS_16: "Ensure all data stored in the RDS is securely encrypted at rest"
FAILED for resource: aws_db_instance.app
File: /infra/rds.tf:1-9
exit=1

Two failed checks, exit code 1. In CI that non-zero status turns the compliance stage red and halts the pipeline right there, inside the merge request, where the fix costs ten minutes instead of an audit finding six months later. Switch on access logging and encryption for RDS (Amazon's managed database service) in the Terraform, and the same command exits zero. Next comes the policy layer, for controls that Checkov's built-in rules do not cover. Ownership is a good example. A bucket with nobody's name on it has nobody accountable for it, and SOC 2 flags that directly. A few lines of Rego write down the rule 'every S3 bucket must carry an owner tag', and Conftest checks the same infrastructure against it.

step 2 — policy/terraform.rego (your control as code)
package main
import rego.v1 # enables contains/if on conftest v0.56.0's OPA 0.69
# Control: SOC2 CC6.1 / CIS 1.20 — every S3 bucket must carry an owner tag
deny contains msg if {
bucket := input.resource.aws_s3_bucket[name]
not bucket.tags.owner
msg := sprintf("S3 bucket '%s' is missing required tag: owner", [name])
}
step 2 — run Conftest against the same infra
$ conftest test infra/ --policy policy/ ; echo "exit=$?"
FAIL - infra/s3.tf - main - S3 bucket 'data' is missing required tag: owner
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions
exit=1

With infrastructure and policy both green, the build stage produces and pushes the image, and the evidence stage signs it. Cosign uses keyless signing in CI. Instead of a long-lived private key you have to store, rotate, and pray never leaks, it asks Sigstore for a short-lived certificate tied to the pipeline's own OIDC identity (OpenID Connect, the standard way one system proves to another which pipeline it really is). It works like a day pass issued for one shift rather than a master key that lives on somebody's keyring. Cosign then signs the compliance evidence as a custom predicate attached to the image digest, and records the entry in a public transparency log, an append-only ledger anyone can read. The predicate is the merged Checkov and Conftest output: machine-readable proof of which controls ran and passed for this exact artifact, bound to its digest (the content hash that names one specific image) so nobody can slide it onto a different one.

step 3 — attest the passing build, then verify it
$ cosign attest --yes --type custom \
--predicate evidence/compliance.json "$IMAGE@$DIGEST"
$ cosign verify-attestation --type custom \
--certificate-identity-regexp '^https://gitlab.com/acme/compliance-pipeline' \
--certificate-oidc-issuer https://gitlab.com \
"$IMAGE@$DIGEST"
step 3 — cosign output (signed + verified)
Using payload from: evidence/compliance.json
Generating ephemeral keys...
Retrieving signed certificate...
tlog entry created with index: 149204871
Verification for registry.gitlab.com/acme/compliance-pipeline@sha256:9f2a1c... --
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

Verification is the other half, and the half teams forget. Running cosign verify-attestation against the image digest, pinned to the pipeline's certificate identity and OIDC issuer, confirms the evidence came from this pipeline and was not forged, and it hands the auditor a predicate they can read for themselves. This is the verify-once, trust-downstream pattern from supply-chain security, pointed at compliance: the decision gets signed once at build time and consumed many times afterwards, by a deploy gate, an admission controller, or an auditor working a checklist. Be precise about what that signature claims, though. A passing check is evidence toward a control, not a certification. The auditor still wants your written narrative of how the control works and how they can sample it.

Reading the pipeline run

A green run tells the whole audit story in one place. A red run stops the release at the exact control that failed. The job log makes the gate visible. Here Checkov passes, then a Conftest policy fails, so the compliance stage goes red and the build and evidence stages never run. Nothing non-compliant gets built, and nothing unproven gets signed.

pipeline run #4471 — a gated failure
Running with gitlab-runner 17.4.0 on shared-runner
>> compliance:checkov
$ checkov -d infra/ --compact --framework terraform -o json > checkov.json
Passed checks: 36, Failed checks: 0, Skipped checks: 0
Job succeeded # exit 0
>> compliance:conftest
$ conftest test infra/ k8s/ --policy policy/ -o json | tee conftest.json
FAIL - k8s/deployment.yaml - main - container 'api' must set runAsNonRoot (SOC2 CC6.1)
3 tests, 2 passed, 0 warnings, 1 failure, 0 exceptions
ERROR: Job failed: exit code 1 # exit 1 -> stage red
build -> skipped (stage 'compliance' did not succeed)
attest -> skipped (no compliant image to attest)
Pipeline #4471 failed
Every stage gates on its exit code
each stage passes control only on exit 0
checkov -> conftest -> build -> attest
checkov exit 1
IaC misconfig (CKV_AWS_*) -> compliance red
block merge; no build, no deploy
conftest failure
Rego control violated -> compliance red
block merge; nothing gets attested
build fails
image never pushed to registry
evidence stage skipped
all exit 0
IaC + policy pass, image pushed by digest
cosign attests digest -> signed, logged evidence -> deploy
The exit code is the whole gate. Non-zero anywhere halts the release, and only an all-green run produces an attestation, so the signature can never vouch for a non-compliant build.

Making it hold at scale

Three things separate a demo pipeline from one an auditor will accept across fifty repositories. Share the policy: the Rego in policy/ should come from a versioned central library, so every team is gated against the same control set instead of a local copy that quietly drifts. Handle false positives honestly: when a Checkov check genuinely does not apply, suppress that one check with an inline skip carrying a written justification and an expiry date, never with a blanket soft-fail that switches the gate off for everything. Close the loop on the attestation: verify it at deploy or at admission with the exact certificate identity and issuer, so only builds carrying a valid, policy-passing attestation are allowed to run. Do those three, and the pipeline that ships your software is the same pipeline that keeps producing signed, mapped evidence covering the whole period. That is what turns the quarter-end email into a query instead of a week of screenshots.

Sign last, or you are signing a lie
Order and binding carry real weight here. If cosign attest runs independently of the gates, or signs a moving tag like :latest instead of the image digest, you produce a signature that proves nothing. Worse, you produce one that vouches for an artifact which failed Checkov or Conftest. Attest last, after every gate is green. Bind the attestation to the immutable digest. Verify it downstream with the exact --certificate-identity and --certificate-oidc-issuer. An attestation nobody verifies is decoration, and an auditor will treat it as decoration.
Quick check
01Your capstone pipeline runs Checkov, Conftest and cosign, yet every merge request goes green, including the one that adds a public S3 bucket with no encryption. What is the most likely cause?
Incorrect — Ordering does not swallow a failure. A failing Checkov job still returns non-zero and turns the stage red whether it ran beside Conftest or before it.
Correct — The exit code is the whole gate. A wrapper that forces a zero exit, or allow_failure: true letting the job go red without blocking, lets the tool print its findings while the pipeline sails on. The check becomes a no-op that reports but never stops anything.
Incorrect — cosign runs last and only signs evidence. It cannot retroactively pass a stage that already failed, and with correct gating it never even runs when an earlier stage is red.
Incorrect — Checkov scans Terraform, CloudFormation, Kubernetes manifests, Dockerfiles and more, and the run in this lesson finds violations in infra/. Missing coverage is not why a detected violation would still let the pipeline pass.
02Someone edits the attest job so it signs $IMAGE:latest rather than $IMAGE@$(cat digest.txt). The job still succeeds. What have they broken?
Incorrect — They do point at the same image in that moment, which is exactly the trap. A tag can be repointed later, so the signature stops naming the artifact that was checked. The lesson tells you to bind the attestation to the immutable digest for this reason.
Correct — The predicate is meant to be bound to the digest, the content hash that names one specific image, so it cannot be moved onto a different one. Sign a mutable tag and you get a signature that proves nothing, or one that vouches for a build which failed the gates.
Incorrect — The failure mode taught here is the opposite, and nastier: the signing succeeds and the pipeline looks healthy, while the attestation proves nothing about which artifact was actually evaluated.
Incorrect — Nothing in this lesson ties the transparency log entry to tags versus digests. The damage is the broken binding between the evidence and one specific artifact.
03Pipeline #4471 shows Checkov green with 0 failed checks, Conftest printing FAIL - k8s/deployment.yaml - main - container 'api' must set runAsNonRoot (SOC2 CC6.1), and build and attest both skipped. A teammate asks you to unblock the release today. What do you do next?
Incorrect — That is the exact hole this lesson warns about. The job goes red without blocking, so build runs and attest signs a container that violates the control. You would ship a root-running workload with a signature vouching for it.
Correct — The gate did its job and named the exact control and file. Fixing k8s/deployment.yaml turns the compliance stage green, build produces the image, and attest signs evidence that genuinely reflects a passing run.
Incorrect — There is no image to attest, because build was skipped when compliance failed. Signing after a failed gate is signing a lie, which is precisely the ordering this pipeline exists to prevent.
Incorrect — Wrong twice over. Checkov already passed with 0 failed checks, so it is not the blocker, and --soft-fail disables the gate wholesale. When a check genuinely does not apply, the answer in this lesson is an inline skip with a justification and an expiry.

Try this

Work through “Making it hold at scale” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: sign last, or you are signing a lie. 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