· 7 min read
Shift-Left Compliance: Automating Security Governance in CI/CD
How to turn security controls into tested Policy-as-Code: OPA/Rego v1 on Terraform plans, Conftest in CI, waivers, runtime drift detection, and known limits.
The Problem
In regulated industries such as healthcare, financial services and the public sector, infrastructure changes are often gated by manual security reviews. A developer opens a change, waits days or weeks for a review board, and then rewrites it because a reviewer found a public storage bucket or an SSH port open to the internet. The cost is more than delay:
- Reviews are inconsistent. Two reviewers apply the same written standard differently, and fatigue makes late-Friday reviews weaker than Monday-morning ones.
- Feedback arrives at the most expensive moment. The design has already been built on the violating assumption.
- The process invites bypass. When the sanctioned path is slow, teams create resources by hand or in unmanaged accounts. The result is shadow IT that no review ever sees.
Most findings in these reviews are not judgement calls. They are mechanical checks that a machine can evaluate on every change, in seconds, the same way every time.
The Architectural Solution
Policy-as-Code (PaC) expresses those mechanical controls as versioned, tested code. A policy engine evaluates every proposed change, and violations are reported in the pull request with a fix. Human reviewers are kept for work that needs judgement: threat modelling, new architectural patterns, and approving exceptions.
A mature implementation evaluates at three points, because no single point sees everything:
- Developer workstation (advisory). Pre-commit hooks and IDE plugins give instant feedback. They are easy to skip (
git commit --no-verify), so treat them as a convenience, not a control. - Pull request CI (enforcing). This is the real gate. The pipeline runs
terraform plan, converts it to JSON and evaluates it with Open Policy Agent (OPA) through Conftest. Branch protection makes the check mandatory. Evaluating the plan rather than the source catches values that come from modules, variables and defaults. - Runtime (detective and preventive). Cloud-native controls such as AWS Config rules and Azure Policy catch out-of-band changes. Kubernetes admission control (Gatekeeper, Kyverno or the built-in ValidatingAdmissionPolicy) blocks non-compliant objects at the API server.
Choosing an Engine
| Option | Strengths | Trade-offs |
|---|---|---|
| OPA / Rego + Conftest | General-purpose; same language for Terraform, Kubernetes, CI configs; first-class unit testing | Rego has a learning curve |
| Checkov, Trivy (misconfiguration), KICS | Hundreds of built-in checks mapped to CIS and similar benchmarks; fast to adopt | Custom organisational rules are less expressive |
| HashiCorp Sentinel / OPA in HCP Terraform | Native policy sets inside the Terraform run workflow | Tied to HCP Terraform / Terraform Enterprise |
A common pattern is a built-in scanner for baseline benchmark coverage plus OPA for organisation-specific rules.
Architecture Diagram

