# Chapter 14 Labs: Secure Programming and Scripting

This lab pack covers the seven hands-on exercises in Chapter 14: from writing
a custom SAST rule, through image hardening and CI pipeline gating, to SBOM
generation, secrets scanning, and AI-pair code review. Five labs run fully
inside Docker Compose; two require host-side tooling (GitHub Actions or a
coding assistant) and are documented as host-setup steps below.

## Prerequisites

- Docker Desktop (or Docker Engine + Compose plugin) - version 24+ recommended
- Disk space: approximately 3 GB for the tool images on first pull
- Git (for the secrets-sweep lab)
- For host-setup labs: a GitHub account, access to a coding assistant

## Quick Start

```bash
# Bring up the tools container (installs semgrep, trivy, syft, grype, gitleaks, trufflehog)
docker compose up -d tools

# Open a shell inside the tools container
docker compose exec tools bash

# All lab files are mounted at /lab inside the container
ls /lab
```

The first `up` installs all CLI tools inside the container - allow about two
minutes. Subsequent runs reuse the same container.

---

## Lab 14.2.5 - Author a Custom Auth-Bypass Rule in Semgrep

**Objective:** Write and test a Semgrep rule that flags any Flask route handler
missing the `@require_role` decorator - a custom auth-bypass pattern no
off-the-shelf rule would ever know about.

**Setup:** Tools container only. No network connectivity needed.

**Walkthrough:**

1. Start the tools container and open a shell:

   ```bash
   docker compose up -d tools
   docker compose exec tools bash
   ```

2. Confirm Semgrep is installed:

   ```bash
   semgrep --version
   ```

3. Inspect the target file (`/lab/targets/fat-app/app.py`). It has two route
   handlers: `/admin` (correctly decorated with `@require_role("admin")`) and
   `/reports` (missing the decorator). The `/reports` handler is the bug your
   rule must catch.

4. The rule is pre-written at `/lab/semgrep/auth-check.yml`. Read it - notice
   how `pattern` matches any `@app.route` handler and `pattern-not` subtracts
   the safe form (one that also carries `@require_role`).

5. Run the scan:

   ```bash
   semgrep --config /lab/semgrep/auth-check.yml /lab/targets/fat-app/app.py
   ```

6. Confirm exactly one finding is reported (on `/reports`), with none on `/admin`.

7. Prove it both ways: add `@require_role("user")` above `@app.route("/reports")`
   in `app.py` and re-run - the finding should disappear. Then add a third
   undecorated route and re-run - it should appear.

8. Wire into CI: copy `semgrep/auth-check.yml` to `.semgrep/` in a real project
   and add `semgrep ci` to your GitHub Actions security workflow (see
   `ci-example/security.yml`).

**Expected Output:**

```
Findings:
  /lab/targets/fat-app/app.py
  flask-route-missing-require-role (ERROR)
    Flask route handler 'reports' is missing the @require_role decorator.
    ...
    1 finding
```

**Verify Completion:** `semgrep` exits with code 1 (findings present) when
`/reports` lacks the decorator, and exits 0 when all routes are decorated.

**Cleanup:** `docker compose stop tools`

---

## Lab 14.4a - Harden a Docker Image: Fat to Under 80 MB, Scan with Trivy, Sign with Cosign

**Objective:** Take a full-base Python image (>1 GB), rewrite it as a
distroless multi-stage build (<80 MB), and observe how the Trivy CVE count
collapses. Optionally sign the slim image with cosign (keyless Sigstore flow).

**Setup:** Docker host + tools container. Builds images locally - no push needed.

**Walkthrough:**

1. Build the fat baseline image (outside the tools container, on your Docker host):

   ```bash
   docker build -f targets/fat-app/Dockerfile.fat \
     -t app:fat targets/fat-app/
   docker images app:fat
   ```

   Note the size - expect well over 1 GB.

2. Scan the fat image with Trivy (inside the tools container, or on the host
   if Trivy is installed):

   ```bash
   trivy image app:fat --severity HIGH,CRITICAL
   ```

   Record the HIGH/CRITICAL count. Most findings are in OS packages the
   application never calls.

3. Review the slim Dockerfile at `targets/slim-app/Dockerfile.slim`. It uses:
   - A `python:3.12-slim` builder stage to install deps into a venv
   - A `gcr.io/distroless/python3-debian12` runtime stage with no shell
   - `USER nonroot` (distroless uid 65532)

4. Build the slim image:

   ```bash
   docker build -f targets/slim-app/Dockerfile.slim \
     -t app:slim targets/slim-app/
   docker images app:slim
   ```

   Expected: under 80 MB.

5. Re-scan the slim image:

   ```bash
   trivy image app:slim --severity HIGH,CRITICAL
   ```

   The CVE count collapses. Residual findings are real and few - triage those.

