Pipeline Your Way to Safety
There's a version of security that looks like this: a team of security engineers sitting downstream of development, reviewing things after they've been built, filing tickets, and hoping developers get around to them before the next audit. I've seen this version. I've lived parts of it. It doesn't scale, it creates resentment, and it gives you the illusion of security without much of the substance.
Then there's the pipeline version. Your security checks are automated. They run on every commit. They enforce policy consistently without requiring a human to remember. When something fails, the developer gets immediate feedback with context about what went wrong and how to fix it. The security team's job shifts from reviewer to rule-writer and toolsmith.
The pipeline is the most powerful security primitive you have. This post is about using it deliberately.
The Core Mental Model: Pipelines as Policy Enforcement
Forget for a moment that pipelines are primarily for building and deploying software. Think of them differently: a pipeline is a series of gates that every change must pass through before it reaches production. Each gate encodes a policy.
That policy might be: "no builds with critical CVEs in dependencies." It might be: "no infrastructure changes that expose storage buckets publicly." It might be: "no container images that run as root." These are rules. Rules that used to live in documentation nobody read, or in a checklist that a security reviewer applied inconsistently, can instead live as code that runs on every single merge.
This is policy-as-code — the practice of encoding security and compliance requirements as executable rules that are version-controlled, reviewable, and consistently enforced.
Policy-as-Code Frameworks
Open Policy Agent (OPA)
OPA is the most versatile policy-as-code engine in the ecosystem. It uses a declarative language called Rego to express policies, and it can be applied to Kubernetes admission control, Terraform plan evaluation, API authorization, and more.
Here's a simple OPA policy that prevents Kubernetes deployments from using the latest tag:
# policy/no-latest-tag.rego package kubernetes.admission deny[msg] { input.request.kind.kind == "Deployment" container := input.request.object.spec.template.spec.containers[_] endswith(container.image, ":latest") msg := sprintf("Container '%v' uses ':latest' tag. Pin to a specific version.", [container.name]) }
And a policy that requires resource limits:
# policy/require-resource-limits.rego package kubernetes.admission deny[msg] { input.request.kind.kind == "Deployment" container := input.request.object.spec.template.spec.containers[_] not container.resources.limits msg := sprintf("Container '%v' has no resource limits defined.", [container.name]) }
You can evaluate these policies against Kubernetes manifests in your CI pipeline before they ever reach a cluster:
# In your CI pipeline opa eval \ --input k8s-manifest.json \ --data policy/ \ --format pretty \ "data.kubernetes.admission.deny[msg]"
Conftest
Conftest is a tool built on OPA that makes it easier to test configuration files specifically. It works with Terraform, Kubernetes YAML, Dockerfiles, and other structured formats without requiring you to convert everything to JSON first.
# Test your Terraform plan terraform show -json tfplan.binary > tfplan.json conftest test tfplan.json --policy policy/terraform/
Example policy that prevents public S3 buckets:
# policy/terraform/s3.rego package terraform deny[msg] { resource := input.resource_changes[_] resource.type == "aws_s3_bucket_acl" resource.change.after.acl == "public-read" msg := sprintf("S3 bucket '%v' cannot have public-read ACL", [resource.address]) } deny[msg] { resource := input.resource_changes[_] resource.type == "aws_s3_bucket_public_access_block" not resource.change.after.block_public_acls msg := sprintf("S3 bucket '%v' must have block_public_acls enabled", [resource.address]) }
Checkov Custom Policies
If you're already using Checkov for IaC scanning, you can write custom policies in Python or YAML to encode your organization's specific rules beyond the built-in checks:
# custom_policies/require_encryption_at_rest.yaml metadata: name: "Ensure RDS instances have encryption at rest" id: "CKV_CUSTOM_1" category: "ENCRYPTION" severity: "HIGH" definition: and: - cond_type: "attribute" resource_types: - "aws_db_instance" attribute: "storage_encrypted" operator: "equals" value: true
Designing Pipeline Gates
A pipeline gate is a step that can block progression to the next stage. The art is in designing gates that block the right things without being so aggressive that you slow everyone down.
The Gate Design Framework
Every gate should answer three questions before you implement it:
What does this gate block? Be specific. "Blocks critical CVEs" is vague. "Blocks builds where any direct dependency has a CVSS score >= 9.0 with a fix available" is actionable.
What's the false positive rate? If this gate will false-positive on 10% of PRs, developers will route around it within a week. Measure it and tune before enforcing.
What does the developer do when it fires? If the answer is "we don't know," the gate isn't ready. Every blocking gate needs a clear remediation path, documented and linked from the failure output.
Graduated Enforcement
Don't flip from no gates to hard blocking gates overnight. Use a graduated approach:
Phase 1: Scan and report (no blocking) Run all your scanning tools. Publish results to your security dashboard. Understand the baseline. This is the "let there be light" phase — you need to know what you're dealing with before you enforce.
Phase 2: Block on new critical findings only Once you have a baseline, you can block merges that introduce new critical findings above the baseline. Existing technical debt stays for now; you're just preventing it from getting worse.
Phase 3: Reduce the baseline Set a remediation SLA for your existing findings. High severity: 30 days. Critical: 7 days. Track it. As findings are resolved, your baseline shrinks.
Phase 4: Block on all findings above severity threshold Once your baseline is clean, you can enforce across the board.
This phased approach is how you avoid the "security killed our deploy velocity" narrative. You're never surprising anyone, you're never blocking legitimate work that was fine yesterday, and you're clearly communicating the progression.
Breaking the Build vs. Soft Failures
Some findings should break the build. Some should generate a warning. Knowing which is which requires judgment.
Break the build for:
- Secrets/credentials in code or config
- Critical severity vulnerabilities with available fixes in direct dependencies
- Container images running as root in production workloads
- IaC that creates publicly-exposed storage or administrative ports open to 0.0.0.0/0
- Hardcoded IP addresses pointing to internal infrastructure
Warn but don't block for:
- Medium severity vulnerabilities (track them, set SLA)
- Vulnerabilities in transitive dependencies with no available fix
- Informational findings from SAST
- Findings in test code or dev-only dependencies
- Deprecation warnings
Implementing Exception Handling
No policy is perfect and no pipeline should be a prison. You need a mechanism for exceptions — cases where a finding is a false positive, where the risk is accepted, or where a fix isn't available yet.
Most scanning tools support inline suppression comments:
# For Semgrep: password = get_password() # nosemgrep: hardcoded-password # For Bandit (Python): subprocess.call(cmd, shell=True) # nosec B603 # For Checkov in Terraform: resource "aws_s3_bucket" "logs" { # checkov:skip=CKV_AWS_18:Access logging intentionally disabled for this bucket bucket = "my-log-bucket" }
For dependency vulnerabilities, tools like Trivy and Snyk support .trivyignore and .snyk files for suppression:
# .trivyignore # CVE-2023-1234 - False positive, our code doesn't use the affected function CVE-2023-1234 # GHSA-xxxx-yyyy-zzzz - No fix available, accepted risk, tracked in JIRA-5678 GHSA-xxxx-yyyy-zzzz
The discipline here is that suppressions must include a reason and ideally a ticket reference. Suppressions with no context rot over time and become invisible risk. Treat suppression files as security-relevant files that require review in PRs.
Making Pipelines Fast
Slow pipelines get bypassed. If your security scanning adds 15 minutes to every PR, engineers will find ways around it. Speed matters.
Parallelize everything that can be parallelized. SAST, SCA, secret scanning, and IaC scanning can all run simultaneously. There's no reason for them to be sequential.
Fail fast on high-confidence checks. Secret scanning and hardcoded credential checks are fast and high-confidence. Run them first. If they fail, fail immediately rather than waiting for all other checks to complete.
Cache tool installations. Downloading and installing semgrep, trivy, checkov on every pipeline run is wasteful. Cache the binaries. Most CI platforms have caching mechanisms for this.
Scope scans to changed files where possible. On pull requests, some tools can scan only the changed files rather than the entire codebase. This is faster and often produces more signal than a full scan.
# GitHub Actions: scan only changed files with Semgrep - name: Get changed files id: changed-files uses: tj-actions/changed-files@v41 with: files: | **/*.py **/*.js **/*.ts - name: Run Semgrep on changed files if: steps.changed-files.outputs.any_changed == 'true' run: | semgrep --config=auto ${{ steps.changed-files.outputs.all_changed_files }}
Visibility and Metrics
The pipeline is only valuable if you can see what it's doing over time. Track:
- Gate hit rate — how often each gate blocks a merge
- False positive rate — how often blocked merges are suppressed/overridden
- Mean time to remediation — how long findings sit open
- Finding introduction rate — how many new findings per week, trending up or down
These metrics tell you whether your gates are calibrated correctly. A gate that fires on 0.1% of merges might be too lenient. A gate that fires on 30% of merges is probably too noisy. The goal is high signal, low noise.
The Real Win
Here's what a well-designed security pipeline actually gives you: the security team stops being the bottleneck and starts being the rule-writer. Instead of reviewing every deployment, you're writing policies. Instead of chasing individual developers, you're improving the system that catches problems automatically.
Developers get faster feedback than any human review process can provide. Security gets consistent enforcement without manual labor. The pipeline enforces the contract between security requirements and engineering practice.
Takeaway: Pipelines are policy engines. Design them deliberately — graduated enforcement, clear failure messages, fast execution, and an exception mechanism that requires justification. Done right, security pipelines make developers more capable, not less. Done wrong, they're just a new form of security theater that adds noise without reducing risk.