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:

  1. 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.
  2. 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.
  3. 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

Shift-left compliance pipeline: a code commit triggers static analysis and an infrastructure-as-code scan, a policy-as-code engine evaluates the plan, failures block the merge and passing changes proceed to deployment

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

  1. 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.

  2. 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 warn mode and measure the false-positive rate. Publish every policy’s rationale next to its code.

  3. 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.

  4. 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.

  5. 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 -json output.
  • 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.

  1. Open Policy Agent: Terraform Policy Guide - Official walkthrough of evaluating Terraform plan JSON with Rego.
  2. Open Policy Agent: Upgrading to OPA 1.0 (Rego v1) - The if / contains syntax changes and migration guidance.
  3. Open Policy Agent: Policy Testing - Unit-testing Rego with opa test.
  4. Conftest: Documentation - Running Rego policies against structured configuration in CI.
  5. HashiCorp: Policy Enforcement in HCP Terraform - Sentinel and OPA policy sets in the Terraform run workflow.
  6. NIST: SP 800-218, Secure Software Development Framework - Federal guidance on integrating security practices into the SDLC.
  7. OWASP: DevSecOps Guideline - Community guidance on embedding security checks in delivery pipelines.

Disclaimer & Licensing: The architectural patterns, code snippets, and strategies discussed in this publication represent personal research and theoretical paradigms. They are provided "as is" without warranty of any kind. Readers must independently verify and rigorously test any implementation within their specific environments. The author assumes no liability for operational disruptions resulting from the application of these concepts.

This work is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.