# Chapter 13 Labs: Cloud Security

This chapter builds a self-contained cloud-security practice environment you can run on a laptop with no cloud bill. Two components handle this: a Docker Compose stack (LocalStack as a local AWS emulator, plus tool-runner containers for the offline labs) and a KIND Kubernetes cluster for the container/Kubernetes labs. Labs 13.1-13.4 require KIND running on the host; Labs 13.5 and 13.6 run entirely inside Docker.

**Authorisation rule for every offensive lab in this chapter:** run only against the provided local lab targets or a cloud account you own and are explicitly authorised to test. Never run attack steps against infrastructure you do not own.

---

## Prerequisites

| Requirement | Version | Notes |
|---|---|---|
| Docker Desktop / Docker Engine | 24+ | Required for all labs |
| Docker Compose v2 | bundled with Docker Desktop | `docker compose` (not `docker-compose`) |
| KIND | 0.31.0 | Labs 13.1-13.4 |
| kubectl | any current | Labs 13.1-13.4 |
| Helm | 3.x | Lab 13.4 (Falco) |
| Disk space | ~3 GB | LocalStack, KIND node image, Checkov, Python |

Install KIND: https://kind.sigs.k8s.io/docs/user/quick-start/
Install kubectl: https://kubernetes.io/docs/tasks/tools/
Install Helm: https://helm.sh/docs/intro/install/

Run `make check-kind` to verify the host prerequisites.

---

## Quick start

```bash
# Start all containerised services
docker compose up -d

# Or start one lab at a time
docker compose --profile lab1 up -d    # LocalStack
docker compose --profile lab2 up -d    # S3 attack/fix tools
docker compose --profile lab5 up -d    # CloudTrail investigation
docker compose --profile lab6 up -d    # Checkov IaC scan

# Teardown
docker compose down -v
```

---

## Lab 13.1: A cloud on your laptop (LocalStack + KIND)

**Objective:** Stand up a local AWS emulator (LocalStack) and a local Kubernetes cluster (KIND) to use as the substrate for Labs 13.2-13.4 and 13.6.

### Setup

**Part A - LocalStack (containerised):**

```bash
docker compose --profile lab1 up -d
# Wait for healthy status
curl -sf http://localhost:4566/_localstack/health | python3 -m json.tool
```

**Part B - KIND cluster (host setup - not containerised):**

KIND requires the `kind` binary on the host and runs full Kubernetes nodes as Docker containers with kernel access. It cannot be nested inside another container in a portable way.

Install KIND: https://kind.sigs.k8s.io/docs/user/quick-start/

```bash
# Create the lab cluster (pinned to v1.36.0 per access date)
kind create cluster --name ch13 --image kindest/node:v1.36.0

# Or use the Makefile shortcut
make kind-create
```

### Walkthrough

```bash
# Verify LocalStack
pip install awscli-local awscli
awslocal s3 mb s3://ch13-lab
awslocal s3 ls

# Verify KIND
kubectl --context kind-ch13 get nodes
```

### Expected output

```
make_bucket: s3://ch13-lab

NAME                  STATUS   ROLES           AGE   VERSION
ch13-control-plane    Ready    control-plane   30s   v1.36.0
```

### Verify completion

Both commands exit cleanly: `awslocal s3 ls` lists `ch13-lab`; `kubectl --context kind-ch13 get nodes` shows STATUS=Ready.

### Cleanup

```bash
# LocalStack
docker compose --profile lab1 down -v

# KIND cluster
kind delete cluster --name ch13
# or: make kind-delete
```

---

## Lab 13.2: S3 attack-and-fix (offensive)

**Objective:** Enumerate a publicly-readable S3 bucket and over-permissive bucket policy (simulated in LocalStack), then fix it with S3 Block Public Access and a scoped policy.

**Authorisation:** this lab runs only against the local LocalStack instance. It never reaches a real AWS account.

### Setup

```bash
docker compose --profile lab2 up -d
# Wait for the tools container to seed the vulnerable bucket (~20 s)
docker logs ch13-awslocal-tools
```

Enter the tools container:

```bash
docker exec -it ch13-awslocal-tools bash
```

### Walkthrough

**ATTACK phase:**

```bash
# 1. Enumerate buckets
awslocal s3 ls

# 2. List bucket contents (should succeed - bucket is publicly readable)
awslocal s3 ls s3://ch13-lab

# 3. Read the "sensitive" object (should succeed with public policy)
awslocal s3 cp s3://ch13-lab/secret.txt -

# 4. Inspect the over-permissive policy
awslocal s3api get-bucket-policy --bucket ch13-lab | python3 -m json.tool
# Note: Principal = "*" with s3:GetObject and s3:ListBucket - world-readable
```

