Ship Policy, Not PDFs: Secure Coding Standards That Compile in CI
If your secure coding standard lives in Confluence, it’s not a control—it’s a suggestion. Turn intent into guardrails, checks, and automated proofs that ship with every PR.
Policies that don’t compile into CI checks are just suggestions—and suggestions don’t survive Friday deploys.Back to all posts
The day the policy PDF met main
I walked into a SOC 2 readiness review where the team had a 32-page “Secure Coding Standard” in Confluence and a production outage caused by a hardcoded token merged the night before. Classic. The PDF said “no secrets in code”; the repo said AWS_SECRET_ACCESS_KEY=... in a test helper. No pre-commit hook, no PR check, no gate. Auditors were unimpressed. Developers were annoyed.
I’ve seen this movie at fintechs, healthtechs, and old-school enterprises: policy as prose, not as code. What works is simple and boring: translate intent into guardrails that run where developers live, prove it automatically, and keep lead time intact.
Translate intent into guardrails developers can’t ignore
Policies are just hypotheses until they’re tests. Start with a handful that matter and map them to controls:
- No plaintext secrets in code (SOC 2 CC6.6, HIPAA 164.312(a))
- Pre-commit:
detect-secretsorgitleaks - CI: repo-wide scan on every PR; allow baselines with TTL and ticket
- Pre-commit:
- Regulated data never on public endpoints (GDPR, HIPAA)
- IaC policy-as-code: OPA/Rego via
conftestor Gatekeeper - Disallow public S3 buckets or public LoadBalancers for
data-classification: regulated
- IaC policy-as-code: OPA/Rego via
- Dependencies patched within 14 days for critical vulns (PCI DSS 6.3.3)
- SBOM + vulnerability scan (
syft+grype); CI fails if anycriticalolder than 14 days - Renovate for fast, low-friction updates
- SBOM + vulnerability scan (
- Network egress controls for services touching PII
- Kubernetes policies (Gatekeeper/ Kyverno) and egress proxies; fail manifests that bypass
Write policies in the tools devs already use. Keep the rule set small, crisp, and noisy-once.
Wire it into the developer flow (not a security sidecar)
If it isn’t in the PR, it doesn’t exist. Minimal friction path that works:
- Pre-commit for cheap, noisy findings (secrets, obvious code smells)
- PR checks for SAST, IaC, and dependency risk with SARIF results no one has to “go find”
- Branch protection requires green checks and review from
CODEOWNERSofpolicy/ - CD gates only on high-signal proofs (signed artifact + attestation)
- Exceptions in-repo with TTL, reviewers, and auto-expiry
Example .pre-commit-config.yaml:
repos:
- repo: https://github.com/Yelp/detect-secrets
rev: v1.4.0
hooks:
- id: detect-secrets
args: ["--baseline", ".secrets.baseline"]
- repo: https://github.com/returntocorp/semgrep
rev: v1.78.0
hooks:
- id: semgrep
args: ["--config", ".semgrep.yml", "--error"]Protect the critical bits with CODEOWNERS:
# Require security to review policy and infra changes
policy/** @security-team
infrastructure/** @platform-team @security-teamPolicy-as-code you can paste today
Semgrep rule to catch Python HTTP calls without timeouts (a real outage vector):
# .semgrep.yml
rules:
- id: py-requests-no-timeout
patterns:
- pattern: requests.$METHOD($ARGS)
- pattern-not: requests.$METHOD(..., timeout=$TIMEOUT, ...)
message: "requests.* call without timeout"
severity: WARNING
languages: [python]
metadata:
cwe: CWE-400Rego policy to stop public S3 buckets when tagged regulated=true:
# policy/s3_public.rego
package s3
deny[msg] {
input.resource_type == "aws_s3_bucket"
input.tags.regulated == "true"
public := input.acl == "public-read" or input.acl == "public-read-write"
public
msg := sprintf("regulated bucket %s cannot be public (acl=%s)", [input.name, input.acl])
}Run with Conftest in CI over Terraform plans:
terraform init && terraform plan -out=tf.plan
terraform show -json tf.plan | conftest test --policy policy/ -GitHub Actions: SAST, IaC, SARIF upload, and gate on failures:
name: security
on: [pull_request]
jobs:
scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.11' }
- name: Semgrep SAST
uses: returntocorp/semgrep-action@v1
with:
config: .semgrep.yml
generateSarif: true
publishSarif: true
- name: Terraform plan
run: |
terraform -chdir=infrastructure init
terraform -chdir=infrastructure plan -out=tf.plan
terraform -chdir=infrastructure show -json tf.plan > plan.json
- name: Conftest OPA
uses: instrumenta/conftest-action@v1
with:
files: infrastructure/plan.json
policy: policy/Automated proofs: attestations, SBOM, and “show me” for auditors
Auditors don’t want your feelings; they want evidence tied to a commit. Produce machine-verifiable proofs:
- SBOM per image with
syft(CycloneDX or SPDX), signed and attached to the artifact - Vulnerability scan with
grype, fail on policy (e.g., critical older than 14 days) - Build provenance (SLSA-style) and policy attestation with Sigstore
cosign - SARIF results uploaded to code scanning for traceability
Action snippet to build, SBOM, scan, and attest:
jobs:
build_and_prove:
runs-on: ubuntu-latest
permissions:
id-token: write # for keyless signing
packages: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Build image
run: |
docker build -t ghcr.io/acme/payments:${{ github.sha }} .
echo "IMAGE=ghcr.io/acme/payments:${{ github.sha }}" >> $GITHUB_ENV
- name: Generate SBOM (CycloneDX)
uses: anchore/sbom-action@v0
with:
image: ${{ env.IMAGE }}
format: cyclonedx-json
artifact-name: sbom.cdx.json
- name: Scan vulnerabilities
uses: anchore/scan-action@v3
with:
image: ${{ env.IMAGE }}
fail-build: true
severity-cutoff: critical
- name: Sign image and attest policy
uses: sigstore/cosign-installer@v3
- name: Cosign sign
run: cosign sign --yes $IMAGE
- name: Cosign attest (policy)
run: |
echo '{"semgrep": "pass", "opa": "pass", "sbom": "attached"}' > policy.json
cosign attest --yes --predicate policy.json --type slsaprovenance $IMAGENow your artifact carries cryptographic proofs that controls ran and passed. ArgoCD or your deployment system can verify signatures at pull-time. If you want belt-and-suspenders, store attestations in an evidence bucket keyed by commit SHA and release tag.
Keep delivery fast: tiered risk and progressive enforcement
This is where most teams faceplant. They ship 100 rules overnight and wonder why devs revolt. Here’s what works:
- Tier services by data sensitivity: regulated (PII/PHI/PCI), internal, public. Only hard-fail regulated initially.
- Progressive rollout: two weeks audit-only (comment), two weeks soft-fail (non-blocking), then hard-fail for regulated tier.
- Policy canaries: enable new rules on 10% of repos first, measure false positives, then ramp.
- Exception workflow: YAML in-repo with
reason,ticket, andexpiresthat CI enforces.
Example exception file and check:
# .policy-exceptions.yaml
exceptions:
- id: py-requests-no-timeout
repo: payments-api
reason: 3rd-party SDK lacks timeout param
ticket: SEC-1842
expires: 2025-03-31Then in CI, a tiny script filters Semgrep findings if an unexpired exception exists. No emails. No spreadsheets. Everything is code-reviewed and time-boxed.
Track the right metrics:
- PR cycle time (P50/P90) before/after rollout
- Failed check rate per repo (should drop after week 2)
- Time-to-patch critical vulns (target < 14 days)
- Mean age of exceptions (target < 14 days)
What good looks like in 90 days
I’ve run this playbook at a HIPAA SaaS and a PCI fintech. The curve is consistent:
- Days 0–30: 5 rules in audit mode (secrets, SAST hotspots, IaC egress, SBOM+scan, license allowlist). Baseline created, noisy repos identified. PR cycle time unchanged.
- Days 31–60: Soft-fail then hard-fail for regulated tier. Secrets in PRs drop to near-zero. Critical vuln backlog drops 60% as Renovate merges safely.
- Days 61–90: Attestations verified at deploy; auditor evidence is a URL and a SHA. One-click export of SBOM, SARIF, and policy results. Exceptions < 10, mean age under two weeks. No measurable impact to lead time for non-regulated tier.
If a control can’t be proven automatically, it doesn’t exist during an audit and it probably won’t survive a Friday deploy.
If you want a partner that’s done this under SOC 2, HIPAA, PCI, and GDPR pressure, GitPlumbers builds and hands off the whole system: policies, CI, attestations, and dashboards. We’ll even clean up the AI-generated “vibe code” your team merged at 2 a.m. to make the checks pass for the right reasons, not with duct tape.
Key takeaways
- Policies don’t work until they compile into checks that run in the developer path: pre-commit, PR, CI, and CD.
- Start with 5-7 high-signal rules (secrets, dependency risk, data egress) and enforce progressively: warn → soft fail → hard fail.
- Produce automated proofs per artifact: signed SBOM, vulnerability scan, and policy attestation tied to a release SHA.
- Balance speed with risk: tier services by data sensitivity and only hard-fail the regulated path at first.
- Make exceptions first-class: track them in code, time-box them, and auto-expire in CI.
- Measure what matters: PR cycle time, failed check rate, mean exception age, and time-to-patch on critical vulns.
Implementation checklist
- Codify 5-7 top policies in Semgrep, OPA/Rego, and dependency scanners.
- Install `pre-commit` with secrets and lightweight code rules.
- Require PR checks: Semgrep (SAST), SBOM + vuln scan, Conftest/OPA for IaC, license compliance.
- Generate and sign SBOM with `syft` and attach to artifacts; scan with `grype`.
- Attest policy compliance with `cosign attest` and store in your registry.
- Gate merges with branch protection and CODEOWNERS for `policy/` and `infrastructure/`.
- Roll out in audit mode for two weeks, then soft-fail, then hard-fail by service tier.
- Create an exception workflow in-repo with TTL and ticket links.
Questions we hear from teams
- How do we avoid tanking developer velocity when we turn on enforcement?
- Use tiered risk and progressive enforcement. Start in audit mode for two weeks, then soft-fail, then hard-fail only for regulated services. Track PR cycle time and failed check rates; tune rules before expanding scope.
- We’re on GitLab/Jenkins, not GitHub Actions. Does this still work?
- Yes. Semgrep, Conftest, Syft/Grype, and Cosign are CI-agnostic. Replace Actions with GitLab CI YAML or Jenkinsfiles; the pattern—checks on PRs, signed SBOMs, and attestations—stays the same.
- What about Kubernetes runtime policies?
- Enforce at deploy with OPA Gatekeeper or Kyverno for cluster policies (egress, allowed annotations) and verify in CI that manifests will pass. Defense-in-depth: static fail early, dynamic enforce always.
- How do we prove compliance to auditors without manual screenshots?
- Publish SBOMs, vulnerability scan results, and policy attestations per release SHA. Store them in your registry and an evidence bucket. Provide a report mapping control IDs (SOC 2, HIPAA, PCI) to these artifacts. Auditors love immutable, signed evidence.
- We have a lot of AI-generated code creeping in. Any special handling?
- Add Semgrep rules and pre-commit patterns tuned for ‘vibe code’ issues: insecure defaults, missing timeouts, eval/exec usage, and unsafe deserialization. Gate on these in PRs. GitPlumbers can run an AI code rescue to clean up and align with your standards.
- What’s the first rule you’d ship if you could only pick one?
- Secrets prevention with pre-commit + PR blocking. It’s high-signal, universally applicable, and pays off immediately in risk reduction and audit hygiene.
Ready to modernize your codebase?
Let GitPlumbers help you transform AI-generated chaos into clean, scalable applications.
