SevenBelow
GRC Engineering

A GRC Engineering Pipeline, Built in Public

From "it works" to "a stranger can verify it" — six weeks, one system, every claim runnable

ByDavid Kramer, RHCA, CISSP· SevenBelow LLC
PublishedJuly 2026
Version1.0
case study

The one-sentence version

I built a pipeline that takes an AWS resource from "it works" to "a stranger can verify it satisfies NIST 800-53 without talking to me" — and every claim in this article is checkable by running a command.

git clone https://github.com/sevenbelowllc/grc-engineering-club
cd grc-engineering-club && ./verify-pipeline.sh

Latest run: 14 checks, 14 passed, 0 skipped (transcript). Run it yourself and the script verifies whatever your toolchain allows and names every check it cannot run. The ceiling for anyone but me is 12 passed, 1 skipped: the two vault checks read a private Object Lock bucket, and an unreadable vault is reported as one skipped check — the verdict says INCOMPLETE, because a check that did not run is not a check that passed. The Debian transcript shows exactly that run.

Source: github.com/sevenbelowllc/grc-engineering-club


Why this exists

Most compliance evidence is a screenshot in a shared drive. It proves nothing in particular: not that the setting was real, not that it stayed real, not that nobody edited the PNG. The whole apparatus rests on an auditor's willingness to believe the person who produced it, which is why audit season feels like a trust exercise rather than an engineering one.

The alternative is not "better screenshots." It is treating control state as an artifact — something built by a pipeline, signed by a machine identity, recorded in a public log, stored where it cannot be deleted, and published in a format an assessor's tooling can read. That is a supply-chain problem, and the supply-chain tooling already exists.

This is a working, end-to-end demonstration of that idea, built over six weeks in a public repository.


The pipeline

flowchart TD
    subgraph BUILD["build"]
        W1["<b>1 · Provision</b><br/>Terraform baseline<br/><i>encryption, public access block,<br/>versioning, required tags</i>"]
        W2["<b>2 · Express</b><br/>Rego policies<br/><i>the controls, executable</i>"]
    end

    subgraph GATE["enforce"]
        W3["<b>3 · Enforce</b><br/>CI gate<br/><i>conftest on every PR,<br/>keyless OIDC, no stored keys</i>"]
    end

    subgraph PROVE["prove"]
        W4["<b>4 · Prove</b><br/>Signed evidence<br/><i>sha256 + cosign + Rekor</i>"]
        W5["<b>5 · Observe</b><br/>Runtime monitoring<br/><i>CloudTrail, Security Hub,<br/>cross-region replication</i>"]
    end

    subgraph SPEAK["publish"]
        W6["<b>6 · Publish</b><br/>OSCAL control mapping<br/><i>profile, component,<br/>assessment results</i>"]
        VAULT[("<b>WORM vault</b><br/>S3 Object Lock<br/>COMPLIANCE mode")]
    end

    W1 -->|"terraform plan -json"| W2
    W2 -->|"deny rules"| W3
    W3 -->|"verdicts + plan"| W4
    W4 -->|"signed bundle"| VAULT
    W1 -.->|"applied account"| W5
    W5 -->|"captured trail status"| W4
    W4 -->|"evidence href"| W6
    W3 -->|"conftest verdicts"| W6
    VAULT -.->|"preservation leg"| W6

    ASSESSOR(["an assessor<br/>you never meet"]) -.->|"reads and follows links"| W6

    classDef wk fill:#0f172a,stroke:#58a6ff,color:#ffffff
    classDef store fill:#1e1b4b,stroke:#d29922,color:#ffffff
    classDef who fill:#064e3b,stroke:#3fb950,color:#ffffff
    class W1,W2,W3,W4,W5,W6 wk
    class VAULT store
    class ASSESSOR who
StageWhat it doesThe thing that makes it real
1 · ProvisionTerraform baseline satisfying SC-28, AC-3, CM-6Provider default_tags makes the tagging control impossible to forget on a new resource
2 · ExpressRego policies that read a Terraform planRules match by reference, not by value — the bucket's name doesn't exist yet
3 · EnforceGitHub Actions gate, required status checkA violation never reaches the account, because the merge is blocked
4 · ProveSHA-256 + keyless cosign + Rekor + S3 Object LockFour separate properties, four separate mechanisms
5 · ObserveCloudTrail, Security Hub, cross-region replicationThe running account, which is a different claim from the plan's intent
6 · PublishOSCAL profile, component, assessment plan and resultsAn assessor follows links instead of scheduling a call

The whole thing runs on keyless GitHub OIDC. There are no stored AWS credentials anywhere in the repository or its secrets.


Proof

Evidence over adjectives. Every row links to something that ran.