**FIX phase:**

```bash
# 5. Enable S3 Block Public Access (all four settings)
awslocal s3api put-public-access-block \
  --bucket ch13-lab \
  --public-access-block-configuration \
  "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"

# 6. Replace the wildcard policy with a scoped one
awslocal s3api put-bucket-policy \
  --bucket ch13-lab \
  --policy file:///lab2/bucket-policy-fixed.json

# 7. Confirm the bucket is no longer publicly readable
awslocal s3 cp s3://ch13-lab/secret.txt -
# Expected: An error occurred (AccessDenied) when calling the GetObject operation
```

For IAM privilege-escalation practice (the iam:PassRole chain), use CloudGoat v2.5.0 against your own AWS account:

```bash
# On the host (not in Docker):
git clone https://github.com/RhinoSecurityLabs/cloudgoat
cd cloudgoat && git checkout v2.5.0
pip3 install -r requirements.txt
./cloudgoat.py config profile <your-aws-profile>
./cloudgoat.py create iam_privesc_by_rollback
```

CloudGoat project: https://github.com/RhinoSecurityLabs/cloudgoat

### Expected output

After the fix, `awslocal s3 cp s3://ch13-lab/secret.txt -` returns:

```
An error occurred (AccessDenied) when calling the GetObject operation: Access Denied
```

### Verify completion

Block Public Access is confirmed with:

```bash
awslocal s3api get-public-access-block --bucket ch13-lab
# All four settings show true
```

### Cleanup

```bash
docker compose --profile lab2 down -v
```

---

## Lab 13.3: Kubernetes attack, then harden (offensive)

**Host setup (not containerised)**

This lab requires a KIND cluster with full kubectl and cluster-admin access. Deploying a privileged pod, mounting host paths, and running kube-bench require direct kernel and node access that cannot be replicated inside a nested container.

**Authorisation:** run only against your own local KIND cluster (`kind-ch13`). Never run kube-bench, kubescape, or any attack step against a cluster you do not own.

### Setup

```bash
kind create cluster --name ch13 --image kindest/node:v1.36.0
kubectl --context kind-ch13 get nodes
```

Install tools on the host:

```bash
# kube-bench v0.15.4
curl -sSLo kube-bench.tar.gz \
  https://github.com/aquasecurity/kube-bench/releases/download/v0.15.4/kube-bench_0.15.4_linux_amd64.tar.gz
tar xf kube-bench.tar.gz

# kubescape v4.0.8
curl -sSLo kubescape \
  https://github.com/kubescape/kubescape/releases/download/v4.0.8/kubescape-linux-amd64
chmod +x kubescape && mv kubescape /usr/local/bin/
```

### Walkthrough

**ATTACK phase:**

```bash
# 1. Deploy an over-bound ServiceAccount
kubectl --context kind-ch13 create serviceaccount over-bound-sa -n default
kubectl --context kind-ch13 create clusterrolebinding over-bound \
  --clusterrole=cluster-admin --serviceaccount=default:over-bound-sa

# 2. Enumerate rights from inside a pod using that SA
kubectl --context kind-ch13 run attacker --image=ubuntu:24.04 \
  --serviceaccount=over-bound-sa -it --rm \
  --command -- bash

# Inside the pod:
apt-get update -qq && apt-get install -y curl
# Use the SA token to enumerate rights
kubectl auth can-i --list --as=system:serviceaccount:default:over-bound-sa

# 3. Schedule a privileged host-mounting pod
cat <<EOF | kubectl --context kind-ch13 apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: priv-pod
spec:
  serviceAccountName: over-bound-sa
  containers:
  - name: priv
    image: ubuntu:24.04
    command: ["sleep", "3600"]
    securityContext:
      privileged: true
    volumeMounts:
    - name: host-root
      mountPath: /host
  volumes:
  - name: host-root
    hostPath:
      path: /
EOF
kubectl --context kind-ch13 exec priv-pod -- ls /host/etc
```

**BASELINE:**

```bash
# CIS benchmark
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/v0.15.4/job.yaml
kubectl logs job.batch/kube-bench | grep FAIL

# Kubescape NSA framework scan
kubescape scan framework nsa --context kind-ch13
```

**HARDEN phase:**

