Hands-On Labs · The Capstone · Part 1 of 7

Part 1 — Threat Model the App

This is the deep version of Secure a Pipeline's first stage. You'll take Vulnerly — the small, deliberately vulnerable Node.js/Postgres sample app the whole capstone locks down — and produce a real STRIDE threat model of it with OWASP Threat Dragon before a single scanner runs. No tool touches Vulnerly's code in this part. What you leave with is a committed file: security/threat-model.json, with at least five ranked threats and three of them chained into full attack paths, each pointing at the exact capstone part that will close it. If you haven't met Vulnerly yet, skim the capstone hub first — this page assumes you know the shape of what you're about to draw.

⚠ Where you're arriving from, and where you're headed

Arriving: nothing. No repo, no running cluster — Part 1 doesn't need kind at all; the cluster from the hub doesn't get created until Part 4. Leaving this page: a pushed vulnerly repo holding the seeded starter app, and a committed security/threat-model.json whose findings already name the SQL-injection and IDOR paths you'll fix later, plus three attack paths written up as chained scenarios, each mapped to a specific tool and a specific later part. Part 2 picks up exactly here and closes the first of those three.

☺ Explain it like I'm 10

Before you patch a hole in a fence, you walk the whole fence line first and mark every gap with chalk — otherwise you fix the one hole you tripped over and never learn about the other three. Today you walk Vulnerly's fence line: draw the whole yard, mark every gap you can see, write down which ones a fox could actually use to get in, and — this is the part people skip — you leave the chalk marks somewhere permanent instead of just remembering them. A memory fades by next week. A marked-up map, committed to git, doesn't.

🦝🦉Your hosts for this part: Rocky the Raccoon & Professor Owl — Rocky pries at Vulnerly's diagram until he finds the boundary nobody drew, and Professor Owl makes sure every gap Rocky finds gets a home in a real Threat Dragon model, not just a hallway conversation.

What this part assumes, and the world every remaining part shares

☺ Like you're 10: Just a laptop, git, and a free account somewhere to draw on — nothing built yet, and nothing running yet.

You need git, Node.js (LTS) with npm, a text editor, and a GitHub account to push into (GitLab works identically — adjust the commands). You do not need Docker, kind, or any cloud account for this part specifically — Vulnerly's Terraform and Kubernetes manifests exist in the repo from day one, but nothing gets provisioned or deployed until Parts 4 and 5. That's deliberate, not an oversight: this is the one capstone part that's pure design-time work, the same "shift-left" argument What is DevSecOps? and Threat modeling both make — the cheapest place to find the SQL-injection path is a diagram, not a penetration test against a running service.

Here is the whole world this capstone shares, named once so nothing surprises you in a later part:

ThingNameIntroduced
The appVulnerly — a Node.js/Express service backed by Postgres, shaped like Ledgerly's payments-reconciliation API from the case study, seeded with real bugs on purposePart 1 — this page (scaffold)
The repovulnerly, at github.com/<you>/vulnerlyPart 1
Threat model filesecurity/threat-model.json — the OWASP Threat Dragon exportPart 1
Local clusterkind create cluster --name vulnerly-dev, namespaces vulnerly + platformPart 4 (not needed yet)
CI/CDGitHub Actions, .github/workflows/ci.yml — zero required checks todayPart 1 (stub) → Parts 2-6 each add one
◆ Key idea

Every other capstone part starts by installing a control. This one starts by finding out which controls are worth installing, and in what order. Skip it and you'll still eventually find the SQL injection and the IDOR — Part 2's gate and Part 6's scan will surface them either way — but you'll find them the way Ledgerly found its own leaked key: by accident, after the fact, from whichever tool happened to trip over it first.

Standing up the Vulnerly scaffold

☺ Like you're 10: Before you can draw a map of the yard, the yard actually has to exist — so first you build a small, deliberately messy one.

Vulnerly isn't a hypothetical for this part — it's a real, if minimal, app you push to a real repo, because Threat Dragon needs an actual architecture to model and Parts 2 through 6 need actual files to scan, fix, and re-scan. What follows is the seed: enough real code to be genuinely vulnerable in the specific ways the hub named, not a production-grade app. Create the repo and lay down this structure:

mkdir vulnerly && cd vulnerly
git init -b main
mkdir -p app/src infra .github/workflows
// app/src/index.js — Vulnerly's whole API, seeded on purpose
const express = require("express");
const { Pool } = require("pg");
require("dotenv").config();

const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

