Make Compliance Boring: 28% Fewer Audit Findings

compliance

Make Compliance Boring: 28% Fewer Audit Findings
Ship faster while auditors nod approvingly, not suspiciously.

Why Compliance Trips Fast Teams (And What We Change)
Most teams don’t dislike compliance; they dislike being surprised by it. We’ve seen the same movie in fast-moving orgs: controls live in a static PDF, developers learn about them during a release freeze, and audit evidence gets assembled like a last‑minute science fair project. The outcome is predictable—stress, exceptions, and a pile of findings that could’ve been prevented. The real culprit isn’t the people; it’s the mismatch between how software changes (daily, in small chunks) and how traditional compliance wants to see proof (periodic, in big bundles). So, we flip the model. Instead of treating controls as external rules, we treat them as part of the build. Instead of “write a policy,” we “write a test.” Instead of “collect evidence,” we “archive pipeline outputs.” When we push controls as code and collect evidence continuously, audits become reviews of history rather than forensics of memory. That’s where the boring magic happens: small, frequent proofs that don’t slow anyone down. We’ll still negotiate exceptions and deal with weird corner cases, but the baseline gets predictable. And yes, we can keep our sense of humor intact—because nothing is funnier than a policy that enforces itself at 2 a.m. so we don’t have to. If we’re aiming for 28% fewer audit findings, this is the lever we pull: make compliance the default path, not a heroic act.

Turn Policy Into Code: From PDFs to Pipelines
“Policy” sounds like something framed on a wall; we want something that breaks a build. That’s why we encode controls as tests that run everywhere code runs—local, CI, and pre‑prod. Our go-to tools are policy engines like Open Policy Agent (OPA), which slot neatly into CI and Kubernetes admission control. The benefit isn’t just enforcement; it’s explainability. A policy that shows why it failed and how to fix it saves Slack threads and tempers. Here’s a tiny but effective example: we only allow container images from trusted registries and with immutable digests. That kills “latest” tags in production and squashes a surprising number of drift bugs.

Rego snippet using conftest or Gatekeeper libraries:

package devopsoasis.image_policy

default allow = false

trusted_registries = {"ghcr.io/our-org", "registry.company.internal"}

allow {
  startswith(input.image, r)
  endswith(input.image, "@sha256:")
  r := trusted_registries[_]
}

We pass each deployment manifest’s image to this policy; failure blocks the pipeline with a human-friendly message. This isn’t theoretical—OPA is production-grade and actively maintained; we lean on the Open Policy Agent docs for patterns and performance tips. The shift is subtle but powerful: we stop “reminding” people about rules and let the pipeline nudge them. Our time goes into good policies and clean messages, not arguing about whether “latest” is truly that bad. (It is. It’s haunted.)

Version Everything: Controls, Exceptions, and Evidence
If it didn’t happen in Git, it didn’t happen. We version the controls themselves, naturally, but also the exceptions, risk notes, and even the evidence collection scripts. That way every audit conversation has dates, diffs, and commit authors instead of lore. We keep a simple repository layout that’s easy to skim and grep:

  • controls/ holds one folder per control with description, rationale, test, and expected outputs.
  • exceptions/ holds time-bound, approval-tracked markdown with links to tickets and mitigation notes.
  • evidence/ is not the raw evidence (that lives in object storage); it’s an index of pointers, hashes, and retrieval scripts.
  • runbooks/ describe how to reproduce evidence and how to answer common audit asks without panic.

We tag releases of the control catalog the same way we tag app versions. If a control changes (say we tighten image policies), the tag marks when enforcement flipped. Paired with PR templates (“Which framework mappings? What’s the blast radius? How do we roll back?”), we get consistent reviews and fewer “oops” moments. Exceptions get expirations and owners, so they don’t become the hotel California. For evidence, we prefer append-only buckets and checksums over shared docs. A short evidence/get.sh script can pull the right attestation from storage by commit SHA. The goal isn’t ceremony; it’s reproducibility. If a new auditor arrives mid‑cycle, we hand them a repo and a bucket, not a scavenger hunt.

Build a Minimal Control Catalog That Actually Maps
We don’t need 500 controls. We need a lean set that maps cleanly to the frameworks we care about, backed by tests and automatic evidence. We start from a platform baseline (access, network, build, deploy, runtime) and layer framework mappings on top—SOC 2, ISO 27001, PCI DSS, take your pick. You’ll find that the same five to seven technical controls do a lot of heavy lifting: identity and access boundaries, secure build provenance, artifact integrity, hardened runtime configs, encrypted data, and logging with retention. We keep the noise down by writing each control in simple language, then mapping it to specific frameworks with references. For instance, a single “no privileged pods” control hits both CIS Kubernetes and several NIST families. We include links to the source materials and cite them in our control files, like NIST SP 800-53 and the CIS Benchmarks. The catalog then becomes test cases, not prose. Each control answers: how to check, where it runs (CI, cluster, cloud), how we collect evidence, and what a false positive looks like. We leave room for risk acceptance with expiration dates; sometimes business reality needs an exception, but it shouldn’t be a secret. The net result is a catalog that’s small, loud, and tied to code. When an auditor asks, “Which control covers this?” we can point to a file, a test, and last night’s run logs.