ClaimProof
The policies are unit-testedopa test policies/ -vPASS: 6/6
A compliant change mergesPR #3 — green run, all three namespaces pass
A non-compliant change cannot mergePR #7 — SC-28 denies both buckets, exit 1, merge blocked
Evidence survives the failureThe red run still signed and uploaded its bundle — if: always() in grc-gate.yml
The evidence is intact, authentic and timelyverify-evidence.shCHAIN INTACT, signed by the workflow in PR #9
Tampering is detectedThe same script on a modified bundle → FAIL
The evidence cannot be deletedHard delete with admin credentials → "Access Denied because object protected by object lock"
The OSCAL is schema-validtrestle validate -a5 documents VALID
An assessor can traverse it./traverse.sh4/4 controls walked from profile to verified evidence
The converter fails when it shouldThree guard cases, exit 3, nothing written
The verifier itself is gatedverify-pipeline is a required status check on mainrun 30605526989 passed the fourteen-check verifier with zero skips, against live AWS

The row that matters most, seen rather than linked — PR #7, the intentional SC-28 break, as a reviewer sees it:

PR #7: checks failing on the intentional control break

The gate's comment: SC-28 denies both buckets, with remediation — "with branch protection on, this merge is blocked until it is fixed"

The merge box: the required grc-gate check failing, and the merge blocked


Three decisions worth stealing

1. Match by reference, not by value

The obvious way to write "every bucket must be encrypted" is to compare the encryption resource's bucket field to the bucket's name. It does not work.

At plan time the bucket name is grc-challenge-dev-data-${random_id.suffix.hex} — a value that does not exist yet. A name-matching rule cannot evaluate the plan at all, so it silently passes, which is the worst available outcome: a green gate that has checked nothing.

The plan does contain something stable. Under configuration, every resource records the symbolic addresses it references:

# The encryption resource records "aws_s3_bucket.primary.id" — an address, not a
# name. That exists at plan time. The name does not.
encryption_references(bucket_addr) if {
    some r in config_resources
    r.type == "aws_s3_bucket_server_side_encryption_configuration"
    some ref in r.expressions.bucket.references
    references_bucket(ref, bucket_addr)
}

deny contains msg if {
    some bucket in config_resources
    bucket.type == "aws_s3_bucket"
    not encryption_references(sprintf("aws_s3_bucket.%s", [bucket.name]))
    msg := sprintf("SC-28: aws_s3_bucket '%s' has no matching server-side encryption configuration...", [bucket.name])
}

Matching on the address works before apply, works at any module depth, and works regardless of what the resource ends up being called.

The general lesson: policy-as-code that runs before apply has to reason about intent, not state. Most examples on the internet quietly assume otherwise, and quietly pass.

2. The control mapping lives in the OSCAL, not in the converter

Week six's centrepiece is a converter that turns

{ "namespace": "compliance.sc28_aws", "successes": 1 }

into

{ "target-id": "sc-28_obj", "status": { "state": "satisfied" } }

— the shape an assessor's tooling consumes. Somebody has to carry the verdict across that gap. If that somebody is a person, they do it again every time the gate runs, and the moment they stop, the mapping goes stale without anyone noticing.

The tempting design is a dictionary in the converter mapping package names to control IDs. I deliberately didn't write one. That mapping lives in the OSCAL component definition, as a policy-package prop on each implemented-requirement, because that is the document whose job is to say how a control is implemented. The converter reads it back out at conversion time:

# The entire mapping logic.
packages = [p["value"] for p in req.get("props", [])
            if p.get("name") == "policy-package"]

Consequences: add a control to the component definition and its verdicts start converting, with no change to the converter. Rename a Rego package without updating the component and the build fails rather than silently dropping a control. And the converter cannot claim a control the component does not implement, because it has no independent source for the list.

A hardcoded table would have been a third place the truth lives, and the third place is always the one nobody updates.

3. An absent verdict is not a passing verdict

The guard I am proudest of runs in the direction nobody checks. Before writing anything, the converter verifies that every policy package the component claims gates a control actually appears in the policy engine's output:

mapping error: the component definition claims these policy packages gate a
control, but conftest reported no result for them:
  compliance.sc28_aws  (control sc-28)
An absent verdict is not a passing verdict. Either the gate did not run the
policy, or the component definition is claiming a rule that no longer exists.

Here is the failure it prevents. A policy file gets renamed, moved, or picks up a syntax error that stops it loading. Conftest returns one fewer entry and exit code 0. The gate is green. The generated assessment-results document is schema-valid. It simply no longer mentions SC-28 — and nothing, anywhere, turns red.

That is how automated compliance rots: not with a failure, but with a silence. Checking for the silence costs eight lines of Python.

The same principle earned its keep a second time, in an unrelated subsystem. verify-pipeline.sh exits 0 when checks are skipped — right for a laptop with a partial toolchain, wrong for CI, where a skip means an install step broke. A CI job that trusted the exit code alone would go green having run six checks instead of fourteen. So the job parses the summary line and fails the build on a non-zero skip count. Two guards, two subsystems, one failure mode: a green signal that quietly covers less than it appears to.


The part I would not automate away

Three of the four controls are enforced at plan time. AU-3 is not, and that is deliberate.

A Terraform plan can show that a CloudTrail trail will be created with the right arguments. It cannot show that the trail is delivering records, because delivery is a runtime property of a system that does not exist yet at plan time. Writing a plan-time rule for AU-3 would have produced a fourth green check that attests to nothing — and a fourth green check is worth less than an honest gap, because it spends credibility rather than earning it.