// GET /reconciliation/search?merchant=foo
// (1) string-concatenated SQL — classic injection point
// (2) the search term is echoed back into HTML, unescaped — reflected XSS
app.get("/reconciliation/search", async (req, res) => {
  const term = req.query.merchant || "";
  const sql = `SELECT * FROM transactions WHERE merchant_name LIKE '%${term}%'`;
  const { rows } = await pool.query(sql);
  res.send(`<h1>Results for ${term}</h1>` + JSON.stringify(rows));
});

// GET /reconciliation/:id
// no check that the caller's own merchant_id owns this record — IDOR:
// increment the id, read anyone's reconciliation data
app.get("/reconciliation/:id", async (req, res) => {
  const { rows } = await pool.query(
    "SELECT * FROM transactions WHERE id = $1", [req.params.id]
  );
  res.json(rows[0] || {});
});

app.listen(3000, () => console.log("vulnerly-api listening on 3000"));
# app/.env — committed on purpose; a real-looking, revoked-in-advance key
DATABASE_URL=postgres://vulnerly:vulnerly@localhost:5432/vulnerly
PAYMENTS_PROCESSOR_KEY=sk_live_51NxVulnerlyDemoKeyDoNotUseAnywhere0000
{
  "name": "vulnerly-api",
  "dependencies": {
    "express": "^4.19.2",
    "pg": "^8.11.5",
    "dotenv": "^16.4.5",
    "lodash": "4.17.15",
    "jsonwebtoken": "8.5.1"
  }
}
// app/package.json — lodash and jsonwebtoken pinned old on purpose;
// both version lines carry real, publicly documented critical CVEs.
// Part 3's SCA gate is what's supposed to catch this.
# Dockerfile — unpinned base, root, no multi-stage. Part 4 rewrites this.
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "app/src/index.js"]
# infra/main.tf — Part 5 fixes both of these
resource "aws_s3_bucket" "exports" {
  bucket = "vulnerly-transaction-exports"
  acl    = "public-read"                # transaction-export logs, world-readable
}