Automate Evidence Collection in CI/CD
Evidence shouldn’t be a screenshot pretending to be a control. We wire it into the pipeline: SBOMs attached to builds, vulnerability scans as SARIF, policy test outputs, and deployment attestations. The trick is to store machine-readable results with the exact commit and environment, then make them discoverable. Here’s a trimmed GitHub Actions example that builds an artifact, generates an SBOM, runs a scan, emits SLSA provenance, and pushes evidence to an artifact bucket with content-addressed paths:

name: build-and-attest
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build image
        run: docker build -t ghcr.io/our-org/app:${{ github.sha }} .
      - name: Generate SBOM
        run: syft ghcr.io/our-org/app:${{ github.sha }} -o spdx-json > sbom.json
      - name: Vulnerability scan
        run: trivy image --format sarif -o trivy.sarif ghcr.io/our-org/app:${{ github.sha }}
      - name: SLSA provenance
        uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1
      - name: Upload evidence
        run: ./scripts/push-evidence.sh ${{ github.sha }} sbom.json trivy.sarif provenance.json

We like SLSA for provenance because it’s simple enough for everyday pipelines yet strict enough to hold up in audits. The storage layer uses a flat structure keyed by commit SHA and environment, so “show me production deploy evidence for commit X” becomes one command. We also reserve a spot in the artifact for policy version hashes; it’s handy when you upgrade a control and need to prove which version flagged (or didn’t flag) a build last month.

Prove Runtime Compliance Without Freezing Production
Build-time checks are great, but reality lives in runtime. We need admission controls, runtime scanning, and config drift detection that enforce rules without turning every deploy into molasses. Kubernetes admission control is our favorite choke point because it’s fast and declarative. Gatekeeper (OPA-based) or Kyverno let us write constraints that keep dangerous workloads out while generating clean audit events. Think “no privileged pods,” “require network policies,” “enforce resource limits,” and “only pull from trusted registries.” Here’s a Gatekeeper example that blocks privileged containers:

apiVersion: templates.gatekeeper.sh/v1beta1
kind: ConstraintTemplate
metadata:
  name: k8sprivilegedcontainer
spec:
  crd:
    spec:
      names:
        kind: K8sPrivilegedContainer
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8sprivileged
        violation[{"msg": msg}] {
          input.review.kind.kind == "Pod"
          c := input.review.object.spec.containers[_]
          c.securityContext.privileged == true
          msg := sprintf("Privileged container not allowed: %v", [c.name])
        }
---
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sPrivilegedContainer
metadata:
  name: disallow-privileged
spec: {}

We run most constraints in “dry-run” mode first, then flip to “warn,” then “deny.” That gives developers a fair shot to fix before we bring the hammer. Logs from the admission controller pipe to our SIEM with control IDs so audit evidence ties back to policy. We keep an eye on performance and cache sizes; admission webhooks can be a footgun if we don’t tune them. The Gatekeeper docs have solid guidance on scaling and timeout behavior. The outcome is nimble defenses that don’t require a change advisory board to approve a YAML tweak.

Make Risk Visible: Metrics, Drift, and Control SLOs
Compliance is easier when risk is visible. We treat each control like a service with an SLO. For example, “95% of production workloads have resource limits,” “100% of production images have SBOMs,” “99% of deployments include SLSA provenance,” and “<= 2% drift from baseline CIS checks over 7 days.” When a control slips, we don’t wait for audit season—we page ourselves (gently). Drift detection runs on a schedule and on change events; if a cloud config flips a public flag, a ticket appears with the exact delta and someone’s name on it. The point isn’t shaming; it’s shortening time-to-fix. We expose a small dashboard, not a Las Vegas slot machine: green when controls pass, amber when approaching thresholds, red when it’s time to stop the world (rare, but it happens). And yes, we count exception burn-down—how many are expiring this month, how many renewed, and whether a control is failing because reality changed or because we were too strict. If a metric becomes noisy, we rethink the control, not the dashboard. The side effect is delightful: product managers can see compliance health without reading a policy, and leadership can trade off risk and speed with facts rather than vibes. That’s a nicer steering wheel than a quarterly audit report.

Audit Day Without Panic: Playbooks, Packets, and Dry Runs
We don’t cram for audits; we rehearse them. Every few sprints we run a “tabletop audit” dry run: pick three controls, pull the last 30 days of evidence using only our runbooks and scripts, and see how long it takes. The clock is merciless and honest. We fix gaps: missing mappings, unclear messages, broken links to evidence, or reviewers who weren’t looped in. We also keep an “audit packet” generator that bundles: control definitions, mappings, last-run outputs, exception summaries, and the list of who can explain what. It’s boring, and that’s the point. When real auditors arrive, we hand them a menu instead of a buffet. We set expectations early: we prefer read‑only access to evidence buckets and logs, time-boxed interviews, and a shared list of open questions. Disagreements happen—so we capture them as issues in the control repo, add context, and resolve with changes or risk acceptance. The magic phrase is “reproducible in under five minutes.” If we can rerun a check and get the same output with the same inputs, we sleep better. For recurring frameworks (SOC 2, ISO), we leave breadcrumbs specific to last year’s asks and this year’s updates. That consistency turns repeat audits from oral exams into simple diff reviews. And yes, someone brings pastries. It helps morale and pairs well with boring compliance.

Share