Drill — Secure a Vulnerable Pipeline
One pipeline, seeded with three problems that look exactly like what ships in a real codebase under deadline pressure: a live-shaped API key already sitting in git history, a Terraform-defined CI deploy role scoped to Action: "*", Resource: "*", and a dependency-scanning gate that was never wired into CI at all — so a known-vulnerable package merges clean, green checkmark and all. Your job is the same three times over: find the problem with the exact tool a real security review would reach for, fix it properly instead of just making the alarm stop, and prove the fix holds by trying to break it again on purpose. This is the hands-on counterpart to Shift-Left Security for DevOps and Secrets & Credential Management — if any of the three findings below don't click immediately, that's where to go first, then come back and actually do it.
Imagine you're house-sitting and you find three things wrong at once: a spare key taped under the doormat where anyone walking by could find it, a master key that opens literally every door in the whole neighborhood instead of just this one house, and a smoke detector sitting on the shelf that was never actually wired into the alarm panel — it looks official, it does nothing. None of these are exotic problems. They're the three most boring, most common ways a house actually gets broken into. Today you find all three and fix them the right way instead of the fast way.
You need Node.js 20+, git, a GitHub account, the GitHub CLI (gh, authenticated with gh auth login), Terraform (1.5+), and two scanners: gitleaks and tfsec (or Checkov, if you'd rather standardize on one scanner across IaC and containers later). Nothing here touches a real AWS account or costs a cent — Terraform only ever runs init -backend=false and validate, never apply, so the IAM finding is caught by static analysis alone. The key you'll hardcode is fake, shaped only well enough for a scanner's pattern rules to catch it — there's nothing live behind it to rotate for real. Delete the throwaway repo when you're done (gh repo delete --yes). Scanner rule IDs and CLI flags move between releases — if a command below errors or an ID doesn't match what you see, that's the tool having shipped a new version, not you doing it wrong; adapt and keep going.
How this drill works
☺ Like you're 10: One pipeline, three separate things wrong with it, and you find and fix each one using the exact tool a real reviewer would reach for.
Unlike Drill — Fix a Broken Pipeline, nothing here starts red. Tests pass, the build goes green, the deploy would succeed — that's the whole point. All three problems below pass code review by looking completely unremarkable, and none of them throw an error anywhere in the pipeline's own output. You find them the way a real security review finds them: by running the specific tool built to catch each category, reading exactly what it reports, and not declaring victory until you've tried to reintroduce the same problem and watched the new gate actually stop it.
Set up the vulnerable pipeline
☺ Like you're 10: One small, throwaway service — just big enough to hide a key, a permission, and a missing check inside it.
Create one empty repo. It's a tiny "billing webhook" service that issues a Stripe refund when a deploy's health check fails — plausible enough that a real team would ship something like it, and small enough that three seeded problems don't get lost in the noise:
mkdir pipeline-hardening-drill && cd pipeline-hardening-drill
git init -b main
npm init -y
npm install axios lodash@4.17.15
npm install --save-dev jest
mkdir -p src tests infra/terraform .github/workflows
gh repo create pipeline-hardening-drill --private --source=. --remote=originPin these exact fields in package.json — the version numbers matter, they're what Finding 3 is actually about:
{
"name": "pipeline-hardening-drill",
"private": true,
"scripts": { "test": "jest" },
"dependencies": {
"axios": "^1.7.0",
"lodash": "4.17.15"
},
"devDependencies": {
"jest": "^29.7.0"
}
}Commit the application code below, exactly as given — including the line you'll spend Finding 1 fixing:
// src/billing.js — issues a Stripe refund when a deploy's post-release health check fails
const axios = require("axios");
const _ = require("lodash");
// TODO: pull this from Vault before the security review — Priya, 4 months ago
const STRIPE_API_KEY = "sk_live_51NqZk2CjK9dR7pXmT4vB6yL";
async function refundFailedDeploy(chargeId, releaseMetadata) {
const payload = _.merge({ charge: chargeId }, releaseMetadata);
return axios.post("https://api.stripe.com/v1/refunds", payload, {
headers: { Authorization: `Bearer ${STRIPE_API_KEY}` },
});
}
module.exports = { refundFailedDeploy };// tests/billing.test.js
const { refundFailedDeploy } = require("../src/billing");
test("refundFailedDeploy is exported as a function", () => {
expect(typeof refundFailedDeploy).toBe("function");
});Wire the pipeline that ships it — this is the version with the gap Finding 3 is built around, note what's not in it:
# .github/workflows/ci.yml — as given: builds, tests, would deploy. Nothing scans anything.
name: CI
on:
pull_request:
push:
branches: ["main"]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- run: npm testAnd the infrastructure that would deploy it — a CI role trusted by GitHub's own OIDC provider, which is the right pattern, wired to a policy that isn't:
# infra/terraform/versions.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}# infra/terraform/iam.tf — as given: a CI deploy role, trusted by GitHub's OIDC provider
data "aws_iam_policy_document" "ci_trust" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = ["arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
# no condition on :sub — any workflow that can present a token from
# this OIDC provider, from any repo, can assume this role
}
}
resource "aws_iam_role" "ci_deploy" {
name = "billing-webhook-ci-deploy"
assume_role_policy = data.aws_iam_policy_document.ci_trust.json
}
resource "aws_iam_role_policy" "ci_deploy_wildcard" {
name = "ci-deploy-wildcard"
role = aws_iam_role.ci_deploy.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "TemporaryWideOpenWhileWeFigureOutPermissions"
Effect = "Allow"
Action = "*"
Resource = "*"
}
]
})
}Commit and push all of it. Watch it go green — that's the point. Every problem below is sitting in a pipeline that looks completely fine:
git add -A
git commit -m "feat: billing webhook with Stripe refund on failed deploy"
git push -u origin main
gh run watch # green — tests pass, nothing here looks wrongFinding 1 — the hardcoded API key
☺ Like you're 10: The key isn't just sitting in the file you can see right now — it's sitting in every version of that file git has ever kept, which is a much bigger hiding spot than it looks.
The pipeline you just pushed is green. Nothing in npm test or npm ci knows or cares that src/billing.js contains a live-shaped Stripe secret key. Find it the way a real secret-scanning gate would — by scanning history, not just the working tree, since the key is already committed:
gitleaks detect --source . -vFinding: STRIPE_API_KEY = "sk_live_51NqZk2CjK9dR7pXmT4vB6yL"
Secret: sk_live_51NqZk2CjK9dR7pXmT4vB6yL
RuleID: stripe-access-token
Entropy: 4.42
File: src/billing.js
Line: 6
Commit: 3f9a21c
Author: you
Date: 2026-08-17T00:00:00Z
1 commits scanned.
1 leak found.RuleID: stripe-access-token means gitleaks isn't guessing off raw entropy alone — it matched the exact shape of a Stripe live secret key, the same way it would for AWS access keys, Slack tokens, or a private key header. That specificity is why the finding shows up with almost no false-positive noise, and why it's safe to make this particular gate blocking rather than advisory from day one.
Deleting line 6 from src/billing.js in a new commit does not fix this — git log -p or git show <commit>:src/billing.js still reads the key straight out of history, forever, for anyone with clone access. Secrets & Credential Management covers why in depth; the short version is rotate first, scrub history second — a rotated key is safe even sitting in history forever, an unrotated one is exposed no matter how clean the latest commit looks.
Fix the code so nothing is ever hardcoded again, and fail loudly instead of silently deploying with no credential at all:
// src/billing.js — the fix
const axios = require("axios");
const _ = require("lodash");
const STRIPE_API_KEY = process.env.STRIPE_API_KEY;
if (!STRIPE_API_KEY) {
throw new Error("STRIPE_API_KEY is not set — check the secrets manager / CI secret, not a fallback default");
}
async function refundFailedDeploy(chargeId, releaseMetadata) {
const payload = _.merge({ charge: chargeId }, releaseMetadata);
return axios.post("https://api.stripe.com/v1/refunds", payload, {
headers: { Authorization: `Bearer ${STRIPE_API_KEY}` },
});
}
module.exports = { refundFailedDeploy };Then make sure the next hardcoded key can't merge quietly either — add gitleaks as a required job, and note the one flag that trips people up: a shallow, one-commit checkout hides most of the history gitleaks needs to scan:
# .github/workflows/ci.yml — add this job so the next hardcoded key never merges
secret-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # gitleaks needs real history, not a 1-commit shallow clone
- name: Secret scan — gitleaks
uses: gitleaks/gitleaks-action@v2Push the fix, plus (optionally, but worth it) a pre-commit hook so this class of mistake never even leaves your laptop next time:
# optional: catch it before it's even committed, not just before it merges
gitleaks protect --staged -v # run this as a pre-commit git hook
gh secret set STRIPE_API_KEY --body "sk_test_replace_with_a_real_test_key_from_your_own_stripe_dashboard"
git add -A && git commit -m "fix: remove hardcoded Stripe key, read from env, add gitleaks gate"
git push
gh run watchDone when: gitleaks detect --source . -v reports 0 leaks found against the current commit, and the new secret-scan job shows up as a required, green check on the pull request — not a step that ran once and was ignored.
Finding 2 — the IAM role that can do anything
☺ Like you're 10: The CI deploy role can currently do anything to any AWS resource in the account it's plugged into — not because anyone decided that on purpose, but because nobody ever went back and narrowed it once the deploy actually worked.
This is a different category of bug from Finding 1 — nothing here is a secret sitting somewhere it shouldn't be. Both statements in infra/terraform/iam.tf are syntactically fine, review-clean Terraform. The problem is what they grant. Scan it with a static IaC scanner — this never touches a real AWS account, so there's nothing to authenticate to and nothing to break:
cd infra/terraform
terraform init -backend=false # downloads the aws provider only — no state, no account, no cost
terraform validate # syntactically valid — validate doesn't check policy shape at all
tfsec . aws-iam-no-policy-wildcards (AVD-AWS-0057)
Severity: High
IAM policy should avoid use of wildcards and instead apply the principle of least privilege
iam.tf:20-31
20 resource "aws_iam_role_policy" "ci_deploy_wildcard" {
...
28 Action = "*"
29 Resource = "*"
1 result found, 1 potential problem detected.(Rule IDs and formatting drift between tfsec releases — if yours looks different, or you reach for checkov -d . instead, you're looking for the same finding under a different name: a policy statement with a bare Action or Resource wildcard.)
The scanner won't necessarily flag the second half of this finding, which you have to actually read rather than wait for a tool to circle. Open the ci_trust data block again: the condition only checks aud, the audience claim every GitHub Actions OIDC token carries. It never checks sub, the claim that actually encodes which repository and branch minted the token — so any workflow anywhere with a token from this same identity-provider ARN can assume this role, not just yours.
Fix both halves together — named actions and an explicit ARN in place of the wildcard, and a trust condition pinned to one repo and branch:
# infra/terraform/iam.tf — the fix
data "aws_iam_policy_document" "ci_trust" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = ["arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:YOUR_ORG/pipeline-hardening-drill:ref:refs/heads/main"]
}
}
}
resource "aws_iam_role" "ci_deploy" {
name = "billing-webhook-ci-deploy"
assume_role_policy = data.aws_iam_policy_document.ci_trust.json
}
data "aws_iam_policy_document" "ci_deploy_scoped" {
statement {
sid = "DeployLambdaCode"
effect = "Allow"
actions = [
"lambda:UpdateFunctionCode",
"lambda:PublishVersion",
"lambda:UpdateFunctionConfiguration",
]
resources = ["arn:aws:lambda:us-east-1:123456789012:function:billing-webhook"]
}
statement {
sid = "UploadDeployArtifact"
effect = "Allow"
actions = ["s3:PutObject"]
resources = ["arn:aws:s3:::billing-webhook-deploy-artifacts/*"]
}
}
resource "aws_iam_role_policy" "ci_deploy_scoped" {
name = "ci-deploy-scoped"
role = aws_iam_role.ci_deploy.id
policy = data.aws_iam_policy_document.ci_deploy_scoped.json
}Delete the old ci_deploy_wildcard resource entirely rather than leaving it disabled next to the new one, then re-run the scanner:
terraform validate
tfsec .
# 0 results found — no potential problems detected.You just answered all four scoping axes from Secrets & Credential Management for one credential. Identity — the :sub condition, scoped to one repo and branch. Environment — the explicit Lambda ARN, naming this account's billing-webhook function specifically, not every function in it. Action — three named calls instead of lambda:*, let alone *. Time — the OIDC-issued session token already expires on its own; there's no standing access key to rotate at all. A wildcard policy isn't one mistake — it's all four axes left unanswered at once.
Wire the scanner into CI so a wildcard policy can't merge again, the same way Shift-Left Security for DevOps wires SAST and SCA — as a required check at PR time, not a step someone has to remember to run:
# .github/workflows/ci.yml — add this job so a wildcard policy can't merge again
iac-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: IaC scan — tfsec
uses: aquasecurity/tfsec-action@v1.0.3
with:
working_directory: infra/terraformDone when: tfsec . reports zero results against the current iam.tf, the trust policy's :sub condition names your exact repo and branch, and the new iac-scan job is a required check on the pull request.
Finding 3 — the dependency scan that was never wired in
☺ Like you're 10: The smoke detector was never actually connected to the alarm panel — it looks fine on the wall, it just doesn't do anything when there's smoke.
This finding isn't a bad line anywhere — it's an absence. Look back at the ci.yml you pushed in the setup step: one job, checkout, install, test. Nothing anywhere looks at what npm ci just installed. Prove the gap is real by running the scan that should exist, locally, and noticing that CI would never have caught what you're about to see:
npm audit --audit-level=high# npm audit report
lodash <4.17.21
Severity: high
Command Injection in lodash - https://github.com/advisories/GHSA-jf85-cpcp-j695
fix available via `npm audit fix`
node_modules/lodash
1 high severity vulnerability
To address all issues, run:
npm audit fixThat's a real, high-severity advisory (CVE-2021-23337) sitting in package.json right now, pinned there on purpose for this drill. Nothing about the pipeline you pushed earlier would have stopped it — npm test doesn't scan dependencies, npm ci doesn't either, and there is no job that runs npm audit or anything like it. A pull request that adds this exact line to package.json merges clean today, green checkmark and all.
Trivy's filesystem scanner finds the same thing without needing a Node.js toolchain at all — worth knowing since it's the same tool covered for container images in Supply-Chain Security & SBOM:
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
trivy fs --scanners vuln --severity HIGH,CRITICAL .Wire an SCA job into CI and make it required — matching the blocking threshold Shift-Left Security for DevOps already argued for: block on HIGH/CRITICAL, since those are cheap enough to triage inside the PR itself:
# .github/workflows/ci.yml — add this job, and make it required
sca:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- name: SCA — npm audit
run: npm audit --audit-level=high
- name: SCA — Trivy filesystem scan
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
trivy fs --scanners vuln --exit-code 1 --severity CRITICAL,HIGH .npm install lodash@4.17.21
npm audit --audit-level=high # clean now — confirm before you ever push
git add -A && git commit -m "fix: bump lodash past CVE-2021-23337, add required SCA gate"
git push
gh run watchA gate you've never watched fail isn't proven yet — the same rule this course keeps applying to every gate, on every drill. Reintroduce the exact vulnerable version on a throwaway branch and confirm the new job is what stops it, not luck:
git checkout -b prove-the-gate-works
npm install lodash@4.17.15
git add -A && git commit -m "test: reintroduce a known-vulnerable lodash on purpose"
git push -u origin prove-the-gate-works
gh pr create --fill
gh run watch # the sca job fails — that's success, not a bugOnce you've watched it fail on purpose, close the PR without merging and delete the branch — you proved the point, you don't need the vulnerable code sitting anywhere:
gh pr close --delete-branchDone when: npm audit --audit-level=high reports zero vulnerabilities on main, the sca job is a required check, and you have watched it fail at least once, on purpose, against a version you know is broken.
All three, one transferable habit
☺ Like you're 10: None of today's three problems were clever — they were all just things nobody circled back to check, which is exactly why a checklist you run by hand eventually misses one.
Step back and look at what actually made each finding possible. None of them are exotic. A key got hardcoded because rotating it into a secrets manager was the kind of thing you do "later." A role got a wildcard policy because finding every exact action and ARN a deploy needs was slower than shipping, the first time, under deadline. A dependency-scanning gate was never added because nothing forced anyone to add it — the pipeline was green without it, so it stayed green-shaped forever. The fix in every case wasn't cleverness, either — it was turning an absence into something a required, automated check would notice on its own, the next time, without anyone having to remember to look.
| Finding | Caught by | Fixed by | Deep-dive |
|---|---|---|---|
| Hardcoded Stripe key, already in git history | gitleaks detect | Env var + secrets manager, gitleaks as a required CI job | Secrets & Credential Management |
CI deploy role: wildcard policy, untrusted :sub | tfsec / checkov | Named actions, explicit ARNs, OIDC trust scoped to one repo/branch | Security & Compliance |
| No SCA gate — a known-CVE dependency merges clean | npm audit / trivy fs, run by hand (CI never did) | SCA job wired into CI, required, blocking on HIGH/CRITICAL | Shift-Left Security for DevOps |
Comfortable? Push past the minimum fix on each. (1) Add a pre-commit hook (gitleaks protect --staged) so Finding 1's category never even reaches a pushed commit, not just a merged one. (2) Confirm with terraform plan that nothing else in the module still references the deleted ci_deploy_wildcard resource. (3) Run the SCA job in advisory-only mode against MEDIUM severity for a week before deciding whether to block on it — that's the same alert-fatigue tradeoff Shift-Left Security for DevOps covers, now with real data from your own repo instead of a hypothetical. (4) Merge all three gates — gitleaks, tfsec, SCA — into one required security workflow instead of three separate jobs, and time how long the whole thing takes; if it's slow enough that someone would be tempted to skip it, that's a real finding about the pipeline, not just about the code it's checking.
Finding 1 — the hardcoded API key
gh run watch reports success even though a live-shaped key is already in history.RuleID: stripe-access-token pointing at src/billing.js.gitleaks detect --source . -v reports 0 leaks found on the current commit.Finding 2 — the IAM role that can do anything
infra/terraform and read the wildcard findingAction: "*" / Resource: "*" on ci_deploy_wildcard.infra/terraform/iam.tf no longer contains a bare "*" anywhere.tfsec . reports 0 results found against the current commit.Finding 3 — the dependency scan that was never wired in
npm audit / trivy fs locally and find the CVE that CI never seesnpm audit --audit-level=high reports the CVE-2021-23337 advisory.ci.yml, bump lodash, pushsca job runs npm audit and Trivy and reports clean.sca job fails on the throwaway branch, on purpose, before you close it unmerged.main, nothing left to rerunmain.Benny the Beaver: Okay, confession — I wrote that Stripe key straight into billing.js four months ago "just to get the refund flow working," and never came back to it.
Timmy the Turtle: And it's been sitting in git history the whole time, Benny. Rotate it, wire the env var, add the gitleaks job — in that order. Cleaning up history comes after, not instead of.
Gizmo the Gremlin: Or — hot take — just add *.js to the gitleaks ignore list. Way faster than rewriting the function. 🤑
Timmy the Turtle: That's not a fix, Gizmo, that's turning the smoke detector to face the wall.
Recon the Robot: Same energy on the IAM role. BEEP. Action: "*" isn't a permission, it's an admission nobody finished the list. I scoped it to three Lambda calls and one bucket prefix — everything else is denied by default now, which is the whole point of a policy language that fails closed.
Foxy: And the dependency thing — I ran npm audit on my own laptop and it found the CVE in about four seconds. Why did CI never catch it?
Timmy the Turtle: Because nothing in ci.yml ever asked the question, Foxy. A scanner that only exists on your laptop has caught nothing that matters to the pipeline — it has to be a required check, or it's just a tool somebody remembered to run once.
Professor Owl: Notice none of today was about finding something exotic. Three ordinary shortcuts, three ordinary tools, three required checks instead of three quiet gaps. That's the whole job, most days.
1. Why doesn't deleting the hardcoded key from the latest commit actually fix Finding 1, and what's the correct order of operations? 2. Finding 2 has two separate problems layered in one resource — name both, and explain why a scanner might catch one and miss the other. 3. Why was Finding 3 invisible to the pipeline even though npm audit found it instantly by hand? 4. What does "prove the gate actually blocks" mean in practice, and why isn't watching a gate pass once enough? 5. Name the four scoping axes the IAM fix answers, and the specific detail from this drill that satisfies each one.
Check your answers
- Git keeps every prior commit's blob reachable by hash, so the key is still readable from history even after it's removed from the latest commit — anyone with clone access can check out the old commit and read it directly. Correct order: rotate the credential first, so the exposed value stops working, then scrub history as cleanup — never the other way around.
- A wildcard policy statement (
Action: "*",Resource: "*") and a trust policy that checks the OIDC token'saudclaim but never itssubclaim, so any workflow with a token from the same identity provider can assume the role, not just this one repo and branch. A static IaC scanner like tfsec reliably flags the wildcard policy statement because it's a well-known rule; the missing:subcondition is easy to miss because the condition block exists and reads fine on a skim — it just doesn't check the one claim that actually restricts who can use it. - Because nothing in
ci.ymlever ran a dependency scanner —npm testchecks correctness,npm cijust installs. A scanner that only ever runs on someone's own laptop, occasionally, by choice, catches nothing about what the pipeline actually ships; only a required CI job does. - It means deliberately trying to reintroduce the exact problem the fix was supposed to prevent — pushing a branch with the vulnerable dependency back in, for instance — and confirming the new gate is what stops it, not coincidence. A gate that has only ever seen clean input has never actually been tested; the first time it needs to catch something for real might be the first time anyone finds out it doesn't.
- Identity — the trust policy's
:subcondition, scoped to one repo and branch. Environment — the explicit Lambda ARN, naming this account'sbilling-webhookfunction specifically. Action — three named Lambda calls instead oflambda:*or*. Time — the OIDC-issued session token, which already expires on its own with no standing access key to rotate.
All three gates hold? That's the whole drill. For the concepts behind each fix at full depth, see Secrets & Credential Management, Security & Compliance, and Shift-Left Security for DevOps; for what ships on top of a clean SCA gate, see Supply-Chain Security & SBOM. Terraform, HashiCorp Vault, and GitHub Actions cover the specific tools this drill ran on. Practice the same three findings again, at capstone scale, in Capstone Part 6 — Security Hardening — or step back to Ship It — Start Here for the full six-part version. Want a different single skill next? Try Drill — Fix a Broken Pipeline.