resource "aws_security_group" "db" {
  name = "vulnerly-db-sg"
  ingress {
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]         # Postgres reachable from the whole internet
  }
}
# .github/workflows/ci.yml — a pipeline that runs and reports nothing. Parts 2-6 each add a required check.
cat > .github/workflows/ci.yml <<'YAML'
name: ci
on: [pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: cd app && npm install
YAML

git add -A
git commit -m "Seed Vulnerly: the deliberately vulnerable capstone starter"
git remote add origin https://github.com/<you>/vulnerly.git
git push -u origin main

That commit is your baseline. Every later part in this capstone diffs against it — Part 2 rewrites the query and moves the key out, Part 3 bumps lodash and jsonwebtoken, Part 4 rewrites the Dockerfile, Part 5 fixes main.tf, Part 6 confirms the XSS and IDOR are gone from the outside. None of that happens today. Today you look at exactly this code and draw what it actually does, not what a tidier version of it would do.

Vulnerly's architecture, and where the one trust boundary sits

☺ Like you're 10: Four things, one arrow diagram, and a single dashed line marking the one spot a stranger can reach in from outside.

Strip away the seeded bugs for a moment and Vulnerly's runtime shape is small: a merchant's browser calls the API to search transactions or fetch a single reconciliation record; the API queries Postgres directly using the code above; and a scheduled job on the API exports transaction logs into an S3 bucket, the same export path whose Terraform-defined bucket policy Ledgerly's own incident review flagged as a real, if secondary, gap. Four elements, one trust boundary — exactly where the capstone hub said to draw it: the line where the public internet meets the API. Everything to the right of that line is Vulnerly's own environment; everything to the left is a browser you don't control, run by a person you haven't authenticated yet when the request first arrives.

trust boundary public internet Vulnerly's environment (trusted) Merchant (browser) Vulnerly API Express · Postgres client Postgres transactions, merchants S3 export job transaction-export logs HTTP: search / fetch record SQL query (string-concat) scheduled export Only one arrow crosses the dashed line — that crossing is where Part 1's STRIDE pass concentrates.

Two things worth naming before you open Threat Dragon. First, this is the runtime diagram — Vulnerly's delivery pipeline (GitHub Actions → registry → the cluster Part 4 stands up) is a second, separate trust boundary, one a browser never crosses but a compromised CI credential does. Threat Dragon supports multiple diagrams inside one model file; add a second, lighter one for the pipeline rather than cramming both concerns onto a single canvas — the findings table below covers both. Second, notice what's not drawn: no authentication step sits between the merchant and the API. That's not an omission on your part — it's an accurate reflection of Vulnerly's actual code above, and it's itself a finding, not a diagram bug.

Installing OWASP Threat Dragon

☺ Like you're 10: A free drawing tool built specifically for this exercise — pick whichever version means you spend your time threat modeling, not fighting the installer.

OWASP Threat Dragon is a free, open-source threat-modeling tool that gives you a DFD canvas with the same four shapes covered on the threat modeling page — external entity, process, data store, trust boundary — plus a per-element threat editor that pre-fills a short list of likely STRIDE categories based on the shape you clicked (a Process gets all six; a Data Store gets a narrower default set). Three ways to run it, in order of least setup:

Whichever you pick, create a new, empty threat model named "Vulnerly" and confirm you're looking at a blank canvas before going further.

Drawing the DFD and running the STRIDE pass

☺ Like you're 10: Draw the four shapes, draw the one dashed line, then ask all six STRIDE questions about everything that touches that line — and about everything that doesn't, too.

Add the four elements from the diagram above to the canvas — Merchant (browser) as an external entity, Vulnerly API as a process, Postgres and S3 export job as data stores — connect them with the same three flows, and draw the trust boundary exactly where it sits above. Then open each element's threat editor and work through Threat Dragon's per-element suggestions one at a time. Treat the tool's pre-fill as a floor, not a ceiling: it tells you which STRIDE categories are usually worth asking about for a given shape, not the full extent of your obligation for this specific app — add a category by hand anywhere Vulnerly's actual code calls for it, the same warning the threat modeling page makes about STRIDE being a checklist for coverage, not a formula for precision.

Working the canvas element by element against the actual scaffold code above produces this table — nine ranked findings, comfortably past the hub's five-threat minimum, and the SQL-injection and IDOR rows are in it exactly as promised:

ElementType (Threat Dragon defaults)FindingSTRIDEDisposition — closes in
Vulnerly APIProcess (S T R I D E)Reconciliation search builds its SQL with string concatenation — a crafted merchant value changes the query itselfTamperingOpen — Part 2 (Semgrep)
API / delivery pipelineProcess (S T R I D E)A real-looking payments-processor key is committed in app/.envInfo. disclosureOpen — Part 2 (gitleaks + Vault)
Vulnerly APIProcess (S T R I D E)/reconciliation/:id has no ownership check — incrementing the id returns another merchant's record (IDOR)Elevation of privilegeOpen — Part 6 (ZAP)
Vulnerly APIProcess (S T R I D E)The search term is reflected into the HTML response unescaped (reflected XSS)TamperingOpen — Part 6 (ZAP)
API buildProcess (S T R I D E)lodash@4.17.15 and jsonwebtoken@8.5.1 are pinned old, each with real, publicly documented critical CVEs, reachable through normal request handlingTamperingOpen — Part 3 (Trivy)
Delivery pipelineProcess (S T R I D E)Dockerfile pulls an unpinned node:18 tag, never drops root, and the resulting image is never signed — nothing distinguishes a real CI build from any other pushTampering / SpoofingOpen — Part 4 (cosign + Kyverno)
S3 export jobData store (T R I D)The Terraform-provisioned export bucket has a public-read ACLInfo. disclosureOpen — Part 5 (Checkov + OPA)
PostgresData store (T R I D)Security group open to 0.0.0.0/0 on the database port — a path into Postgres that skips the API, and its checks, entirelyTampering / Info. disclosureOpen — Part 5 (Checkov + OPA)
Merchant (browser)External entity (S R)No centralized findings, evidence trail, or alerting — even once Parts 2-6 fix six real bugs, nothing proves any of it happened or stays fixedRepudiationOpen — Part 7 (DefectDojo + InSpec + Wazuh)
⚠ Every row needs a disposition, not just a description

"SQL injection possible on the search endpoint" is an observation. "Open, closed by Part 2's Semgrep gate" is a disposition. The threat modeling page makes this point in general; here it's concrete — a threat entry in Threat Dragon with an empty status field is exactly the false sense of coverage that page warns about, and it's the first thing a reviewer (or your own Part 7 self, reading this file five parts later) should be able to check for at a glance.

The top three attack paths

☺ Like you're 10: The table above is nine separate gaps in the fence. This part is picking the three worst ones and walking through exactly how a fox would actually use each — start to finish, not just "there's a gap here."

Nine ranked findings is the breadth pass — STRIDE over every element, the same sweep the threat modeling page covers. What earns the name "attack path" is the depth pass: picking a small number of findings worth walking end-to-end, as a chain an actual attacker would follow, using the same attack-tree thinking that page introduces as STRIDE's complement. These three are the ones worth that treatment, because each maps to exactly one specific control the very next capstone parts implement.

Attack path 1 — a leaked key and a SQL injection are two doors into the same room

Step one: an attacker with read access to the vulnerly repo — a public fork, a leaked clone, a former contractor's stale access — finds PAYMENTS_PROCESSOR_KEY sitting in plain text in app/.env, committed on the very first push. Step two, and this is the part that makes the finding worse than a simple leaked-key story: the attacker doesn't even need that key. The reconciliation search endpoint's string-concatenated query is a classic injection point — a crafted merchant parameter turns LIKE '%...%' into an arbitrary UNION SELECT, pulling every row out of transactions without ever touching the leaked secret. Step three: either path lands on the same table — merchant names, transaction volumes, and reconciliation data — the identical shape of exposure as the real incident in the Ledgerly case study, except this time closing only one of the two doors leaves the other one wide open.

Control landing in Part 2: gitleaks (or TruffleHog) as a required pre-merge check catches the committed key before it ever reaches a shared branch, and the key itself moves out of .env into HashiCorp Vault as a runtime-fetched credential. Semgrep as a second required check flags the string-concatenated query pattern; the fix is a parameterized query, not a rewritten error message.

Attack path 2 — a five-year-old dependency CVE is a second way into the same process

Vulnerly's package.json pins lodash@4.17.15 and jsonwebtoken@8.5.1 — both real version lines with publicly documented critical-severity advisories (verify the exact CVE identifiers and the affected code paths against the current NVD or GitHub Advisory Database entries before you write them into a real report; version-to-CVE mappings are exactly the kind of detail that's worth re-checking rather than trusting from memory). Either dependency sitting in the require graph of a route that handles untrusted input is a live exploitation surface, not dead weight — a vulnerable transitive dependency doesn't announce itself just because nobody imports it directly by name. Whatever the specific primitive turns out to be — prototype pollution, an authentication-bypass class of bug in token verification — the pod it runs in is the same pod holding the live Postgres connection and, until Part 2 fixes it, the payments key. One exploited dependency is not a contained foothold; it's standing where the API already stands.