```bash
# 1. Install Calico CNI (enables NetworkPolicy enforcement)
kubectl --context kind-ch13 apply -f \
  https://raw.githubusercontent.com/projectcalico/calico/v3.27.0/manifests/calico.yaml

# 2. Apply default-deny NetworkPolicy
cat <<EOF | kubectl --context kind-ch13 apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: default
spec:
  podSelector: {}
  policyTypes:
  - Ingress
  - Egress
EOF

# 3. Enforce restricted Pod Security Standard
kubectl --context kind-ch13 label namespace default \
  pod-security.kubernetes.io/enforce=restricted

# 4. Replace cluster-admin binding with least-privilege Role
kubectl --context kind-ch13 delete clusterrolebinding over-bound
kubectl --context kind-ch13 create role least-priv \
  --verb=get,list --resource=pods -n default
kubectl --context kind-ch13 create rolebinding least-priv-binding \
  --role=least-priv --serviceaccount=default:over-bound-sa -n default

# 5. Install Kyverno v1.18.0 and apply a policy blocking privileged pods
helm repo add kyverno https://kyverno.github.io/kyverno/
helm install kyverno kyverno/kyverno --version 3.2.0 -n kyverno --create-namespace \
  --kube-context kind-ch13
# Apply a Kyverno policy to block privileged pods - see Kyverno docs for policy YAML
```

**RE-BASELINE:**

```bash
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/v0.15.4/job.yaml
kubectl logs job.batch/kube-bench | grep FAIL
# Previously failing controls should now pass

kubescape scan framework nsa --context kind-ch13
# Score should improve
```

### Expected output

After hardening, attempting to schedule the privileged pod returns:

```
Error from server: pods "priv-pod" is forbidden: violates PodSecurity "restricted:latest"
```

### Verify completion

Re-run the attack steps: each one is refused. kube-bench and kubescape scores improve compared to the baseline run.

### Cleanup

```bash
kind delete cluster --name ch13
```

References:
- kube-bench: https://github.com/aquasecurity/kube-bench
- kubescape: https://github.com/kubescape/kubescape
- Kyverno: https://kyverno.io/docs/
- Note on legacy tools: kube-hunter (last release v0.6.8, 2022, dormant repo) and kubeaudit (archived by Shopify, last release v0.22.2) are referenced in older tutorials but should not be used for real findings; use kube-bench and kubescape instead.

---

## Lab 13.4: Runtime detection with Falco (cross-ref s13.8.2)

**Host setup (not containerised)**

Falco requires the eBPF modern probe which needs privileged kernel access to the KIND node. Deploying Falco via Helm inside the KIND cluster is the correct approach; nesting it in a separate Docker container does not provide the syscall visibility the lab requires.

**Authorisation:** run only against your own local KIND cluster. Do not point Falco rules-testing at infrastructure you do not own.

### Setup

```bash
# Ensure KIND cluster is running
kind create cluster --name ch13 --image kindest/node:v1.36.0

# Add Falco Helm repo
helm repo add falcosecurity https://falcosecurity.github.io/charts
helm repo update
```

### Walkthrough

```bash
# 1. Install Falco 0.43.1 with modern eBPF driver
helm install falco falcosecurity/falco \
  --namespace falco --create-namespace \
  --kube-context kind-ch13 \
  --set driver.kind=modern_ebpf \
  --version 4.11.0

# Note: pin chart version 4.11.0 which ships Falco 0.43.1.
# Falco 0.44.0 and 0.44.1 are post-access-date; use 0.43.1 for reproducibility.

# 2. Verify Falco DaemonSet is running
kubectl --context kind-ch13 get pods -n falco
# Expected: falco-XXXXX   2/2   Running

# 3. Generate triggering behaviours in a test pod
kubectl --context kind-ch13 run testpod --image=ubuntu:24.04 \
  --command -- sleep 3600
kubectl --context kind-ch13 wait --for=condition=ready pod/testpod

# Trigger: spawn an interactive shell in the pod
kubectl --context kind-ch13 exec testpod -- /bin/sh -c "echo trigger"

# Trigger: read a sensitive file
kubectl --context kind-ch13 exec testpod -- cat /etc/shadow

# Trigger: write to a normally read-only path (create a file in /etc)
kubectl --context kind-ch13 exec testpod -- touch /etc/malicious-file

# 4. Read the Falco alerts
kubectl --context kind-ch13 logs -n falco -l app.kubernetes.io/name=falco --tail=50
# Look for: "Terminal shell in container", "Read sensitive file", "Write below etc"

# 5. TUNE: write a local rules exception to suppress noise from a benign component
# Edit lab5/falco_rules.local.yaml and apply it as a ConfigMap, then redeploy:
kubectl --context kind-ch13 create configmap falco-rules-local \
  --from-file=falco_rules.local.yaml=lab4/falco_rules.local.yaml \
  -n falco
helm upgrade falco falcosecurity/falco \
  --namespace falco \
  --kube-context kind-ch13 \
  --set driver.kind=modern_ebpf \
  --set-file customRules."falco_rules\.local\.yaml"=lab4/falco_rules.local.yaml \
  --version 4.11.0
```

