Compliance in DevSecOps
Controls built into the pipeline and platform.
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.
$ 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.
stages: [compliance, build, evidence]variables:IMAGE: $CI_REGISTRY_IMAGEcheckov:stage: complianceimage: bridgecrew/checkov:3.2.334script:- checkov -d infra/ --compact --framework terraform -o json > checkov.jsonartifacts:paths: [checkov.json]when: always # keep the evidence even when the job fails# a failed check exits non-zero -> job fails -> pipeline stops hereconftest:stage: complianceimage: openpolicyagent/conftest:v0.56.0script:- conftest test infra/ k8s/ --policy policy/ -o json | tee conftest.jsonartifacts:paths: [conftest.json]when: alwaysbuild:stage: build # only runs if BOTH compliance jobs exited 0image: gcr.io/kaniko-project/executor:v1.23.2-debugscript:- /kaniko/executor --destination "$IMAGE:$CI_COMMIT_SHORT_SHA" --digest-file digest.txtartifacts:paths: [digest.txt]attest:stage: evidence # only runs if a compliant image was builtimage: bitnami/cosign:2.4.1id_tokens:SIGSTORE_ID_TOKEN:aud: sigstore # keyless OIDC identity for THIS pipelinescript:- 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.
$ pip install checkov==3.2.334$ checkov -d infra/ --compact --framework terraform ; echo "exit=$?"
_ ____| |__ ___ ___| | _______ __/ __| '_ \ / _ \/ __| |/ / _ \ \ / /| (__| | | | __/ (__| < (_) \ V /\___|_| |_|\___|\___|_|\_\___/ \_/By Prisma Cloud | version: 3.2.334terraform scan results:Passed checks: 34, Failed checks: 2, Skipped checks: 0Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"FAILED for resource: aws_s3_bucket.dataFile: /infra/s3.tf:1-6Check: CKV_AWS_16: "Ensure all data stored in the RDS is securely encrypted at rest"FAILED for resource: aws_db_instance.appFile: /infra/rds.tf:1-9exit=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.
package mainimport 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 tagdeny contains msg if {bucket := input.resource.aws_s3_bucket[name]not bucket.tags.ownermsg := sprintf("S3 bucket '%s' is missing required tag: owner", [name])}
$ conftest test infra/ --policy policy/ ; echo "exit=$?"FAIL - infra/s3.tf - main - S3 bucket 'data' is missing required tag: owner1 test, 0 passed, 0 warnings, 1 failure, 0 exceptionsexit=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.
$ 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"
Using payload from: evidence/compliance.jsonGenerating ephemeral keys...Retrieving signed certificate...tlog entry created with index: 149204871Verification 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.
Running with gitlab-runner 17.4.0 on shared-runner>> compliance:checkov$ checkov -d infra/ --compact --framework terraform -o json > checkov.jsonPassed checks: 36, Failed checks: 0, Skipped checks: 0Job succeeded # exit 0>> compliance:conftest$ conftest test infra/ k8s/ --policy policy/ -o json | tee conftest.jsonFAIL - k8s/deployment.yaml - main - container 'api' must set runAsNonRoot (SOC2 CC6.1)3 tests, 2 passed, 0 warnings, 1 failure, 0 exceptionsERROR: Job failed: exit code 1 # exit 1 -> stage redbuild -> skipped (stage 'compliance' did not succeed)attest -> skipped (no compliant image to attest)Pipeline #4471 failed
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.
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.$IMAGE:latest rather than $IMAGE@$(cat digest.txt). The job still succeeds. What have they broken?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?--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.