Control landing in Part 3: an SCA gate (Trivy, or Snyk/OWASP Dependency-Check) blocks any build where a direct or transitive dependency carries a critical or high-severity CVE with an available fix, backed by a Syft-generated SBOM so the next CVE announcement can be checked against exactly what's deployed in minutes, not by re-scanning everything from scratch.

Attack path 3 — an unpinned, unsigned image turns one compromise into every compromise

Nothing in Vulnerly's current pipeline distinguishes an image GitHub Actions legitimately built from one pushed by anyone else holding registry credentials — no signature, no attestation, no admission check either. Chain that with attack path 1: a leaked credential with registry scope, or a CI runner compromised through attack path 2's RCE, can swap the image the cluster is watching for a backdoored one, and because the Dockerfile never drops root, whatever runs inside that container runs with more reach than the app itself ever needed. Kubernetes pulls whatever the tag currently resolves to and runs it with the API's real permissions — no step in the deploy path would have refused an image nobody can vouch for.

Control landing in Part 4: a rewritten, pinned, non-root, multi-stage Dockerfile, signed keylessly with cosign/Sigstore at build time, enforced by a Kyverno admission policy in the vulnerly namespace that rejects any image without a signature it can verify against Rekor's transparency log — an image nobody can vouch for never gets scheduled.

◆ Key idea

All three paths above eventually reach the same place: the API's own database connection and payments key. That's not a coincidence worth being alarmed by — it's what happens whenever a single process holds every credential the whole system needs. It's also exactly why the three controls landing in Parts 2 through 4 matter more together than any one does alone: closing the SQL injection doesn't help if the dependency CVE gets there anyway, and neither helps if an unsigned image can just replace the fixed one wholesale.

Committing the threat model

☺ Like you're 10: A model that only lives inside one browser tab, on one laptop, is worth exactly as much as a photo of a whiteboard nobody saved — it disappears the next time something gets cleared.

Export the model. Threat Dragon's file menu produces a JSON document; if you connected a GitHub provider instead of working from local storage, it can commit the file directly into a repo you've linked. Either path should end with the same result: a real file, with real content, in vulnerly. The shape looks roughly like this — treat it as the shape, not a byte-for-byte schema, since field names have shifted slightly across Threat Dragon's major versions; let the tool's own export be the source of truth, not this snippet:

{
  "version": "2.4.x",
  "summary": {
    "title": "Vulnerly — Part 1 threat model",
    "owner": "you",
    "description": "STRIDE pass over Vulnerly: browser, API, Postgres, S3 export job, plus the delivery pipeline."
  },
  "detail": {
    "contributors": [{ "name": "your-name" }],
    "diagrams": [
      {
        "title": "Vulnerly runtime architecture",
        "diagramType": "STRIDE",
        "cells": [
          {
            "id": "vulnerly-api",
            "shape": "process",
            "data": {
              "type": "tm.Process",
              "name": "Vulnerly API",
              "threats": [
                {
                  "title": "SQL injection via string-concatenated query",
                  "type": "Tampering",
                  "status": "Open",
                  "severity": "High",
                  "mitigation": "Part 2: parameterized query, Semgrep required check"
                },
                {
                  "title": "IDOR on /reconciliation/:id",
                  "type": "Elevation of privilege",
                  "status": "Open",
                  "severity": "High",
                  "mitigation": "Part 6: ownership check, verified by ZAP re-scan"
                }
              ]
            }
          }
        ]
      }
    ]
  }
}
mkdir -p security
mv ~/Downloads/vulnerly-threat-model.json security/threat-model.json

git add security/threat-model.json
git commit -m "Part 1: STRIDE threat model for Vulnerly (OWASP Threat Dragon)"
git push
⚠ Don't let "we talked through STRIDE" stand in for the file

A threat model that exists only in Threat Dragon's local browser storage, on one laptop, that no one else on the team can open, has the durability of a photo of a whiteboard — gone the next time someone clears site data or reimages a machine. The done-when for this part is specifically a real, diffable, committed security/threat-model.json, because a committed file is the only version of "we did threat modeling" that survives a new hire joining the team, an auditor asking to see it eleven months from now, or you yourself forgetting the details of a meeting from three sprints ago. If git log -- security/threat-model.json comes up empty, this part isn't finished, no matter how thorough the conversation was.

What "done" looks like for Part 1

☺ Like you're 10: A pushed repo, a real diagram, nine written-down gaps, three of them walked start to finish, and all of it saved somewhere permanent.

At the end of this part: a pushed vulnerly repo holding the seeded scaffold above, a committed security/threat-model.json with at least the nine findings in the table — including the SQL-injection and IDOR rows the hub's own done-when calls out by name — every one carrying an explicit disposition, and three of those findings written up as full attack paths, each pointing at the specific tool and part that closes it. Nothing here is thrown away. Part 2 opens by reading this exact file, wiring gitleaks and Semgrep into the ci.yml stub above as required checks, and closing attack path 1 for real — not by rotating the leaked key, but by fixing both doors into that room.

🎬 At the Shift-Left Squad
🦝

Rocky: Gave myself twenty minutes with the diagram before I even opened the code. Found the missing auth step on the trust boundary crossing before I found anything in index.js.

🦉

Professor Owl: Which is the point of drawing it first. The diagram tells you where to look before you've read a single line.

🦊

Foxy: Nine findings, but only three made it into a full attack path. How did you pick?

🦝

Rocky: The ones with a next stage already lined up to close them. A finding with nowhere to land is just a worry. These three have a Part number.

🐢

Timmy: And I don't gate anything until this file exists. No committed model, no SAST rollout — Part 2 doesn't start from nothing, it starts from what Rocky wrote down.

🦉

Professor Owl: Which is the whole sequence, laid out once: find it on paper, write it down, then go build the thing that closes it. Meet us here again once Part 2's gate is live.

Milestones

☺ Like you're 10: Tick a box only once you've actually watched it happen on your own screen — a step that "sounds right" isn't the same as one you've verified.

Work these in order. Progress saves in this browser.

