Passing the CGE-P and Building infra-modules

I passed the GRC Engineer Practitioner (CGE-P) exam and finished the capstone project!

The most useful thing I built was not part of the course. Partway through, I started infra-modules for a client project, and it ended up doing most of the work in my capstone. One capstone pull request put a storage bucket back on default encryption. The pipeline blocked it before anything was built:

GAP-01 (SOC2-CC6.1): S3 bucket "aws_s3_bucket.uploads" is not encrypted with SSE-KMS using a customer-managed key

This post covers how that works.

Why I built infra-modules

The labs had me write a compliant S3 module (Lab 2.3), a compliant GCS module (Lab 2.4), and Rego policies that check a Terraform plan against NIST controls. Then I started building Look At These Birds, a birding blog for a client. The client wanted a secure blog where they owned the code, plus a CMS to add new posts. That ruled out an off-the-shelf CMS, since those providers store the content on their own backend. So I built a lightweight CMS and admin portal on Next.js and AWS.

My site design used S3, CloudFront, WAF, Cognito, SES, DynamoDB, Lambda, and Route 53. Since I was working through the CGE-P labs at the time, I wanted to apply what I was learning in a real project. I made a repo called infra-modules. It started with an S3 module and a policy. It now has 16 AWS modules.

How the modules work

Two things live in the repo, and they follow different versioning rules.

  • Modules are pinned. A project references a tag, like ?ref=v1.25.0, and upgrades when it chooses to. Each project also commits an infra-modules.yml that declares its version, and CI checks that every module reference matches it.
  • Policies are not pinned. CI clones the repo at main, runs terraform plan, and checks the plan with Conftest. apply only runs if the check passes.

The unpinned policy clone is on purpose. Every project is held to the current rules, so nothing drifts. The cost is that a new rule can fail an existing project's next build. That works for me as a solo developer.

The Rego rules are organized by security intent, like encryption at rest or auditable account activity, not by module. One intent can cover S3 and DynamoDB in a single section. The rules check the plan itself, so they also catch a resource someone wrote by hand outside a module.

I also chose not to write cloud-agnostic modules. A module that abstracts over S3 and GCS hides the provider-specific settings, and those are the settings the policies need to inspect. What I share across clouds is the pattern, not the resource code.

The repo has its own gates. Every Rego rule has an allow fixture and a deny fixture. Module logic is covered by terraform test with a mocked provider. Checkov runs as a blocking check. main is protected, and that applies to me too.

What a real project found

As I built the blog, I discovered changes the modules needed. I used Claude Code to work across both repos in parallel and make those changes as I went. Three examples:

  1. The SES bounce topic failed on apply with InvalidSNSDestination: Access denied to KMS key. The module defaulted to the AWS-managed SNS key, and AWS does not let you edit that key's policy, so SES could never publish to the topic. The plan looked fine and only a real apply caught it. The module now creates its own customer-managed key with a policy that lets SES publish.
  2. My first CloudTrail rule failed any plan without a trail. That would have broken every other project's build, so the rule now checks a trail's properties instead.
  3. My DynamoDB and SNS encryption rules fired on resources being destroyed. A deleted resource has no after state in the plan, so every deleted table looked newly non-compliant. I added a helper that skips resources being destroyed and still checks replacements. A rule that fires on false positives gets disabled, and then the gate protects nothing.

The capstone

The capstone project is a patient intake API for a fictional company, "Acme Health". It shipped with eight known compliance gaps. I chose SOC 2 as the framework instead of HIPAA, the obvious choice for a healthcare app. SOC 2 is the standard software companies get asked about most, and its Type II audits look at whether controls are working over time. A pipeline that signs evidence on every merge produces that kind of proof. SOC 2 does not replace HIPAA, but the two overlap. The starter maps seven of the eight gaps to both a SOC 2 criterion and a HIPAA Security Rule section, so most of the same fixes support both.

I added four layers:

  1. Terraform fixes for six of the eight gaps.
  2. Rego policies that block five of those from coming back.
  3. A GitHub Actions pipeline that plans, checks policy, applies, signs the evidence with Cosign, and uploads it to an Object Lock vault.
  4. An OSCAL component that traces each control to a Terraform resource and to signed evidence.

The brief requires at least five policies, each detecting a real gap. Here is how my eight gaps came out, with the SOC 2 criterion each one maps to:

  • Fixed, with a policy: GAP-01 and GAP-02 (encryption with my own KMS key, CC6.1), GAP-03 (deny non-TLS requests, CC6.7), GAP-05 (Lambda in the VPC, CC6.6), and GAP-07 (least-privilege IAM, CC6.3).
  • Fixed, no policy: GAP-04, bucket versioning (A1.2). It is one setting, so there is no useful bad change to test against.
  • Not fixed: GAP-06 (Lambda concurrency, dead-letter queue, tracing) and GAP-08 (API logging, throttling, firewall). Both map to CC7.2, monitoring. The OSCAL file documents them as planned, not implemented, and a real SOC 2 audit would flag CC7.2 because of them.

The write-up in the repo lists the trade-offs I accepted and what I did not get to.

The evidence vault and the DynamoDB table both come from infra-modules. Both needed to use my own KMS key, and the modules did not support that yet. I extended them and released v1.25.0.

The live apply also caught two mistakes of mine. My notes said GAP-01 and GAP-04 were closed. The KMS key was never wired into the uploads bucket, and the versioning fix sat on a branch that never merged. I fixed both and confirmed with a plan that showed no drift. Now I check the live resources instead of my notes.

The evidence backs up the gate.

  • PR #12 passed and merged, and its run signed the evidence bundle and uploaded it to the vault.
  • PR #13 is the blocked one from the top of this post.

The exam

The exam was 60 multiple-choice questions with 90 minutes to complete it. I finished in about 40 minutes. It costs $350, or it is free for members of the GRC Engineering Club, which I am.

I started the course a couple of months ago and worked on other things in between, so I leaned on the study guide to remember the earlier sections. Because I built the labs as I went, and infra-modules on the side, the concepts stuck and the exam was much easier than I expected.