⬡ Engagement — Operation: OPEN BUCKET, Phase 2
Six weeks after your Phase 1 remediation plan, Meridian Retail Group's SOC 2 auditor came back with a follow-up letter: IAM, storage, and logging passed review — but the audit scope is expanding. Meridian just containerized their checkout service onto Kubernetes, their platform team has been hand-editing Terraform in production for two years with no review process, and nobody has ever looked at whether their security groups actually do what their names say. This is the second half of the job: network, containers, and the pipeline that ships infrastructure changes in the first place.
Everything from Phase 1's safety rules still applies — own infrastructure or named practice targets only, billing alarms, destroy-after-use, never root. Phase 2 adds one more, because this module's tooling lives in CI/CD pipelines and supply chains, not just cloud consoles.
This is general training guidance, not legal or financial advice. Cloud provider pricing and free-tier terms change — always check current limits before deploying a lab, especially EKS/AKS/GKE.
| Surface | Why Phase 1 Skipped It | Why Phase 2 Can't |
|---|---|---|
| Network (VPCs, Security Groups) | IAM misconfig is the #1 breach vector — that came first | An over-permissive security group exposes every service behind it regardless of IAM correctness |
| Containers & Kubernetes | Meridian wasn't running K8s yet | Checkout service just containerized — default K8s settings are notoriously insecure |
| Infrastructure-as-Code | Can't scan what doesn't exist yet as code | Two years of hand-edited production Terraform, never peer-reviewed for security |
Phase 1 and Phase 2 together form the complete picture: you can have perfect IAM and still have a publicly accessible EC2 instance because a security group says 0.0.0.0/0 on port 22. Both layers have to be correct at the same time.
The IaC scanner you'll use in Module 3 — Palo Alto Networks, actively maintained, unaffected by the Trivy supply-chain incident.
The Kubernetes CLI.
Kubernetes-in-Docker — spins up a local cluster instantly with no cloud cost, used for Modules 2 and 3's K8s labs.
CIS Kubernetes Benchmark checker — as a direct binary download, not via a GitHub Action (see Lab 0.3).
K8s penetration testing tool — same, binary install only.
Run the version check for each one before moving on.
# Checkov pip install checkov checkov --version # kubectl (Linux) curl -LO "https://dl.k8s.io/release/$(curl -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" chmod +x kubectl && sudo mv kubectl /usr/local/bin/ kubectl version --client # kind (Kubernetes-in-Docker) — binary install curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.24.0/kind-linux-amd64 chmod +x kind && sudo mv kind /usr/local/bin/ kind version # kube-bench — binary download, NOT via GitHub Action wget https://github.com/aquasecurity/kube-bench/releases/latest/download/kube-bench_linux_amd64.tar.gz tar -xzf kube-bench_linux_amd64.tar.gz && sudo mv kube-bench /usr/local/bin/ kube-bench version # kube-hunter — pip install pip install kube-hunter kube-hunter --help
| Tool | Verify Command | Expected |
|---|---|---|
| checkov | checkov --version | Checkov 3.x.x |
| kubectl | kubectl version --client | Client version output, no server error |
| kind | kind version | kind vX.X.X go... |
| kube-bench | kube-bench version | Version string |
| kube-hunter | kube-hunter --help | Help text with --remote, --cidr, --pod options |
On March 19, 2026, a threat actor known as TeamPCP used credentials stolen three weeks earlier to force-push 76 of 77 version tags in the aquasecurity/trivy-action GitHub repository — the official GitHub Action used to run Trivy in CI/CD pipelines. Every pipeline referencing a tag like aquasecurity/trivy-action@v0.24.0 automatically pulled and executed the attacker's code. The malicious payload ran silently before the legitimate Trivy scan, so affected pipelines appeared to complete normally while harvesting AWS/Azure/GCP credentials, SSH keys, Kubernetes service account tokens, and other secrets.
This was the second compromise in under a month — the first (February 28) extracted a privileged access token via a misconfigured pull_request_target workflow. Incomplete credential rotation during the first incident gave the attacker a foothold to launch the tag-poisoning attack three weeks later. The exposure window was roughly 3–6 hours before Aqua Security identified and removed the malicious artifacts.
Why it's in this workbook: Trivy was the "recommended" tool for most of what Module 3 covers just four months ago. This incident is the reason this workbook uses Checkov for IaC scanning and installs kube-bench as a direct binary rather than via a GitHub Action — and Module 4 turns this specific attack into the lesson plan for supply-chain security.
| Unsafe Pattern (Used Before the Incident) | Safe Pattern (What You'll Do in This Workbook) |
|---|---|
uses: aquasecurity/trivy-action@v0.24.0 | Pin to a full 40-char commit SHA, not a mutable version tag |
| Install kube-bench via its GitHub Action | Download the binary directly from GitHub Releases (the binary itself was safe — only the Action was compromised) |
| Trivy as sole IaC/container scanner in CI/CD | Checkov for IaC scanning; evaluate any tool's incident history before putting it in your pipeline |
| No monitoring of outbound CI/CD network calls | Alert on unexpected outbound connections from runner environments |
Note any that are still using the Default VPC — this should be deleted in production accounts.
A subnet whose route table has a 0.0.0.0/0 → internet gateway route is public, regardless of what it's called.
Private subnets route outbound traffic through a NAT gateway — they can reach out but can't be reached in.
Databases and internal services should never be directly internet-routable, even if a security group restricts port access.
# list all VPCs in your account aws ec2 describe-vpcs --query 'Vpcs[*].{ID:VpcId,CIDR:CidrBlock,Default:IsDefault}' # inspect route tables — look for 0.0.0.0/0 with gateway destination aws ec2 describe-route-tables --query 'RouteTables[*].{ID:RouteTableId,Routes:Routes[*].{Dest:DestinationCidrBlock,GW:GatewayId}}'
| Finding | Risk |
|---|---|
| Default VPC still exists | Permissive default security group + internet gateway attached — a blast radius waiting to happen |
| 0.0.0.0/0 → igw-xxx in a "private" subnet | The subnet is public regardless of its name — anything in it is internet-reachable |
| Database instance in a public subnet | Network layer doesn't protect it — only security group rules do, and those can be misconfigured |
And their inbound rules.
Any rule allowing traffic from the whole internet.
22 (SSH), 3389 (RDP), 3306 (MySQL), 5432 (Postgres), 27017 (MongoDB), 6379 (Redis) — none should be reachable from 0.0.0.0/0.
Is the group named "private-db" but has port 3306 open to 0.0.0.0/0? That's your finding.
Each finding: group ID, port, intended vs actual access, fix recommendation.
# find security groups with inbound 0.0.0.0/0 or ::/0 rules aws ec2 describe-security-groups \ --query "SecurityGroups[?IpPermissions[?IpRanges[?CidrIp=='0.0.0.0/0']]].{Name:GroupName,ID:GroupId,Rules:IpPermissions}" \ --output table # alternatively, use Prowler's EC2 security group checks from Phase 1 prowler aws --service ec2 --checks ec2_securitygroup_allow_ingress_from_internet_to_any_port
| Port Open to 0.0.0.0/0 | Severity | Correct Pattern |
|---|---|---|
| 22 (SSH) | High | Restrict to your office IP or use AWS Systems Manager Session Manager — no SSH at all |
| 3389 (RDP) | High | Same — restrict by IP or use a bastion/SSM |
| 3306 / 5432 / 27017 | Critical | Allow only from the application server's security group ID, never from 0.0.0.0/0 |
| 80 / 443 | Expected for public-facing load balancers only | Verify the instance behind it is intentionally public |
On Meridian's VPCs.
Sending records to an S3 bucket or CloudWatch Logs.
Make a few API calls, curl a public endpoint from an EC2 instance — something that produces records.
Identify source IP, destination IP, port, action (ACCEPT/REJECT), and bytes transferred.
Something that tried to connect and was blocked — confirm it matches a security group rule.
# check for existing flow logs on your VPC aws ec2 describe-flow-logs # enable flow logs to S3 (replace bucket-name with your own) aws ec2 create-flow-logs \ --resource-type VPC \ --resource-ids vpc-XXXXXXXX \ --traffic-type ALL \ --log-destination-type s3 \ --log-destination arn:aws:s3:::your-flow-logs-bucket
| Flow Log Field | What It Tells You |
|---|---|
| action: ACCEPT | Traffic reached the destination — the security group allowed it |
| action: REJECT | Traffic was blocked by a security group or NACL rule |
| Unexpected ACCEPT on a sensitive port | A security group is more permissive than intended |
| High-volume REJECT from a single IP | Possible port scan or credential stuffing attempt against a blocked service |
Copy the example below into a file — it deliberately contains the four most common container security mistakes.
Running as root, no USER instruction, latest tag, no health check, unnecessary packages.
Fix each issue: pin a specific image tag, add a non-root USER, pin dependencies, minimize the attack surface.
And confirm the hardened one runs correctly.
Note how many of the issues Checkov catches automatically.
# ── insecure baseline Dockerfile ────────────────────────── FROM node:latest # unpinned tag — version drift is a supply-chain risk RUN apt-get install -y curl # unnecessary tool increases attack surface COPY . . RUN npm install CMD ["node", "app.js"] # runs as root — no USER instruction # ── hardened version ────────────────────────────────────── FROM node:20.18-alpine3.20 # pinned digest; alpine reduces surface area WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev # no dev dependencies in production image COPY --chown=node:node . . USER node # non-root; node user exists in the official image EXPOSE 3000 CMD ["node", "app.js"] # scan your Dockerfile with Checkov checkov -f Dockerfile
| Issue | Risk | Fix |
|---|---|---|
| FROM node:latest | Unpredictable image content across builds; supply-chain risk | Pin to a specific version + digest |
| No USER instruction | Process runs as root inside container — privilege escalation path | Add USER nonroot before CMD |
| npm install (not npm ci) | Installs dev deps + resolves floating versions | Use npm ci --omit=dev |
| Unnecessary tools (curl) | Extra attack surface; used by post-exploit payloads to exfiltrate data | Remove everything not needed at runtime |
Your local, cost-free Kubernetes environment.
Against the cluster — note that kind hides some control-plane files, so some checks will WARN rather than FAIL.
Control Plane, Worker Nodes, Policies — find at least three FAIL items.
kube-bench prints the exact fix for each finding. Apply one.
Then destroy the cluster.
# create a local cluster with kind kind create cluster --name meridian-checkout # run kube-bench as a job inside the cluster kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml kubectl wait --for=condition=complete job/kube-bench --timeout=120s kubectl logs job/kube-bench # when finished — always destroy immediately kind delete cluster --name meridian-checkout
| kube-bench Section | Common FAILs on a Default Kind Cluster |
|---|---|
| 1.x Control Plane | API server anonymous auth, audit logging not configured |
| 3.x Control Plane Config | etcd data directory permissions, peer TLS settings |
| 5.x Policies | Default namespace has no NetworkPolicy, Pod Security Admission not configured |
For this lab.
Flag any that bind the cluster-admin role to a ServiceAccount — that ServiceAccount can do anything in the cluster.
With only the permissions the checkout service actually needs — read-only access to ConfigMaps in one namespace.
From outside the cluster — it maps the attack surface without attempting exploitation.
Note which findings overlap with kube-bench's policy section.
Immediately after.
# list ClusterRoleBindings to find over-privileged ServiceAccounts kubectl get clusterrolebindings -o json | \ jq '.items[] | select(.roleRef.name=="cluster-admin") | .subjects' # create a minimal ServiceAccount with read-only ConfigMap access kubectl create serviceaccount checkout-sa -n default kubectl create role checkout-reader \ --verb=get,list --resource=configmaps -n default kubectl create rolebinding checkout-rb \ --role=checkout-reader --serviceaccount=default:checkout-sa -n default # run kube-hunter in passive mode (safe — no exploitation attempts) kube-hunter --remote $(kubectl cluster-info | grep -oP 'https://[^ ]+' | head -1) # destroy when done kind delete cluster --name meridian-checkout
| RBAC Issue | Impact | Fix |
|---|---|---|
| ServiceAccount bound to cluster-admin | Any pod using this SA can do anything cluster-wide | Create a minimal Role scoped to exactly what the pod needs |
| Default ServiceAccount auto-mounted | Every pod in the namespace gets a token even if it doesn't need one | Set automountServiceAccountToken: false on Pods that don't use the API |
| Wildcard verbs in a Role | "* on *" in a namespace is effectively admin for that namespace | List only the specific verbs (get, list, watch) actually needed |
--active mode attempts real exploits and should never be used in this workbook — passive/remote mode is sufficient to understand the attack surface.Copy the file below — it contains five real misconfigurations Meridian's platform team would plausibly have committed.
Against the directory.
Note which checks pass, which fail, the check IDs, and the file/line references.
Directly in the Terraform file.
Track the delta — this is exactly what a PR security gate would show.
# main.tf — deliberately misconfigured S3 + security group resource "aws_s3_bucket" "meridian_data" { bucket = "meridian-data-2024" # ISSUE 1: no server-side encryption block # ISSUE 2: no versioning block } resource "aws_s3_bucket_public_access_block" "meridian_data" { bucket = aws_s3_bucket.meridian_data.id block_public_acls = false # ISSUE 3: public access not blocked block_public_policy = false } resource "aws_security_group" "checkout" { name = "checkout-sg" ingress { from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] # ISSUE 4: SSH open to internet } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] # ISSUE 5: unrestricted egress } } # scan — no plan file needed, Checkov reads HCL directly checkov -d .
| Checkov Check ID | Finding | Severity |
|---|---|---|
| CKV_AWS_19 | S3 bucket not encrypted | High |
| CKV_AWS_21 | S3 versioning not enabled | Medium |
| CKV_AWS_53/54/55/56 | Block Public Access settings false | High |
| CKV_AWS_25 | Security group allows SSH from 0.0.0.0/0 | High |
| CKV_AWS_277 | Security group unrestricted egress | Medium |
Copy the example below.
Same command, different file.
Note the overlap — the same misconfiguration shows up in both the cluster audit and the manifest scan.
Add runAsNonRoot: true, readOnlyRootFilesystem: true, and drop all capabilities.
# checkout-deployment.yaml — deliberately insecure manifest apiVersion: apps/v1 kind: Deployment metadata: name: checkout spec: replicas: 1 selector: matchLabels: app: checkout template: metadata: labels: app: checkout spec: containers: - name: checkout image: meridian/checkout:latest # unpinned tag # MISSING: securityContext block entirely # MISSING: resources.limits (no CPU/memory caps) # MISSING: readinessProbe # scan the manifest checkov -f checkout-deployment.yaml --framework kubernetes
| Checkov Check ID | Kubernetes Finding |
|---|---|
| CKV_K8S_14 | Container not running as non-root |
| CKV_K8S_20 | Containers should not run with allowPrivilegeEscalation |
| CKV_K8S_28 | No resource limits defined |
| CKV_K8S_8 | No liveness probe defined |
| CKV_K8S_15 | Image tag is "latest" (mutable, unpinned) |
All Terraform and Kubernetes files together — Checkov auto-detects framework per file.
For structured processing.
Using the same severity-first approach as Phase 1's Module 4.3 remediation plan.
Exact HCL or YAML additions, not vague recommendations.
Findings, business risk, exact fix, owner, target date — the deliverable Meridian's engineering team can act on.
# scan everything at once — Checkov detects Terraform + Kubernetes + Dockerfiles checkov -d . --output json --output-file-path ./checkov-results # filter for critical and high findings only cat ./checkov-results/results_json.json | \ jq '[.results.failed_checks[] | select(.severity=="HIGH" or .severity=="CRITICAL")]'
Identify the three issues the Trivy incident exploited or enabled.
Not a version tag. A SHA cannot be rewritten; a tag can. This is the single most impactful change.
Declare only the permissions the workflow actually needs.
If it checks out PR code and has token permissions, it's a critical vulnerability waiting to be exploited.
Checkov covers GitHub Actions YAML — same tool, different framework.
# ── INSECURE workflow ───────────────────────────────────── on: [push, pull_request_target] # RISK: pull_request_target + checkout = credential theft jobs: scan: runs-on: ubuntu-latest # RISK: no permissions block = default wide-open GITHUB_TOKEN steps: - uses: actions/checkout@v4 # RISK: mutable version tag - uses: aquasecurity/trivy-action@v0.24.0 # RISK: tag was rewritten in March 2026 # ── HARDENED workflow ───────────────────────────────────── on: [push] # pull_request_target removed permissions: contents: read # explicit least-privilege jobs: scan: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # SHA pinned - name: Run Checkov IaC scan run: pip install checkov && checkov -d . # Checkov via pip — no GitHub Action needed # scan your own workflow file with Checkov checkov -f .github/workflows/scan.yml --framework github_actions
| Vulnerability | How It Was Exploited in March 2026 | Fix |
|---|---|---|
| Mutable version tags | Tags rewritten to point to malicious commits — pipelines pulled attacker code automatically | Pin all Actions to full 40-char commit SHA |
| pull_request_target + checkout | Lets PR code run with the base repo's secrets — the initial credential theft vector | Separate trusted workflow from untrusted PR code; avoid the combination |
| Over-permissive GITHUB_TOKEN | Default token had write access that amplified the blast radius | Declare permissions: contents: read at minimum |
| Pattern | Risk |
|---|---|
| AWS_ACCESS_KEY_ID in GitHub Secrets, rotated annually | Static key — if the pipeline is compromised, the attacker has a valid key that lasts until next rotation |
| AWS OIDC — no stored key at all | GitHub requests a short-lived token per job; attacker capturing it gets something valid for minutes, not months |
From Module 7 of the Bug Bounty workbook — secrets accidentally committed to Terraform or workflow files are extremely common.
Create an IAM OIDC provider for GitHub Actions and an IAM Role it can assume — no stored key required.
Replace aws-actions/configure-aws-credentials using stored keys with the OIDC token approach.
Without any long-lived key stored in GitHub Secrets.
# scan for accidentally committed secrets gitleaks detect --source . --log-opts="--all" # hardened workflow using OIDC (no stored AWS keys) permissions: id-token: write # required for OIDC contents: read steps: - name: Configure AWS Credentials via OIDC uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # SHA pinned with: role-to-assume: arn:aws:iam::ACCOUNT_ID:role/GitHubActionsRole aws-region: us-east-1 # no access-key-id or secret-access-key — OIDC exchanges the GitHub token for temporary AWS creds
| Gate | Tool | Catches |
|---|---|---|
| Pre-commit (developer machine) | Checkov + gitleaks | IaC misconfigs and secrets before the commit lands |
| PR gate (GitHub Actions) | Checkov, SHA-pinned, OIDC credentials | Same checks on every PR — blocks merge on High/Critical |
| Post-deploy (runtime) | kube-bench CronJob · VPC Flow Logs · CloudTrail | Drift from the hardened baseline after deployment |
| Periodic full-account audit | Prowler (from Phase 1) | Everything Phase 1 covers — IAM, storage, logging — on a schedule |
Combining Labs 4.1 (SHA pinning, OIDC, least-privilege) and 4.2 (OIDC), with a Checkov scan gate on every PR.
Both phases together: what Meridian's posture was on day one, what changed in Phase 1, and what Phase 2 adds.
What does the pipeline above still not catch? (Runtime threats inside a running container — the answer is a runtime security tool like Falco, which is out of scope here but the honest next step.)