So AU-3's evidence is captured get-trail-status output from the running account, showing IsLogging: true with recent log and digest delivery. That is a narrower claim than the plan-time controls make — true of one moment, not of every future pull request — and a stronger one, because it is about the system rather than about the intent.

The OSCAL says this in the model rather than in a footnote: the assessment plan carries two control-selections, one per method, and the difference between them is the assurance boundary of the pipeline.


What this does not prove

An assessor's first question about any automated control is "what does it miss?" The answer is the same whoever produces it. The only variable is whether it came from the builder or from the auditor.

So the repository ships an assurance boundary document listing nine limits, each with what would close it. The four that matter most:

  • Plan-time is not runtime. The gate proves no merged change intends to violate a control. It does not prove the control is in force in the account right now.
  • Drift is mostly invisible. A change made in the console is recorded by CloudTrail — forensically recoverable, which is not the same as the control holding.
  • The chain proves custody, not correctness. If terraform plan had produced a wrong answer, the pipeline would have signed the wrong answer faithfully, and every verification would still pass.
  • Thirty days of Object Lock is a demo value. COMPLIANCE mode is real — a hard delete with admin credentials is refused — but SEC 17a-4 retention is measured in years, and requires a designated third party and an audited process besides.

What I would build next

This pipeline proves controls for one account that already exists. The interesting problem is the one before it: standing up a client's foundation so these controls are true from the first apply.

Concretely — an interactively-bootstrapped AWS Organizations landing zone. Admin, NonProd and Prod accounts; OUs and SCPs; an org-level CloudTrail, a Config aggregator, Security Hub and GuardDuty enabled at the organization; Identity Center; and Terraform state bootstrapped before any of it — all driven by a single idempotent shell script an engineer runs once per client.

I scoped it during this build and deliberately did not start it, for reasons worth stating because the reasoning is the transferable part:

  1. It would orphan the evidence. Everything captured across the six weeks — the signed bundle, the plan, twelve Security Hub findings, a 53-object replica listing — was captured against a single-account topology. Rebuilding as multi-account invalidates all of it to re-prove controls that are already proven.
  2. The iteration loop is irreversible. Closing an AWS member account triggers a 90-day suspended state; root email addresses must be globally unique across all of AWS and cannot be reused on a retry; an organization with member accounts cannot be deleted. Every test run burns addresses and leaves 90-day residue.
  3. It is 40–80 hours to a client-ready standard. There were five days.

Knowing which work not to start is most of the job.

The narrower next steps, in order: run the same Rego rules against live state on a schedule so plan-time and runtime verdicts can be compared; generate AWS Config rules from the same Rego source so the two checks cannot disagree by accident; and capture runtime evidence from a scheduled workflow rather than by hand, so every signature in the chain is a workflow signature rather than a person's.


What actually clicked

Compliance evidence is a supply-chain problem, and the tooling already exists. Cosign, Rekor and S3 Object Lock were built for software artifacts, not audits. But the four properties they provide — integrity, authenticity, timeliness, preservation — are precisely the four an auditor is trying to establish about a screenshot, and are precisely the four a screenshot cannot provide.

The realisation that reframed everything after it: those four are separate properties requiring separate mechanisms.

PropertyMechanismProvesSays nothing about
IntegritySHA-256 sidecarThe bytes did not changeWho produced them
Authenticitycosign keyless signatureWhich identity signedWhen
TimelinessRekor transparency logThe signature existed at a timeWhether it can be deleted
PreservationS3 Object Lock, COMPLIANCEIt cannot be deletedWhether the content is true

Compliance programmes routinely collapse all four into "we have evidence", and then discover during an audit which one they were actually missing.

And the second thing: a blocked merge beats a caught mistake. Detection is the industry's default posture, and it is a posture of permanent lateness — every finding is a description of something that already happened. Moving the same rule from "monitor" to "required status check" changes what it is. The violation stops being an incident and becomes a build error: nobody escalates it, nobody writes a ticket, nobody explains it to an auditor. It is just a red X and a one-line fix.

The argument applies to the verification as much as to the controls. The fourteen-check verdict this article opens with used to be something I remembered to run; it now runs on every pull request and is itself a required status check alongside the gate. The check that proves the pipeline works is no longer allowed to be the one nobody ran.


Try it

git clone https://github.com/sevenbelowllc/grc-engineering-club
cd grc-engineering-club

./verify-pipeline.sh    # every eligibility check, one verdict
./traverse.sh           # profile → component → evidence → CHAIN INTACT

No AWS account required — the two vault checks report themselves skipped and the verdict says INCOMPLETE, because a check that did not run is not a check that passed. No cooperation from me required. That was the point.


What I learned

The biggest thing I learned was working with OSCAL and Rego and putting them into action. I really love the policy-as-code methodology: OSCAL's Lego-like models (Catalog, Profile, Component Definition, System Security Plan, Assessment Plan, Assessment Results, POA&M) snap it all together, and Rego evaluates the reality. This challenge really opened my eyes to Policy as Code and I can't wait to start implementing it everywhere I can!


Built for the GRC Engineering Club six-week challenge, July 2026. The repository is public and the pipeline is live.