### Expected output

Falco logs show alerts similar to:

```
{"output":"Shell spawned in a container (user=root ... proc_name=sh)","rule":"Terminal shell in container","priority":"NOTICE"}
{"output":"Sensitive file opened for reading (user=root file=/etc/shadow ...)","rule":"Read sensitive file untrusted","priority":"WARNING"}
```

After adding the local rules exception, the noisy benign alert disappears while the shell and sensitive-file alerts remain.

### Verify completion

```bash
kubectl --context kind-ch13 logs -n falco -l app.kubernetes.io/name=falco --tail=20 | grep -c "Terminal shell"
# Returns at least 1
```

### Cleanup

```bash
helm uninstall falco -n falco --kube-context kind-ch13
kind delete cluster --name ch13
```

References:
- Falco: https://falco.org/docs/
- Falco Helm chart: https://github.com/falcosecurity/charts

---

## Lab 13.5: CloudTrail breach investigation (network-free stub)

**Objective:** Reconstruct a Capital-One-shaped attack chain (ConsoleLogin from anomalous IP, AssumeRole into over-permissioned role, mass GetObject reads) from a local stub CloudTrail dataset. No live AWS calls.

### Setup

```bash
docker compose --profile lab5 up -d
docker exec -it ch13-cloudtrail-tools bash
```

### Walkthrough

**Using Python (automated):**

```bash
# Inside the container
cd /lab5
python3 investigate.py
```

**Using jq (manual, step by step):**

```bash
# All commands run inside the container at /lab5

# 1. Find ConsoleLogin from anomalous IP
jq '[.Records[] | select(.eventName == "ConsoleLogin")] | .[] | {time: .eventTime, ip: .sourceIPAddress, user: .userIdentity.userName}' data/cloudtrail-stub.json

# 2. Find AssumeRole event and the role assumed
jq '[.Records[] | select(.eventName == "AssumeRole")] | .[] | {time: .eventTime, ip: .sourceIPAddress, roleArn: .requestParameters.roleArn}' data/cloudtrail-stub.json

# 3. Find the mass-read burst (ListBuckets + GetObject)
jq '[.Records[] | select(.eventName == "ListBuckets" or .eventName == "GetObject")]' data/cloudtrail-stub.json

# 4. Count GetObject events and identify the exfiltration window
jq '[.Records[] | select(.eventName == "GetObject")] | length' data/cloudtrail-stub.json
jq '[.Records[] | select(.eventName == "GetObject")] | {start: .[0].eventTime, end: .[-1].eventTime, count: length}' data/cloudtrail-stub.json

# 5. Confirm all suspicious events share the same source IP
jq '[.Records[] | select(.eventName == "ListBuckets" or .eventName == "GetObject" or .eventName == "AssumeRole") | .sourceIPAddress] | unique' data/cloudtrail-stub.json
```

### Expected output

Running `python3 investigate.py` produces:

```
[*] Loaded 9 CloudTrail records from /lab5/data/cloudtrail-stub.json

=== Step 1: Login and role assumption chain ===
  2026-04-15T09:01:00Z  ConsoleLogin          principal=analyst-01                               ip=198.51.100.77
  2026-04-15T09:02:30Z  AssumeRole            principal=analyst-01                               ip=198.51.100.77

=== Step 2: AssumeRole into over-permissioned role ===
  Role ARN   : arn:aws:iam::123456789012:role/DataEngineering-OverPermissioned
  Source IP  : 198.51.100.77  <-- anomalous; all mass-read events share this IP

=== Step 3: Mass-read burst ===
  Events : 6 (Counter({'GetObject': 5, 'ListBuckets': 1}))
  IPs    : {'198.51.100.77'}
  Buckets: {'prod-customer-data', 'N/A'}
  Window : 2026-04-15T09:03:00Z -> 2026-04-15T09:03:14Z

=== FINDINGS ===
  Offending principal : analyst-01
  Assumed role        : arn:aws:iam::123456789012:role/DataEngineering-OverPermissioned
  Attacker IP         : 198.51.100.77
  Exfiltration window : 2026-04-15T09:03:00Z to 2026-04-15T09:03:14Z
  GetObject count     : 5
```