Core Implementation
A Policy Worth Enforcing
Older tutorials often show a Rego rule that blocks S3 buckets without server_side_encryption_configuration. That example is now obsolete twice over. Since AWS provider v4, encryption is configured with a separate aws_s3_bucket_server_side_encryption_configuration resource. And since 5 January 2023, S3 encrypts all new objects with SSE-S3 by default. A more useful control is one that prevents real incidents: no administrative ports open to the internet.
The policy below uses OPA 1.x syntax, where if and contains are mandatory. It checks both the standalone aws_vpc_security_group_ingress_rule resource and legacy inline ingress blocks:
package terraform.network
import rego.v1 # no-op on OPA 1.x; lets the same file parse on 0.59+
admin_ports := {22, 3389}
# Resources this plan will create or update
changed contains rc if {
some rc in input.resource_changes
some action in rc.change.actions
action in {"create", "update"}
}
# Standalone rule resource (recommended by current AWS provider docs)
deny contains msg if {
some rc in changed
rc.type == "aws_vpc_security_group_ingress_rule"
rule := rc.change.after
rule.cidr_ipv4 == "0.0.0.0/0"
some port in admin_ports
exposes(rule, port)
msg := sprintf(
"[NET-01] %s exposes port %d to 0.0.0.0/0. Use a VPN/bastion CIDR or SSM Session Manager instead.",
[rc.address, port],
)
}
# Legacy inline ingress blocks on aws_security_group
deny contains msg if {
some rc in changed
rc.type == "aws_security_group"
some rule in rc.change.after.ingress
"0.0.0.0/0" in rule.cidr_blocks
some port in admin_ports
exposes(rule, port)
msg := sprintf("[NET-01] %s has an inline rule exposing port %d to 0.0.0.0/0.", [rc.address, port])
}
exposes(rule, port) if {
rule.from_port <= port
port <= rule.to_port
}
exposes(rule, _) if rule.ip_protocol == "-1" # all traffic (standalone rule)
exposes(rule, _) if rule.protocol == "-1" # all traffic (inline rule)
Each message carries a control ID (NET-01). The ID maps to your internal control catalogue, and from there to external frameworks. This mapping is what turns CI logs into audit evidence.
Policies Need Tests
Policy that is not tested will eventually block a legitimate change or let a violation through. OPA has a built-in test runner (opa test policy/ -v):
package terraform.network_test
import data.terraform.network
import rego.v1
rule_change(cidr) := {"resource_changes": [{
"address": "aws_vpc_security_group_ingress_rule.ssh",
"type": "aws_vpc_security_group_ingress_rule",
"change": {
"actions": ["create"],
"after": {"cidr_ipv4": cidr, "ip_protocol": "tcp", "from_port": 22, "to_port": 22},
},
}]}
test_ssh_open_to_internet_is_denied if {
count(network.deny) == 1 with input as rule_change("0.0.0.0/0")
}
test_ssh_from_vpn_range_is_allowed if {
count(network.deny) == 0 with input as rule_change("10.20.0.0/16")
}
In the pipeline, the enforcement step is three commands:
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
conftest test tfplan.json --policy policy/ --namespace terraform.network
Conftest evaluates deny, violation and warn rules and exits non-zero on any deny. Use warn for a two-week grace period when you introduce a new rule, then promote it to deny.
Edge Cases & Limitations
-
Static analysis cannot see runtime context. A plan shows configuration, not behaviour. It cannot tell you whether an allowed CIDR now belongs to someone else, or whether a role’s permissions are excessive in practice. Values that are unknown until apply (computed attributes) also do not appear in
change.after. Write policies that fail closed on the attributes that matter, or check them again at runtime. -
False positives destroy trust quickly. A few unjustified blocks in the first week are enough to convince teams the system is an obstacle. Introduce rules in
warnmode and measure the false-positive rate. Publish every policy’s rationale next to its code. -
You need a waiver process. Some violations are legitimate, such as a public bucket for a static website. Model exceptions as data in the policy repository: resource address, rule ID, justification, approver and an expiry date. Reviewing exceptions then becomes a pull request, and expired waivers fail the build automatically. A waiver without an expiry becomes permanent.
-
Changes outside the pipeline are invisible. Pull-request checks only govern changes that go through the pipeline. Console changes need a runtime layer, such as AWS Config rules with automatic remediation or Azure Policy, ideally with the same control IDs, plus GitOps reconciliation for Kubernetes workloads.
-
Compliance frameworks are not rule lists. SOC 2 reports are attestations against the AICPA Trust Services Criteria (security, availability, processing integrity, confidentiality and privacy), and they do not prescribe specific technical settings. PCI DSS is more prescriptive but still needs interpretation. Policy-as-Code automates your controls and produces evidence. It does not make you compliant by itself, and auditors will still examine the process around it.
Key Strategic Takeaways
- Gate on the plan, in CI, with branch protection. Local hooks are for convenience. The enforcing control is a required pipeline check against
terraform show -jsonoutput. - Treat policy like product code. Use OPA 1.x syntax, unit tests, code review and semantic versioning of the policy bundle.
- Make every failure actionable. Each message should name the resource, the control ID and the fix.
- Design exceptions explicitly. Time-boxed, reviewed waivers keep the system credible without encouraging people to bypass it.
- Close the loop at runtime. Pair pre-merge checks with cloud configuration rules and admission control, as described in Zero-Trust Kubernetes, so that changes outside the pipeline are caught too.
Recommended Industry Reading & References
- Open Policy Agent: Terraform Policy Guide - Official walkthrough of evaluating Terraform plan JSON with Rego.
- Open Policy Agent: Upgrading to OPA 1.0 (Rego v1) - The
if/containssyntax changes and migration guidance. - Open Policy Agent: Policy Testing - Unit-testing Rego with
opa test. - Conftest: Documentation - Running Rego policies against structured configuration in CI.
- HashiCorp: Policy Enforcement in HCP Terraform - Sentinel and OPA policy sets in the Terraform run workflow.
- NIST: SP 800-218, Secure Software Development Framework - Federal guidance on integrating security practices into the SDLC.
- OWASP: DevSecOps Guideline - Community guidance on embedding security checks in delivery pipelines.