6. (Stretch) Sign the slim image with cosign (keyless Sigstore flow). Requires
   a registry push and an OIDC identity (GitHub Actions or Google account):

   ```bash
   # Push first (substitute your registry)
   docker tag app:slim ghcr.io/<you>/app:slim
   docker push ghcr.io/<you>/app:slim

   # Sign (authenticates via browser OIDC, records in Rekor transparency log)
   cosign sign ghcr.io/<you>/app:slim

   # Verify
   cosign verify ghcr.io/<you>/app:slim \
     --certificate-identity-regexp=<your-oidc-subject> \
     --certificate-oidc-issuer=https://accounts.google.com
   ```

   Cosign v3.x is the current major line; pin the version your pipeline expects.

**Expected Output:** `app:slim` size under 80 MB; `trivy image app:slim` reports
significantly fewer CVEs than `app:fat`.

**Verify Completion:** `docker images app:slim --format "{{.Size}}"` shows under
80 MB; `trivy image app:slim` exits 0 on a clean build.

**Cleanup:**

```bash
docker rmi app:fat app:slim
docker compose stop tools
```

**Guard Rail:** Run `trivy image` only against images you built or are authorised
to analyse. Do not push to a registry you do not control.

---

## Lab 14.4b - Wire SAST + SCA + Container Scan into GitHub Actions

**Host Setup (not containerised)**

This lab is a CI pipeline exercise. It requires a GitHub repository you own and
cannot be automated inside a local Compose environment.

**Objective:** Create a `.github/workflows/security.yml` that runs Semgrep SAST,
Trivy filesystem SCA, and Trivy image scan on every pull request, configured to
block merges on new HIGH/CRITICAL findings.

**Canonical References:**

- GitHub Actions: https://docs.github.com/en/actions
- Semgrep GitHub Action: https://github.com/semgrep/semgrep-action
- Trivy GitHub Action: https://github.com/aquasecurity/trivy-action
- SARIF upload: https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/uploading-a-sarif-file-to-github

**Setup Steps:**

1. Fork or create a small repository you own (use `targets/fat-app/` as a
   starting point - it contains intentional flaws).

2. Copy `ci-example/security.yml` to `.github/workflows/security.yml` in the
   repository.

3. Set any required secrets in the repository settings:
   - `SEMGREP_APP_TOKEN` (obtain from semgrep.dev; optional for open-source rules)

4. Open a pull request that introduces a new vulnerable line (for example, add
   a second unparameterised SQL query to `app.py`). Watch the SAST job turn red.

5. Fix the line and watch the check go green.

**Gating Policy (baseline approach):** To avoid failing on pre-existing debt,
configure Semgrep with `--baseline-commit` and Trivy with a `.trivyignore` file
listing accepted pre-existing CVEs. The gate then measures only the delta a pull
request adds.

**Verify Completion:** A pull request that adds a vulnerable line fails the SAST
check; a clean pull request passes all three jobs.

---

## Lab 14.5.4 - SBOM Generation and Vulnerability Query (syft + grype)

**Objective:** Generate a CycloneDX SBOM from a container image using syft, scan
it with grype, then demonstrate that re-scanning an unchanged SBOM with an updated
vulnerability feed surfaces newly-disclosed CVEs.

**Setup:** Tools container + Docker socket access (to pull a public image).

**Walkthrough:**

1. Inside the tools container:

   ```bash
   docker compose exec tools bash
   ```

2. Confirm tool versions:

   ```bash
   syft version
   grype version
   ```

3. Generate a CycloneDX SBOM from a known image. Using `alpine:3.20` as a safe,
   small, public target:

   ```bash
   syft alpine:3.20 -o cyclonedx-json=/lab/alpine-sbom.json
   ```

4. Inspect the SBOM:

   ```bash
   jq '.components | length' /lab/alpine-sbom.json
   jq '.components[0]' /lab/alpine-sbom.json
   ```

   Each component has `name`, `version`, `purl` (Package URL) and often a hash.

5. Scan the SBOM with grype:

   ```bash
   grype sbom:/lab/alpine-sbom.json
   ```

   grype joins each component against its CVE database and prints a table:
   CVE ID, package, installed version, fixed version, severity.

6. Generate an SPDX version for comparison:

   ```bash
   syft alpine:3.20 -o spdx-json=/lab/alpine-sbom.spdx.json
   ```

   The same scan, different format. A downstream consumer can request either.

7. Prove the freshness point:

   ```bash
   grype db update
   grype sbom:/lab/alpine-sbom.json
   ```

   Any new rows are CVEs the world learned about since the previous scan. The
   SBOM did not change; the knowledge did. This is why SBOMs are re-queried, not
   generated once.

