Capstone Part 6 — Security Hardening
This is the sixth and last of six parts building one continuous project: checkout-svc, the same Northwind Retail checkout service you gave a gated pipeline in Part 1 and real AWS infrastructure in Part 2, then shipped through a canary in Part 3, watched with real dashboards in Part 4, and paged yourself on for real in Part 5. Part 5's postmortem closed with three action items, and named the third one this page's opening move — a policy-as-code check that closes the exact gap its own incident found. Every part before this one also moved fast by deliberately leaving something loose — that's normal, and it's how this whole capstone was built on purpose, one working layer at a time. Today you don't add a layer. You go back through every one you already built and close what was left open: the pipeline stops authenticating to AWS with a key that never expires, checkout-svc's database credential stops living in plaintext, and nothing merges again without first being asked whether it's safe to run. By the end of this page there is no more "later" left in this capstone.
Picture the house Parts 1 through 5 built. It went up fast because the crew borrowed the general contractor's master key for every door instead of cutting one key per room, and someone taped the safe's combination to the inside of the closet door because typing it in every time was slow. The house works — people have been living in it for five chapters. It's just wide open in exactly two places, and the front door lets packages in without ever checking what's inside the box. Today isn't building a new room. It's walking the finished house with a checklist, cutting the right key for each door, moving the combination into an actual safe, and putting an inspector at the front door before anything ships through it again.
checkout-svc's infrastructure against Git; today he reconciles who's allowed to touch it.Arriving: checkout-svc running in prod behind the ALB and ASG Part 2 provisioned, shipping through a canary rollout, watched by real dashboards, and already paged and postmortemed once — with a postmortem action item this page still owes. Its pipeline still authenticates to AWS with a long-lived access key, its database credential still sits in plaintext inside the launch template's user_data, and its image has never once been scanned for a known vulnerability. Leaving this page: a required policy-check job closes Part 5's action item outright, refusing to let the canary's instance sizing change again without an explicit, typed acknowledgment; that same pipeline authenticates to AWS with a short-lived, narrowly scoped role assumed over GitHub's own OIDC provider; the database credential lives in AWS Secrets Manager, readable only by an EC2 instance role that can read exactly that one secret and nothing else; a required security-scan job blocks any pull request that introduces a Critical dependency CVE or a Critical/High image CVE; and every other shortcut this capstone took has been named out loud and either closed or explicitly justified. There is no Part 7 — this is where the capstone ends.
Part 6's opening move: the policy-as-code gate Part 5 filed
☺ Like you're 10: Part 5 wrote a sticky note that said "make sure this can't happen quietly again." This is that sticky note, turned into a gate nobody can skip past.
Part 5's blameless postmortem traced its incident back to one root cause: the canary's resource sizing was copied forward from an earlier config, unreviewed, and nobody re-evaluated whether it still held up under real concurrent traffic until it didn't. The postmortem filed three action items. Two were process fixes for that week. The third was explicitly deferred here — named, in Part 5's own words, as this page's opening move, not optional extra credit:
| Action item | Filed by | Closed here by |
|---|---|---|
| A policy-as-code check that blocks a canary from progressing past its first weighted step unless its instance sizing changed with a reviewed, typed acknowledgment — not silently inherited | Part 5's postmortem | An Open Policy Agent check run against every terraform plan, required in CI before apply |
The mechanism translates cleanly from Part 5's incident language into the AWS/Terraform world Part 2 through Part 4 actually run on: "the canary's resources.limits, inherited unreviewed" becomes "the canary launch template's instance_type, inherited unreviewed" — same failure shape, same fix shape, different infrastructure underneath. Write the policy as Rego, checked with conftest (brew install conftest, or the release binary — everything else here you already installed for Part 1 or Part 2) against the plan's own JSON output:
## infra/policy/resource-sizing.rego
package terraform.checkout
deny[msg] {
rc := input.resource_changes[_]
rc.type == "aws_launch_template"
rc.name == "checkout"
before := rc.change.before.instance_type
after := rc.change.after.instance_type
before != after
not input.variables.resource_sizing_reviewed.value
msg := sprintf(
"instance_type changing from %v to %v without resource_sizing_reviewed=true — the exact class of change Part 5's incident traced back to",
[before, after]
)
}The policy only ever fires on the specific change class that caused the incident — an instance_type diff — and it only ever asks for one thing: a human to set a variable that can't be true by accident. Wire it into CI as a required job, run before terraform apply ever executes:
# .github/workflows/ci.yml — runs before terraform-apply, blocks it on deny
policy-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/checkout-svc-ci-deploy
aws-region: us-east-1
- run: terraform -chdir=infra/envs/prod init
- run: terraform -chdir=infra/envs/prod plan -out=tfplan -var-file=prod.tfvars
- run: terraform -chdir=infra/envs/prod show -json tfplan > plan.json
- uses: instrumenta/conftest-action@master
with:
files: infra/envs/prod/plan.json
policy: infra/policy/Prove it the same evidence-first way every other gate on this page gets proven. Bump prod's instance_type without setting the new variable, and watch the exact failure Part 5's postmortem was written to prevent:
# infra/envs/prod/main.tf — bump instance_type, nothing else
# instance_type = "m5.large" -> instance_type = "m5.xlarge"
git commit -am "test: (temporary) resize prod without review"
git push
gh pr checks --watch
# policy-check fail instance_type changing from m5.large to m5.xlarge
# without resource_sizing_reviewed=true — the exact class
# of change Part 5's incident traced back to
# the real way through: an explicit, typed acknowledgment, not a bigger PR description
git commit -am "resize: m5.large -> m5.xlarge, reviewed against Part 4's memory dashboard"
TF_VAR_resource_sizing_reviewed=true gh pr checks --watchThat failure is Part 5's action item, closed — not with a paragraph in a document nobody reopens, but with a CI job that refuses to let the exact class of unreviewed change that paged you once ever merge silently again. Make it a required check the same way every other gate on this page becomes one:
gh api --method PUT repos/{owner}/{repo}/branches/main/protection \
--input - <<'JSON'
{
"required_status_checks": { "strict": true, "contexts": ["build-and-test", "policy-check"] },
"enforce_admins": true,
"required_pull_request_reviews": { "required_approving_review_count": 0 },
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false
}
JSONSee Compliance as Code & Policy Enforcement for the general Policy Decision Point / Policy Enforcement Point pattern this job is a working instance of — conftest is the PDP, evaluating the plan against Rego; the required check in branch protection is the PEP, refusing to let a denial through.
What this part assumes, and the rest of the shortcut audit it closes
☺ Like you're 10: Nothing new to install — just the tools you already have, and an honest list of what's still been left loose since Part 1.
You need the same tooling Part 1 and Part 2 already had you install: git, the GitHub CLI (gh, authenticated), Terraform 1.9 or newer, the AWS CLI configured against the same AWS account Part 2 provisioned into, Docker, and jq for reading JSON back out of the AWS CLI, plus the conftest binary from the section above. Nothing here is new infrastructure — every change on this page edits or extends a file Part 1 or Part 2 already created. With Part 5's own action item filed, the rest of this page turns to what nobody filed a ticket for — the shortcuts baked in since Part 1 and Part 2 that no incident ever surfaced. Read this table once, straight through, before touching anything else:
| Shortcut | Where it was introduced | Why it was fine, until now | Closed by |
|---|---|---|---|
| The pipeline authenticates to AWS with a long-lived access key | Part 2 — needed for terraform apply from CI or a laptop | A disposable sandbox account with no blast radius beyond your own capstone | This page — GitHub OIDC + a scoped checkout-svc-ci-deploy role |
DATABASE_URL baked into the launch template's user_data, in plaintext | Part 2 — every environment since, just trimmed out of what was shown | Nobody outside your own account could ever read it | This page — AWS Secrets Manager + a least-privilege EC2 instance role |
The app security group's egress is wide open — every port, every protocol, 0.0.0.0/0 | Part 2 — let the first apply succeed without fighting network rules while everything else was still being proven | Ingress was already correctly locked to the ALB; egress was the one corner cut to keep Part 2 focused on IaC, not networking | This page — narrowed to 443/tcp |
ci.yml builds an image but never scans it, or the dependencies inside it | Part 1 — explicitly deferred: "this pipeline stops at a verified local build, on purpose" | Part 1's job was proving the gate exists, not proving what's inside it is safe | This page — a required security-scan job |
required_approving_review_count: 0 on branch protection | Part 1 — explicit, acknowledged: GitHub won't let you approve your own PR | A solo capstone with a nonzero count locks the one person doing the capstone out entirely | Audited, not changed. Part 1 already named this trade-off and its real-team fix; nothing here pretends it's a problem |
Auditing isn't the same as fixing everything. Some shortcuts in a fast-moving build are genuine, documented trade-offs — pretending every single one needs a commit just buries the real ones under busywork nobody reads. The last row above stays exactly as it is, on purpose, and says so out loud. The other four don't get that pass, and the rest of this page proves why, with your own hands, the same way every earlier part did.
Least-privilege IAM for the pipeline: from a static key to a scoped role
☺ Like you're 10: Swap the master key everyone's been quietly passing around for a badge that only opens the three doors this job actually needs — and expires the moment the job clocks out.
Whatever credential you set as a GitHub Actions secret back in Part 2 to let CI (or your own laptop) run terraform apply — an AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY pair off an IAM user — has three problems at once: it never expires on its own, it's scoped as broadly as whichever IAM user issued it (commonly your own admin identity, on a personal sandbox), and it sits readable in the repo's secret store for as long as nobody remembers to rotate it. OIDC federation replaces all three: GitHub's own OIDC provider mints a short-lived identity token for exactly one workflow run, AWS trusts that token because you registered GitHub as an identity provider once, and the role it can assume is scoped to only what this pipeline actually touches. Register the provider and the role in infra/bootstrap, next to the state backend Part 2 already put there:
## infra/bootstrap/oidc.tf
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
# GitHub's documented root CA thumbprint at the time of writing — AWS has since
# started validating and rotating this automatically in most regions. Check
# AWS's and GitHub's current docs before copying this value verbatim.
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}## infra/bootstrap/ci-role.tf
data "aws_iam_policy_document" "ci_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
# locks this role to ONE repo, on ONE branch — a PR from a fork, or a
# push to any other branch, gets no token at all
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:YOUR-ORG/checkout-svc:ref:refs/heads/main"]
}
}
}
resource "aws_iam_role" "ci_deploy" {
name = "checkout-svc-ci-deploy"
assume_role_policy = data.aws_iam_policy_document.ci_assume.json
max_session_duration = 3600 # one hour — long enough for one apply, not a whole afternoon
}
data "aws_iam_policy_document" "ci_deploy_permissions" {
statement {
sid = "TerraformState"
effect = "Allow"
actions = ["s3:GetObject", "s3:PutObject", "s3:GetBucketLocation"]
resources = [
"arn:aws:s3:::acme-terraform-state",
"arn:aws:s3:::acme-terraform-state/checkout/*",
]
}
statement {
sid = "TerraformLock"
effect = "Allow"
actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem"]
resources = ["arn:aws:dynamodb:us-east-1:123456789012:table/acme-terraform-locks"]
}
statement {
sid = "ReadCheckoutCompute"
effect = "Allow"
# Describe* calls aren't scopable to a specific resource in EC2's IAM
# model — this breadth is normal, not a gap. The real scoping happens below.
actions = ["ec2:Describe*", "elasticloadbalancing:Describe*", "autoscaling:Describe*"]
resources = ["*"]
}
statement {
sid = "WriteCheckoutCompute"
effect = "Allow"
actions = [
"ec2:CreateSecurityGroup", "ec2:AuthorizeSecurityGroupIngress", "ec2:AuthorizeSecurityGroupEgress",
"ec2:RevokeSecurityGroupEgress", "ec2:CreateLaunchTemplate", "ec2:CreateLaunchTemplateVersion",
"ec2:CreateTags", "elasticloadbalancing:CreateLoadBalancer", "elasticloadbalancing:CreateTargetGroup",
"elasticloadbalancing:CreateListener", "autoscaling:CreateAutoScalingGroup", "autoscaling:UpdateAutoScalingGroup",
]
resources = ["*"]
condition {
# several EC2/ELB/ASG create-actions only support conditions on the tag
# being REQUESTED, not a tag an existing resource already carries —
# that's an AWS limit, not a mistake in this policy
test = "StringEquals"
variable = "aws:RequestTag/Project"
values = ["checkout-svc"]
}
}
statement {
sid = "ManageCheckoutSecrets"
effect = "Allow"
actions = ["secretsmanager:CreateSecret", "secretsmanager:PutSecretValue", "secretsmanager:TagResource", "secretsmanager:DescribeSecret"]
resources = ["arn:aws:secretsmanager:us-east-1:123456789012:secret:checkout-svc/*"]
}
statement {
sid = "PassOnlyTheInstanceRole"
effect = "Allow"
actions = ["iam:PassRole"]
resources = ["arn:aws:iam::123456789012:role/checkout-*-instance"]
condition {
test = "StringEquals"
variable = "iam:PassedToService"
values = ["ec2.amazonaws.com"]
}
}
}
resource "aws_iam_role_policy" "ci_deploy" {
name = "checkout-svc-ci-deploy-scope"
role = aws_iam_role.ci_deploy.id
policy = data.aws_iam_policy_document.ci_deploy_permissions.json
}Notice what's missing: no iam:CreateUser, no iam:CreateAccessKey, no secretsmanager:GetSecretValue (the CI role never needs to read the DB secret — only the EC2 instance role, below, does), and the compute statement's reach is "tagged checkout-svc at creation time," not "anything in this account." A role that can manage its own service but can't mint new AWS identities or read runtime secrets is the whole point of least privilege — it's what turns "this pipeline got compromised" from "the entire AWS account is compromised" into "one service's infrastructure needs a review."
Apply it once, from a human terminal, the same way Part 2's bootstrap ran — this directory still has no remote backend of its own, for the same chicken-and-egg reason Part 2 explained:
cd infra/bootstrap
terraform init
terraform plan -out=tfplan
terraform apply tfplan
terraform output -raw ci_deploy_role_arn
# arn:aws:iam::123456789012:role/checkout-svc-ci-deployNow point ci.yml at it instead of a static key. OIDC needs one new top-level permission on the workflow, and one new step before any AWS or Terraform command runs:
# .github/workflows/ci.yml — additions to the workflow Part 1 wrote
permissions:
id-token: write # lets this workflow request a short-lived OIDC token from GitHub
contents: read
jobs:
terraform-apply:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/checkout-svc-ci-deploy
aws-region: us-east-1
# no access-key-id, no secret-access-key — there is no static credential to leak
- run: terraform -chdir=infra/envs/dev init
- run: terraform -chdir=infra/envs/dev apply -auto-approve -var-file=dev.tfvarsDelete the old credential for real — leaving it inactive isn't the same as gone:
aws iam list-access-keys --user-name YOUR-CI-USER
aws iam delete-access-key --user-name YOUR-CI-USER --access-key-id AKIA...
gh secret delete AWS_ACCESS_KEY_ID
gh secret delete AWS_SECRET_ACCESS_KEY
# if that user has no other purpose left, remove the user itself too:
aws iam delete-user --user-name YOUR-CI-USERProving the role can't do more than this
Trust, but verify — with a command, not a reading of the policy. From a run authenticated as checkout-svc-ci-deploy (a scratch workflow_dispatch step is the easiest way to try this), attempt something the policy deliberately excludes:
aws s3 ls
# An error occurred (AccessDenied) when calling the ListBuckets operation:
# User: arn:aws:sts::123456789012:assumed-role/checkout-svc-ci-deploy/... is not
# authorized to perform: s3:ListAllMyBuckets
aws iam create-user --user-name whoops
# An error occurred (AccessDenied) when calling the CreateUser operationBoth denials are the actual proof. The policy above never grants s3:ListAllMyBuckets — only GetObject/PutObject on one bucket and prefix — and never grants any iam:Create* action at all. A role that can run terraform apply against checkout-svc's own state but cannot enumerate every bucket in the account or mint a new identity is exactly what "least privilege for the pipeline" means, made concrete instead of asserted.
Moving checkout-svc's database credential into Secrets Manager
☺ Like you're 10: Stop taping the safe's combination to the closet door and put it in an actual safe — one only the room that needs it can open.
Part 2's own listing of modules/checkout-service/main.tf was "trimmed to the shape that mattered" for provisioning — the full file every environment has actually been running since then also launches checkout-svc with its Postgres connection string baked straight into user_data, in plaintext, like this:
## infra/modules/checkout-service/main.tf — the untrimmed user_data, unchanged since Part 2
resource "aws_launch_template" "checkout" {
name_prefix = "${local.name}-"
image_id = data.aws_ami.al2023.id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.app.id]
user_data = base64encode(<<-EOT
#!/bin/bash
dnf install -y docker && systemctl enable --now docker
docker run -d --restart unless-stopped -p ${var.container_port}:${var.container_port} \
-e DATABASE_URL="postgres://checkout:Sup3rS3cr3t@checkout-db.${var.environment}.internal:5432/checkout" \
registry.internal/checkout-svc:${var.image_tag}
EOT
)
# ...tag_specifications unchanged from Part 2
}Anyone who ever gets read access to this Terraform module, this state file, or a shell on any instance it launches now also has the database password, for every environment, forever — because it was never a secret at runtime, it was a string literal. Add a Secrets Manager secret, an EC2 instance role that can read exactly that one secret, and rewrite user_data to fetch it at boot instead of embedding it:
## infra/modules/checkout-service/variables.tf — one new input
variable "db_password" {
type = string
sensitive = true
description = "passed via TF_VAR_checkout_db_password at apply time — never committed to a .tfvars file"
}## infra/modules/checkout-service/main.tf — additions
resource "aws_secretsmanager_secret" "checkout_db" {
name = "checkout-svc/${var.environment}/db-credentials"
}
resource "aws_secretsmanager_secret_version" "checkout_db" {
secret_id = aws_secretsmanager_secret.checkout_db.id
secret_string = jsonencode({
username = "checkout"
password = var.db_password
})
}
data "aws_iam_policy_document" "instance_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
resource "aws_iam_role" "checkout_instance" {
name = "${local.name}-instance"
assume_role_policy = data.aws_iam_policy_document.instance_assume.json
}
resource "aws_iam_role_policy" "checkout_instance_secrets" {
name = "read-own-db-secret"
role = aws_iam_role.checkout_instance.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["secretsmanager:GetSecretValue"]
Resource = aws_secretsmanager_secret.checkout_db.arn # this one secret. Nothing else.
}]
})
}
resource "aws_iam_instance_profile" "checkout" {
name = "${local.name}-instance"
role = aws_iam_role.checkout_instance.name
}
resource "aws_launch_template" "checkout" {
name_prefix = "${local.name}-"
image_id = data.aws_ami.al2023.id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.app.id]
iam_instance_profile { name = aws_iam_instance_profile.checkout.name } # new — this instance had no role at all before
user_data = base64encode(<<-EOT
#!/bin/bash
dnf install -y docker aws-cli jq && systemctl enable --now docker
SECRET=$(aws secretsmanager get-secret-value \
--secret-id checkout-svc/${var.environment}/db-credentials \
--query SecretString --output text --region us-east-1)
DB_USER=$(echo "$SECRET" | jq -r .username)
DB_PASS=$(echo "$SECRET" | jq -r .password)
docker run -d --restart unless-stopped -p ${var.container_port}:${var.container_port} \
-e DATABASE_URL="postgres://$DB_USER:$DB_PASS@checkout-db.${var.environment}.internal:5432/checkout" \
registry.internal/checkout-svc:${var.image_tag}
EOT
)
}$VAR, not ${VAR}, inside this heredocNotice $DB_USER and $DB_PASS above have no curly braces, while ${var.container_port} and ${var.environment} do. That's not a style choice — Terraform interpolates anything shaped like ${...} inside a heredoc before the shell ever sees it, so a literal shell variable reference written as ${DB_USER} would make Terraform go looking for a Terraform value named DB_USER and fail with "reference to undeclared variable." Bash accepts bare $VAR just as validly as ${VAR} — using the bare form here is what keeps Terraform's interpolation and the shell's own variable expansion from colliding in the same string.
Generate a fresh password per environment, pass it only as an apply-time environment variable, and apply:
export TF_VAR_checkout_db_password=$(openssl rand -base64 24)
cd infra/envs/dev
terraform init -upgrade
terraform plan -out=tfplan -var-file=dev.tfvars
terraform apply tfplan
unset TF_VAR_checkout_db_password # don't leave it sitting in your shell history or environmentThis is a real, honest improvement over the launch template you had — but it isn't the final form. Terraform still writes var.db_password into its own state file as plaintext (state itself is encrypted at rest in Part 2's S3 bucket, but anyone with s3:GetObject on it can still read the value out). A production setup would let Secrets Manager generate and rotate the password itself, out of band from any Terraform apply, or hand this whole job to HashiCorp Vault's dynamic database credentials, which mint a lease that expires whether anyone remembers to rotate it or not. Both are one deliberate step further than this capstone goes — the gap is worth knowing by name, not pretending away.
Tightening the app security group's egress
☺ Like you're 10: The front door was already locked to the right visitor. The back door was still propped open to the whole street.
Part 2's aws_security_group.app correctly restricts ingress to only the ALB's own security group — that part was never loose. Its egress, though, allows every port and every protocol to anywhere:
## infra/modules/checkout-service/main.tf — as Part 2 left it
resource "aws_security_group" "app" {
name = "${local.name}-app"
vpc_id = data.aws_vpc.default.id
ingress {
from_port = var.container_port
to_port = var.container_port
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress { from_port = 0, to_port = 0, protocol = "-1", cidr_blocks = ["0.0.0.0/0"] } # every port, every protocol
}checkout-svc only ever needs outbound HTTPS — pulling its own image, reaching Secrets Manager, talking to its Postgres backend over TLS. Narrow it to that:
## infra/modules/checkout-service/main.tf — replace the single egress block above with:
resource "aws_security_group" "app" {
name = "${local.name}-app"
vpc_id = data.aws_vpc.default.id
ingress {
from_port = var.container_port
to_port = var.container_port
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
description = "HTTPS only — registry, Secrets Manager, Postgres over TLS. Nothing else."
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}terraform plan -out=tfplan -var-file=dev.tfvars
# ~ update in-place: aws_security_group.app — 1 to change
terraform apply tfplan
curl -s -o /dev/null -w "%{http_code}\n" "$(terraform output -raw checkout_url)/healthz"
# 200 — the running service never needed anything egress opened up beyond 443 in the first placeAdding a dependency and image scanning gate to ci.yml
☺ Like you're 10: The inspector who checks every box the instant it's set down — now she also opens the box.
Part 1 built a real gate: nothing merges to main without build-and-test passing. But build-and-test only ever asked "does this compile and pass its own tests" — never "does anything in this dependency tree, or this built image, have a known Critical vulnerability." Add a second required job that asks exactly that. A dependency scan (also called SCA, software composition analysis) checks the packages checkout-svc pulls in at build time against a CVE database; an image scan checks the finished container — base OS packages included, not just what npm installed:
# .github/workflows/ci.yml — a new required job, alongside build-and-test
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci
- name: Dependency scan (SCA)
run: npm audit --omit=dev --audit-level=critical
- name: Build the image for scanning
run: docker build -t checkout-svc:${{ github.sha }} .
- name: Image scan (Trivy)
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: checkout-svc:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: "1"
ignore-unfixed: trueignore-unfixed: true is a deliberate, honest choice, not a loophole: failing a build over a CVE with no available patch yet just blocks every future merge until upstream ships a fix nobody can rush, which trains a team to route around the gate instead of trusting it. Failing on everything that does have a fix, every time, is the version of this rule worth actually enforcing. Now widen branch protection to require the new job too, the same way Part 1 required build-and-test in the first place — cumulatively, alongside the policy-check context this page already required earlier:
gh api --method PUT repos/{owner}/{repo}/branches/main/protection \
--input - <<'JSON'
{
"required_status_checks": { "strict": true, "contexts": ["build-and-test", "policy-check", "security-scan"] },
"enforce_admins": true,
"required_pull_request_reviews": { "required_approving_review_count": 0 },
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false
}
JSONProve it actually gates, the same way Part 1 proved build-and-test did — by breaking it on purpose. Add a package with a known, fixable Critical CVE on a throwaway branch:
git checkout -b prove-the-scan-gate
npm install lodash@4.17.15 # a real, long-patched prototype-pollution CVE — good for exactly this proof
git commit -am "test: (temporary) add a known-vulnerable dependency"
git push -u origin prove-the-scan-gate
gh pr create --fill --base main
gh pr checks --watch
# security-scan fail Critical severity vulnerability found: GHSA-... lodash
gh pr merge
# X Required status check "security-scan" is failing — merge not allowed.
npm uninstall lodash
git commit -am "revert: drop the vulnerable dependency"
git push
gh pr checks --watch && gh pr merge --squash --delete-branchThat refusal is the point of this whole section: a Critical CVE with a known fix is now exactly as un-mergeable as a failing unit test was in Part 1 — not a warning in a log somewhere nobody reads, a blocked merge button.
What "done" looks like — the capstone, closed
☺ Like you're 10: Every door has its own key now, the safe's in the wall instead of taped to a door, and an inspector checks every box before it ships. Six chapters, one story, finished.
At the end of this part: policy-check closes Part 5's own filed action item, refusing to let the canary's instance sizing change unreviewed ever again; the pipeline authenticates to AWS as checkout-svc-ci-deploy, a role that expires hourly and cannot enumerate the account or mint new identities; the old long-lived key no longer exists; checkout-svc's database credential lives in Secrets Manager, readable only by the one EC2 instance role scoped to it; the app security group's egress is narrowed to 443/tcp; and security-scan sits alongside build-and-test and policy-check as a third required check, proven — not assumed — to block a real, fixable Critical CVE. Nothing from any earlier part was thrown away to get here; every fix above edited a file an earlier part already wrote:
| Part | What it built | What this page closed in it |
|---|---|---|
| 1 — Pipeline Foundation | A gated ci.yml: build-and-test required on every push | Added policy-check and security-scan as two more required gates — the build was verified; what was inside it, and what it was about to resize, wasn't, until now |
| 2 — Infrastructure as Code | Real AWS infra for dev/staging/prod from one module, applied with a static AWS key | Replaced the key with OIDC, added a real DB secret and instance role, narrowed egress |
| 3 — Deployment Strategy | A canary rollout across the ALB this module provisions | Ships through the exact same hardened infrastructure and pipeline — nothing about the rollout itself changed |
| 4 — Observability | Dashboards and alerts scraping the ASG this module creates | Unaffected — the instances being watched are the same instances, now booting with a role instead of a plaintext secret |
| 5 — Incident Response | A real page, a runbook, a blameless postmortem, and three filed action items | The policy-as-code action item is closed outright, not just tracked; any future incident's blast radius is also smaller — a compromised CI run can no longer read every bucket in the account, and a leaked instance no longer hands over the DB password by itself |
Pip the Hummingbird: I still remember paging everyone over that resize. Did the policy-check gate actually go in, or did it just get talked about?
Recon: It's in. I watched it deny an unreviewed m5.large to m5.xlarge myself, then pass the exact same change with one typed variable set. Your ticket's closed, Pip — not filed, closed.
Benny the Beaver: Six parts in and it still runs. Do we really need to touch the key and the launch template both? Neither one's ever actually leaked.
Recon: "Never leaked yet" isn't a control, Benny, it's a streak. The role I just wired can't list a bucket outside its own state prefix. The old key could've listed every bucket in the account. That's the whole difference.
Gizmo: Or — hot take — just leave the DB password in user_data. It's base64'd. That's basically encrypted. 🤑
Timmy: Base64 is an encoding, Gizmo, not a lock — anyone with ec2:DescribeLaunchTemplateVersions reads it in one command, no key required. I already ran that command. It came right back out.
Foxy: And the branch protection review count — you left that at zero. Isn't that the same kind of shortcut?
Timmy: No — that one I checked, wrote down, and left alone on purpose. GitHub won't let me approve my own PR. On a real team it's the first knob you turn. On a solo capstone, turning it just locks the door from the inside.
Professor Owl: Six parts, one loop, closed. That's the whole capstone.
Milestones
☺ Like you're 10: Tick each box only once you've actually watched it happen on your own screen, not because the step "sounds right."
Work these in order — each depends on the pipeline and infrastructure state from the one before. Progress saves in this browser.
policy-check gateinfra/policy/resource-sizing.rego and the policy-check job exactly as shown, then push a resize with and without TF_VAR_resource_sizing_reviewed=true.checkout-svc-ci-deploy role to infra/bootstrapoidc.tf and ci-role.tf exactly as shown, substituting your own GitHub org, then terraform apply.terraform output -raw ci_deploy_role_arn prints a real role ARN.ci.yml at the role instead of a static keypermissions: id-token: write and the aws-actions/configure-aws-credentials step shown above.terraform apply with no AWS_ACCESS_KEY_ID in sight.aws iam delete-access-key, then gh secret delete both repo secrets.aws iam list-access-keys for that user returns nothing, and the repo's secrets list no longer shows them.var.db_password, aws_secretsmanager_secret, aws_iam_role.checkout_instance, and aws_iam_instance_profile.checkout, exactly as shown.terraform plan shows these four new resources, with no errors.user_data to fetch the secret at bootDATABASE_URL line with the aws secretsmanager get-secret-value + jq sequence shown, using bare $VAR, not ${VAR}.export TF_VAR_checkout_db_password=$(openssl rand -base64 24), then terraform apply, then unset it.curl .../healthz against dev's ALB still returns 200, now booting off the fetched secret.443/tcpprotocol = "-1" egress block with the single 443/tcp block shown, then apply.terraform plan shows exactly one in-place update to aws_security_group.app, and /healthz still answers 200 after apply.security-scan job to ci.ymlnpm audit) and image scan (Trivy) steps shown above, as one new job.main.policy-check and security-scan required, alongside build-and-testgh api --method PUT .../protection command with all three contexts listed.npm install lodash@4.17.15 on a throwaway branch, open a PR, then run gh pr merge and read the refusal.security-scan is red, then revert and confirm it merges clean.aws s3 ls and aws iam create-user and confirm both are denied. Then walk the shortcut audit table one final time.AccessDenied, and four of the five audit rows read "closed" while the fifth still reads "audited, not changed" — on purpose.1. What action item did Part 5's postmortem file for this page, and what exactly does the new policy-check gate refuse to let through unreviewed? 2. Why is a GitHub OIDC-federated IAM role a genuine security improvement over a long-lived AWS access key stored as a repo secret, even though both let CI run terraform apply? 3. Name the two places a credential was living either in plaintext or with unnecessarily broad reach before this part, and where each one lives now. 4. Which row in the shortcut audit table was deliberately left unfixed, and why is that a legitimate answer instead of a gap?
Check your answers
- A policy-as-code check blocking the canary's infrastructure from progressing past its first weighted step unless its instance sizing changed with a reviewed, typed acknowledgment — the same class of unreviewed inheritance Part 5's five-whys traced the incident back to. The
policy-checkjob denies any Terraform plan that changes the canary launch template'sinstance_typeunlessTF_VAR_resource_sizing_reviewed=trueis explicitly set for that run. - A static key never expires on its own and is scoped as broadly as whichever IAM user issued it — commonly a personal admin identity. An OIDC-federated role only exists for the lifetime of one workflow run's token, is trusted only for one specific repo and branch (via the
subclaim condition), and carries only the exact permissions its own policy grants — proven in this page by two AWS calls it's denied, not by the policy's text alone. checkout-svc's database credential — was plaintext in the launch template'suser_data, now lives in AWS Secrets Manager behind a one-secret-only EC2 instance role. The pipeline's own AWS credential — was a long-lived, broadly-scoped access key, now is a short-lived role assumed via GitHub OIDC.required_approving_review_count: 0on Part 1's branch protection. It's legitimate because GitHub won't let a solo capstone author approve their own pull request — setting it to 1 or more on a one-person project would lock the only person doing the work out entirely. Part 1 named this trade-off explicitly when it made the choice; this page audits it again rather than silently leaving it unexamined, and leaves it exactly as it was.
Part 6 closed the capstone: every credential the pipeline uses now expires on its own, the one credential checkout-svc's own database needs lives in a secrets manager instead of a launch template, and nothing merges without a required scan standing between a vulnerability and production. There is no Part 7 — six parts, one continuous project, done. Step back to Ship It — Start Here to see the whole six-stage arc in one place, or turn what you just proved with your own hands into exam-ready recall with Measuring Success: the DORA Metrics and the DOP-C02 exam guide. Revisit Secrets & Credential Management and Supply-Chain Security & SBOM for the concepts behind what you just built, or get a faster, standalone rep of a scanning gate with Drill — Secure a Vulnerable Pipeline.