0 / 12 milestones complete
1Create the vulnerly repo and push the seeded scaffold
Create app/src/index.js, app/.env, app/package.json, Dockerfile, infra/main.tf, and the ci.yml stub exactly as shown above, then push to main.
Done when: the repo on GitHub shows all six files, and git log shows the seed commit.
2Read the scaffold like an attacker would, before any tool runs
Open app/src/index.js, app/.env, package.json, Dockerfile, and infra/main.tf and list every weakness you can spot by eye.
Done when: your list independently includes the SQL string-concat, the IDOR, the committed key, and at least one of the container or Terraform issues — before you check the findings table on this page.
3Install or open OWASP Threat Dragon
Use the hosted web app, a self-hosted Docker container, or the desktop release — whichever gets you to a blank canvas fastest.
Done when: you can create a new, empty threat model and see the drawing surface.
4Draw the four-element DFD
Add Merchant (browser), Vulnerly API, Postgres, and S3 export job, connected by the three flows in the diagram above.
Done when: the canvas shows all four elements and all three flows, matching the schematic on this page.
5Mark the trust boundary
Draw the boundary line between the Merchant browser and the API — the one place a request from outside the environment enters.
Done when: exactly one crossing exists on the canvas, and it's the browser-to-API flow.
6Run the STRIDE pass on every element
Open each element's threat editor, work through Threat Dragon's pre-filled categories, and add any category the tool didn't suggest that Vulnerly's actual code calls for.
Done when: every one of the four elements has at least one threat entry — none are left at zero.
Concept: STRIDE
7Confirm the SQL-injection and IDOR threats both appear
Check your model against the findings table on this page — both must be present as their own ranked entries, not folded into a vaguer catch-all.
Done when: you can point at the exact threat entry for each, by name, in your own model.
8Give every threat an explicit disposition
Set each threat's status — Open (with the capstone part that closes it), Mitigated, Accepted, or Deferred — none left blank.
Done when: no threat entry in your model has an empty status field.
9Write up the three chained attack paths
Pick three findings worth walking end-to-end and write each as an ordered, multi-step scenario — not a single bullet.
Done when: each of your three paths reads as a sentence with a beginning, a middle, and an end, the way the three on this page do.
Concept: Attack trees
10Map each attack path to the specific control that closes it
Write down, next to each path, the exact tool and capstone part responsible — not just "this needs fixing."
Done when: all three paths name a specific part number and a specific tool.
11Export and commit security/threat-model.json
Export the model from Threat Dragon, move it into security/threat-model.json, commit, and push.
Done when: git log -- security/threat-model.json shows the commit, and opening the file shows real, non-empty threats arrays, not an empty shell.
12Say out loud what Part 2 inherits
Confirm: vulnerly is pushed, security/threat-model.json is committed, and you know which attack path Part 2 closes first and why.
Done when: you can describe this state without looking anything up — it's exactly the starting point Part 2 assumes.
✓ Checkpoint

1. Why does this capstone start with a diagram and zero running infrastructure, rather than a cluster the way a build-focused capstone might? 2. Where is Vulnerly's one trust boundary, and what's the single flow that crosses it? 3. Name the three attack paths from this page and the capstone part each one's control lands in. 4. What makes a committed security/threat-model.json the actual done-when for this part, rather than "the team discussed STRIDE in a meeting"?

Check your answers
  1. Because a design-time flaw is the cheapest one to fix — the same shift-left argument made throughout this course. Threat modeling needs nothing running to be useful; SAST, SCA, container signing, IaC scanning, and DAST all need real code, a real pipeline, or a real deployment to act on, which is why the cluster in this capstone isn't created until Part 4.
  2. The trust boundary sits between the Merchant's browser and the Vulnerly API — the only place data crosses from an untrusted, unauthenticated caller into Vulnerly's own environment. The one flow that crosses it is the merchant's HTTP request to search transactions or fetch a reconciliation record; Postgres and the S3 export job sit entirely inside the trusted zone.
  3. Attack path 1 (leaked key + SQL injection, two doors into the same data) closes in Part 2 with gitleaks/Semgrep and a move to Vault. Attack path 2 (a known-CVE dependency reachable through normal request handling) closes in Part 3 with an SCA gate (Trivy) and an SBOM. Attack path 3 (an unpinned, unsigned, root container image) closes in Part 4 with cosign signing and a Kyverno admission policy.
  4. A model that lives only in one browser's local storage disappears the moment that storage is cleared or the laptop is reimaged, and no one else on the team can open it. A committed file in git is diffable, reviewable in a pull request, and survives a new hire, an audit request months later, or the original author simply forgetting the details — which is exactly the kind of durable, checkable artifact "done" has to mean for a security control, not a conversation that happened once.

Part 1 leaves you with a pushed vulnerly repo and a real, committed threat model — nine ranked findings, three of them walked as full attack paths, each pointing at the exact tool that closes it. Continue to Capstone Part 2 — Wire In SAST & Secrets Scanning, where attack path 1 gets closed for real. Or step back to the full capstone hub to see how this part fits the other six, and revisit Threat modeling and the Ledgerly case study for the concepts behind what you just built.