8. (Stretch) Gate in CI: add `grype sbom:sbom.json --fail-on high` to a GitHub
   Actions step (see `ci-example/security.yml` for the pattern).

**Expected Output:** A `alpine-sbom.json` file listing the Alpine packages;
`grype` produces a CVE table with package names, installed versions, and severity.

**Verify Completion:** `jq '.components | length' /lab/alpine-sbom.json` returns
a positive integer; `grype sbom:/lab/alpine-sbom.json` runs without error.

**Cleanup:** `rm -f /lab/alpine-sbom.json /lab/alpine-sbom.spdx.json`

**Authorisation Note:** Scan only images you own or are explicitly authorised to
analyse. Pulling `alpine:3.20` from Docker Hub is public and safe.

---

## Lab 14.5.6 - Secrets Sweep (gitleaks + trufflehog)

**Objective:** Sweep a seeded repository for hardcoded secrets using gitleaks
(history-aware regex + entropy) and trufflehog (verified live credentials).
Understand why a "deleted" secret in git history is still compromised.

**Setup:** Tools container + the `secrets-seed/` directory (seeded with synthetic
non-functional patterns).

**Walkthrough:**

1. Inside the tools container:

   ```bash
   docker compose exec tools bash
   ```

2. Confirm tool versions:

   ```bash
   gitleaks version
   trufflehog --version
   ```

3. Initialise a git repo in the seed directory (needed for gitleaks history mode):

   ```bash
   cd /lab/secrets-seed
   git init
   git add .
   git commit -m "initial commit with lab seed"
   ```

4. Run gitleaks across the working tree and full history:

   ```bash
   gitleaks detect --source /lab/secrets-seed \
     --report-format json \
     --report-path /lab/gitleaks-report.json
   cat /lab/gitleaks-report.json | jq '.[].RuleID'
   ```

   Note the rule IDs. Each match names the file, the commit hash, and the
   offending line. The synthetic AWS key and GitHub PAT patterns should match.

5. Simulate "deleting" a secret and show it persists in history:

   ```bash
   # Remove the synthetic credential from config.py
   sed -i '/GITHUB_TOKEN/d' /lab/secrets-seed/config.py
   git -C /lab/secrets-seed add config.py
   git -C /lab/secrets-seed commit -m "remove token"

   # Re-run gitleaks - it still finds the token in history
   gitleaks detect --source /lab/secrets-seed \
     --report-format json \
     --report-path /lab/gitleaks-report2.json
   jq '.[].Commit' /lab/gitleaks-report2.json
   ```

   The commit hash in the report is the *original* commit, not the deletion.
   The secret is still in history.

6. Run trufflehog with verification:

   ```bash
   trufflehog filesystem /lab/secrets-seed --only-verified
   ```

   Because the patterns in `config.py` are synthetic and non-functional,
   trufflehog's live-verification step will NOT confirm them (they are not real
   credentials). This demonstrates the difference between a raw regex hit (gitleaks)
   and a verified live credential (trufflehog). In a real sweep, a verified finding
   is a live credential that must be rotated immediately.

7. Triage and remediation (for real secrets, not this lab): rotate the credential
   first (the leaked value is burned), move it to a secrets manager, then optionally
   rewrite history with `git filter-repo` - but rotation, not history rewriting,
   is what closes the exposure.

8. (Stretch) Add gitleaks as a pre-commit hook:

   ```bash
   gitleaks protect --staged --source /lab/secrets-seed
   ```

**Expected Output:** gitleaks reports findings for the synthetic AWS key and GitHub
PAT patterns in `secrets-seed/config.py`; trufflehog reports no verified
findings (because the patterns are non-functional placeholders).

**Verify Completion:** `cat /lab/gitleaks-report.json | jq 'length'` returns a
positive integer (at least one finding).

**Cleanup:**

```bash
rm -f /lab/gitleaks-report.json /lab/gitleaks-report2.json
rm -rf /lab/secrets-seed/.git
docker compose stop tools
```

**Safety Note:** Every verified trufflehog finding is a live credential. Treat it
as a loaded weapon: rotate immediately, do not share the finding via unencrypted
channels, and follow your organisation's disclosure process if the repo is not yours.
Scan only repositories you own or are authorised to assess.

---

## Lab 14.6a - AI-Pair Review of a 500-Line AI-Generated Pull Request

**Host Setup (not containerised)**

This lab requires a coding assistant (any of GitHub Copilot, Claude, GPT-4o, or
similar) and a repository you own. It is a human-process exercise that cannot
be containerised.

**Objective:** Generate a large AI-written diff, apply automated security gates
before reading a single line, then conduct a structured human review and produce
a defensible merge decision.

**Walkthrough:**