### Verify completion

`python3 /lab5/investigate.py` exits 0 and all four findings fields are non-empty.

### Cleanup

```bash
docker compose --profile lab5 down -v
```

---

## Lab 13.6: IaC hardening with Checkov, gated in CI (network-free)

**Objective:** Scan a deliberately vulnerable Terraform repository with Checkov 3.2.528, identify HIGH-severity findings (unencrypted S3, open SSH ingress, IMDSv1 enabled), remediate the HCL, and verify the scan exits 0.

### Setup

```bash
docker compose --profile lab6 up -d
docker logs ch13-checkov-tools
# The initial scan runs automatically and shows the failing checks
```

### Walkthrough

**Step 1 - Review the initial scan output:**

```bash
docker exec -it ch13-checkov-tools bash
checkov -d /lab6/terraform --compact
```

Look for failing checks including:
- `CKV_AWS_19` - S3 bucket server-side encryption not enabled
- `CKV_AWS_20` - S3 bucket ACL allows public read
- `CKV2_AWS_6` - S3 Block Public Access not enabled
- `CKV_AWS_24` - Security group allows unrestricted SSH (port 22)
- `CKV_AWS_8`  - IMDSv1 enabled (http_tokens not required)

**Step 2 - Verify the CI gate behaviour:**

```bash
# Exit code 1 means failed checks; CI pipeline would block here
checkov -d /lab6/terraform --hard-fail-on HIGH
echo "Exit code: $?"
```

**Step 3 - Remediate the HCL:**

Edit `/lab6/terraform/main.tf` (from the container or from the host, since the directory is bind-mounted). Apply these fixes:

```hcl
# Fix 1: Add S3 encryption
resource "aws_s3_bucket_server_side_encryption_configuration" "fix" {
  bucket = aws_s3_bucket.vuln_bucket.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "aws:kms"
    }
  }
}

# Fix 2: Enable Block Public Access
resource "aws_s3_bucket_public_access_block" "fix" {
  bucket                  = aws_s3_bucket.vuln_bucket.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# Fix 3: Change ACL from public-read to private
# In aws_s3_bucket_acl: acl = "private"

# Fix 4: Remove 0.0.0.0/0 from SSH ingress (restrict to known CIDR)
# In aws_security_group: cidr_blocks = ["10.0.0.0/8"]

# Fix 5: Require IMDSv2
# In metadata_options: http_tokens = "required", http_put_response_hop_limit = 1
```

A complete reference solution is provided in `lab6/terraform/main-fixed.tf.example`.

**Step 4 - Re-scan and verify exit 0:**

```bash
checkov -d /lab6/terraform --compact --hard-fail-on HIGH
echo "Exit code: $?"
# Expected: 0 (pipeline green)
```

**CI gate (GitHub Actions):**

The file `lab6/.github/workflows/checkov.yml` shows how to wire this check into a GitHub Actions pipeline so a bad Terraform change cannot merge.

### Expected output

Before remediation (initial scan):

```
Check: CKV_AWS_19: "Ensure all data stored in the S3 bucket is securely encrypted at rest"
  FAILED for resource: aws_s3_bucket.vuln_bucket
Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"
  FAILED for resource: aws_security_group.vuln_sg
...
Passed checks: X, Failed checks: Y, Skipped checks: 0
```

After remediation, `checkov -d /lab6/terraform --hard-fail-on HIGH` exits 0.

### Verify completion

```bash
checkov -d /lab6/terraform --hard-fail-on HIGH; echo "Exit: $?"
# Prints: Exit: 0
```

### Cleanup

```bash
docker compose --profile lab6 down -v
```

---

## Teardown (all labs)

```bash
docker compose down -v
kind delete cluster --name ch13
```

---

## Safety / Authorisation

All offensive steps in this chapter (Labs 13.2, 13.3) are designed to run against local sandbox infrastructure only: a LocalStack container on localhost or a KIND cluster on your own machine. The following commands must never be run against infrastructure you do not own:

- `awslocal s3 cp` / `awslocal s3api get-bucket-policy` (Lab 13.2 attack phase)
- `kubectl exec` with a privileged pod (Lab 13.3 attack phase)
- `kubectl auth can-i --list --as=...` (Lab 13.3 attack phase)
- `kube-bench`, `kubescape` (Lab 13.3 baseline/re-baseline)
- Falco trigger commands (Lab 13.4)

Running these tools against any cluster, bucket, or account you do not own and have written authorisation to test is unauthorised access. The lab infrastructure provided here is deliberately isolated to your local machine for exactly this reason.