1. Using your coding assistant, prompt it to build a realistic feature: a REST
   endpoint that queries a database, validates input, and writes a file. Ask for
   something large enough to produce roughly 500 lines across 3-4 files. Do not
   read the diff yet.

2. Before human review, run the automated gates on the diff:

   ```bash
   # SAST
   semgrep ci

   # SCA + secrets
   trivy fs --severity HIGH,CRITICAL .
   gitleaks detect --source .
   trufflehog filesystem . --only-verified

   # IaC (if the change touches Terraform or Kubernetes manifests)
   checkov -d .
   ```

   Record every finding. The tools are your first reviewer - they do not tire.

3. Verify every dependency exists and is intended. For each `import` or
   `require` / entry in `requirements.txt` or `package.json`:

   ```bash
   pip index versions <package-name>
   # or for npm:
   npm view <package-name> version
   ```

   Any package that does not resolve is a slopsquatting candidate. Stop and
   investigate before proceeding.

4. Now read the diff as if a hostile junior developer wrote it. For each
   section ask: is input validated? Are queries parameterised? Are secrets
   hardcoded? Are permissions least-privilege?

5. Map each finding back to STRIDE categories (section 14.1):
   - Missing auth check: Elevation of Privilege
   - Unparameterised query: Tampering
   - Logged secret: Information Disclosure

6. Write a short merge verdict (approve-with-changes, request-changes, or reject)
   citing the specific lines and gate output. The deliverable is the defensible
   decision, not the green tick. You, the merger, own this code.

**Guard Rail:** Run the generated code only in a disposable sandbox you control.
Never paste proprietary source into a public assistant. Never run generated
infrastructure code against a production account.

---

## Lab 14.6b - Slopsquatting Drill: Hallucinate a Package, then Gate Against It

**Host Setup (not containerised)**

This lab requires a coding assistant and a Python or JavaScript project you own.

**Objective:** Observe an LLM hallucinating a non-existent package name, understand
the slopsquatting attack surface that creates, then build a CI allow-list gate
that blocks it.

**Walkthrough:**

1. Ask your coding assistant to solve a niche task in Python (for example, parsing
   a custom binary file format, or implementing an obscure network protocol). Ask
   it to provide a complete `requirements.txt`. Do not install anything yet.

2. For each package name in the suggested `requirements.txt`, verify it exists:

   ```bash
   pip index versions <package-name>
   ```

   If `pip index versions` returns an error or empty result, the package does not
   exist on PyPI - it is a hallucinated name.

3. Note the attack: an attacker who enumerated this hallucinated name (Spracklen
   et al. 2025 found ~43% of hallucinations recur across generation runs) could
   register it on PyPI with malicious content. Do NOT register the name; the
   exercise is to see the gap.

4. Build the allow-list gate using the provided script:

   ```bash
   # Install your known-good dependencies into allowed-packages.txt first
   cp ci-example/allowed-packages.txt ./allowed-packages.txt
   # Edit allowed-packages.txt to list your project's approved packages

   # Run the gate against the AI-suggested requirements.txt
   bash ci-example/check-allowlist.sh requirements.txt allowed-packages.txt
   ```

   The gate exits non-zero if any package is not on the allow-list.

5. Require pinned versions with hashes to prevent silent republish attacks:

   ```bash
   pip install --require-hashes -r requirements.txt
   ```

6. Prove the gate blocks the hallucinated package: ensure the hallucinated name
   is NOT in `allowed-packages.txt` and run the gate - it should exit non-zero.
   Add the name to the allow-list (after confirming it is real) and re-run - it
   should pass.

7. Wire into CI: add `bash ci-example/check-allowlist.sh requirements.txt` as
   a step before `pip install` in your GitHub Actions workflow.

**Guard Rail:** Never publish a package under a hallucinated name to a public
registry, even as a test. That IS the attack. Run only against repositories
and registries you own.

---

## Teardown

Stop and remove all containers, networks, and anonymous volumes:

```bash
docker compose down -v
```

Remove locally built images:

```bash
docker rmi lab-ch14-fat-app:latest lab-ch14-slim-app:latest 2>/dev/null || true
```

---

## Safety and Authorisation

All offensive steps in this chapter (SAST scanning, image scanning, secrets
sweeping) must be run ONLY against:

- Code, images, and repositories you wrote or own
- Systems explicitly made available for lab practice
- Infrastructure you have written permission to test

The SAST target (`targets/fat-app/app.py`) and the secrets seed
(`secrets-seed/config.py`) are synthetic specimens built for this lab.
The synthetic credentials in `secrets-seed/config.py` are non-functional
placeholders - they grant access to nothing.

A verified trufflehog finding against a real repository is a live credential.
Handle it as a security incident: rotate immediately, do not share via
unencrypted channels, follow your organisation's disclosure policy.
