L71 · Capstone: threat-model your own system
Module 10: People, Governance, and Emerging Threats · ~25 min · Lesson 71 of 71
🎯 What you'll learn
- How to scope a service and draw its four paths in: customer, data, human, build
- How to walk STRIDE with a fill-in table, rate threats, and pick a top 5
- How to link each mitigation to the lesson that teaches it
- How to turn the results into tickets and a re-review trigger
🔥 Why this matters
You've met each attack on its own: BOLA at Optus (L44), stolen instance credentials at Capital One (L47), a poisoned build at SolarWinds (L56), a phished admin at Uber (L28). Real attackers take the cheapest path in, and a threat model shows every path at once. Today you write one for a service you work on, whatever it runs on, using L05's method.
1. Scope it and draw it
Pick one service you know well, with its stores, jobs and pipelines; a whole platform is too big. Write what's in scope (the parts you'll analyze) and out of scope (what you deliberately skip, such as the identity provider). Then draw all four ways in, not just the one you see daily:
1 CUSTOMER app ──▶ gateway ──▶ service ──▶ database
2 DATA upstream file ──▶ batch job ──▶ database
3 HUMAN engineer ──▶ SSO ──▶ cloud console, kubectl
4 BUILD merge ──▶ CI ──▶ registry ──▶ cluster
Paths 3 and 4 (L03's identity and supply-chain attack surface) skip the gateway and can change the code itself, yet teams forget to draw them. Label every flow (F1...) and trust boundary (TB1...).
⚠️ Keep your notes clean. Placeholders only: no secrets, real hostnames, internal IPs, AWS account IDs or customer data. Write
service-A,ACCOUNT-PROD,PL-000123. A finished model maps your weak spots: keep it with internal design docs, never in personal notes or a gist.
2. Walk STRIDE with a fill-in table
Walk the diagram element by element with L05's per-element table, starting with flows that cross a trust boundary. Each threat gets one row:
| ID | Where | STRIDE | Threat | L×I |
|----|-------|--------|------------------------------|-----|
| T1 | F1 | E | <actor> can <action> because | |
| | | | <weakness>, causing <result> | |
The threat sentence matters most. "Data leak in Mongo" can't be tested; "a customer can read another customer's policy because the query filters by ID only" names the fix. Count only controls you've verified. Aim for 10 to 20 threats.
3. Rate them and pick a top 5
Rate each threat's likelihood and impact High, Medium or Low, and multiply (H=3, M=2, L=1). That's L02's risk formula on a grid:
IMPACT
LOW MEDIUM HIGH
┌───────┬────────┬────────┐
L HIGH │ 3 │ 6 │ 9 │ 9 fix now
I ├───────┼────────┼────────┤
K MEDIUM │ 2 │ 4 │ 6 │ 6 top-5 zone
E ├───────┼────────┼────────┤
L LOW │ 1 │ 2 │ 3 │ 1-4 backlog, quick
Y └───────┴────────┴────────┘ win, or accept
Likelihood: High if a normal account and curl are enough, Medium if it needs a foothold or a phished engineer, Low if several controls must fail. Impact: High if many customers' NPI leaks, money moves or a regulator must be told (L68); Medium for one customer; Low for noise. Rate the residual risk (what's left given today's real controls), break ties by impact, and take the top 5.
4. Map each mitigation to the course
Almost every threat has a lesson behind it:
| If the threat is... | Look at |
|---|---|
| Forged tokens, trusted headers | L25, L43, L51 |
| Someone else's object (BOLA) | L34, L40 |
| Too much data in or out | L41, L69 |
| Injection, SSRF and IMDS | L31, L35, L47 |
| Flooding and enumeration | L42, L44 |
| Secrets, over-privileged roles | L46, L48, L50, L53 |
| CI/CD identity and dependencies | L55, L57 |
| Phished admins | L21, L28, L66 |
| No record of who did what | L52, L60 |
5. Tickets and a re-review trigger
Each top-5 threat becomes a ticket with an owner, a due date, and acceptance criteria written as an abuse-case test (L59). The rest get an L02 decision: backlog, quick win (low risk but a 10-minute fix), or accept, with an owner and a review date.
Write re-review triggers (events that force an update) on the document: L05's change
triggers, any incident or near miss (L63), and at least once a year. Keep it in the repo
(docs/threat-model.md) so it changes with the code.
🧪 Worked example: policy-lookup-api
Scope. A fictional read-only API, built on the example stack (Kong, EKS, MongoDB, Airflow, GitHub Actions): customers fetch their policies in a mobile app, and agents search them. In scope: everything drawn below. Out: the identity provider and the upstream system.
[Customer app] [Engineer] [Developer]
│ F1 HTTPS+JWT │ F7 SSO+MFA │ F9 merge to main
╌╌╌┼╌ TB1 ╌╌╌╌╌ ╌╌╌┼╌ TB5 ╌╌╌╌╌ ╌╌╌┼╌ TB6 ╌╌╌╌╌╌╌
▼ ▼ ▼
(Kong gateway) (Identity Center) (GitHub Actions)
│ F2 +consumer │ F8 prod admin │ F10 OIDC deploy
│ header │ role │ role
╌╌╌┼╌ TB2 ╌╌╌╌╌ ▼ ▼
│ ┌──────────── EKS API + AWS APIs ─────┐
│ └───────┬─────────────────────────────┘
▼ │ F11 kubectl exec, rollout
(policy-lookup-api) ◀────┘
│ F3 find(), read-only DB user
╌╌╌┼╌ TB3 ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
▼
═══ MongoDB: policies ═══ ◀── F6 upsert ── (Airflow DAG)
▲ F5 nightly
╌╌ TB4 ╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌
═══ S3 drop bucket ═══
▲ F4 extract
[Upstream admin system]
STRIDE and ratings (names and IDs made up):
| ID | Where | STRIDE | Threat | L×I |
|---|---|---|---|---|
| T1 | F1, api | I, E | A customer swaps {policyId} and reads another's policy; the query uses the ID only |
H×H = 9 |
| T2 | F2 | S | Any pod calls the service directly with a forged consumer header | M×H = 6 |
| T3 | Kong | D | A scraper walks policy IDs; no per-consumer rate limit | H×L = 3 |
| T4 | api | I | Responses return the whole document, SSN and underwriting notes included | H×M = 6 |
| T5 | F3 | T | Agent search binds the body to a Map, so {"$ne": null} matches everything |
L×H = 3 |
| T6 | api pod | E | SSRF reaches IMDS; hop limit 2 lets pods take the node role | M×M = 4 |
| T7 | F4, F5 | T | A bad upstream file overwrites live policies; no row-count check | L×H = 3 |
| T8 | Airflow | I | The DAG's Mongo password is a plain Airflow Variable | L×H = 3 |
| T9 | F7, F8 | S, E | A phished engineer (push MFA) gets prod admin with pods/exec |
M×H = 6 |
| T10 | F10 | E | Deploy role trusts sub = repo:example-org/policy-lookup-api:*: any branch deploys |
M×H = 6 |
| T11 | F9 | T | Third-party actions pinned to tags, not commit SHAs | L×H = 3 |
| T12 | api | R | No record of which consumer read which policy | M×M = 4 |
Top 5, with mitigations and lessons:
| # | Threat | Mitigation, and what "done" means | Lessons |
|---|---|---|---|
| 1 | T1 BOLA | Filter by policyId and the token's customer; "not yours" returns 404; two-user test in CI |
L34, L40, L59 |
| 2 | T10 deploy role | StringEquals on sub = ...:environment:prod; that environment takes only main, with a reviewer |
L46, L57 |
| 3 | T9 admin path | FIDO2 for prod permission sets; separate break-glass set; no pods/exec in prod |
L21, L46, L50 |
| 4 | T2 spoofing | NetworkPolicy admitting only Kong; the service validates the JWT itself |
L25, L43, L51 |
| 5 | T4 data exposure | Response DTO with an allow-list of fields; the SSN never leaves the service | L41, L69 |
The rest: T6 and T12 go to the backlog (L35, L47; L60), T11 is a quick win, and T3, T5, T7 and T8 get an owner and a review date. The first ticket:
POL-201 [security] T1: enforce policy ownership on GET
Owner: policy team Due: sprint 42 Rating: 9
Done when:
- the query filters by policyId AND the token's customer
- another customer's ID returns 404, like an unknown ID
- a two-user abuse-case test runs in CI
🛠 Try it (~10 min)
Copy this template and fill it in for a service you work on, whatever it runs on. Placeholders only: no secrets, real hostnames, account IDs or customer data.
# Threat model: <service-A>
Version 1 · YYYY-MM-DD · Owner: <team> · Next review: YYYY-MM
## Scope
In: <paths covered> Out: <what you leave out>
## DFD
<diagram with F1..Fn and TB1..TBn, or a link to it>
## Threats
| ID | Where | STRIDE | Actor, action, weakness, result | L×I |
|----|-------|--------|---------------------------------|-----|
## Top 5
| # | Threat | Mitigation, "done when" | Lessons | Ticket |
|---|--------|-------------------------|---------|--------|
## Everything else
| ID | Backlog / quick win / accept | Owner | Review by |
|----|------------------------------|-------|-----------|
## Re-review when
- <change triggers, any incident, at least yearly>
Check: did you draw the human and build paths? Does every row have an owner?
🧠 Key takeaways
- Draw all four paths in; the human and build paths skip the gateway.
- Write each threat as actor, action, weakness, result, so it becomes a test and a ticket.
- Rate residual risk on one yardstick, take the top 5, and link each fix to its lesson.
- Done means every row has an owner, and the model has a next-review date.
📖 Words to know
| Term | Meaning |
|---|---|
| Residual risk | The risk left after the controls that really exist today |
| Re-review trigger | An event that means the threat model must be updated |
🎓 Course complete
That's all 71 lessons. You can now read a design, see where it will break, and name the fix. Next:
- Practice: PortSwigger's free Web Security Academy labs (start with access control), and flaws.cloud and flaws2.cloud for AWS.
- Requirements: use OWASP ASVS 5.0 as a checklist for one of your services.
- Certification: AWS Certified Security – Specialty; CKS if you live in Kubernetes.
- At work: threat-model the next design that crosses a trust boundary, and add a threat-model section to your team's design template. Say review here now and then, too.
Scoring an attack tree: the cheapest path and the minimum cut
Why your top threat deserves a tree
The threat table lists rows one at a time, but an attacker combines them. L05 introduced attack trees: a goal at the root, alternatives (OR) and required steps (AND) below it. Here you add the part that makes them useful for prioritizing: a cost on every leaf, so you can compute which path an attacker would take, and which controls block all of them.
Two scoring rules
Give each leaf an attacker cost from 1 to 5. 1 means an afternoon with a normal account and curl; 3 means it needs a foothold or a phished engineer; 5 means an insider plus several failed controls. Then roll the costs up the tree:
OR node cost = MIN of its children (attacker picks one)
AND node cost = SUM of its children (attacker needs all)
blocked cost = ✖ (infinite) (a control stops it)
The numbers are judgment calls, not measurements. Their value is in comparing paths, so use the same yardstick for every leaf, the way you did for the risk ratings.
🧪 Worked example: "bulk-read other customers' policies"
The goal behind policy-lookup-api's T1, with costs in brackets:
GOAL: bulk-read other customers' policies = 1
│
├─ OR 1. through the API min = 1
│ ├─ 1a. swap {policyId}: BOLA (T1) [1]
│ └─ AND 1b. get a foothold in any pod [3]
│ 1c. forge the consumer header (T2) [1] = 4
│
├─ OR 2. through the data path sum = 5
│ └─ AND 2a. read the DAG's Mongo password (T8) [3]
│ 2b. reach MongoDB from that position [2]
│
├─ OR 3. through the human path sum = 4
│ └─ AND 3a. phish an engineer past push MFA (T9) [3]
│ 3b. exec into a prod pod, read its env [1]
│
└─ OR 4. through the build path sum = 3
└─ AND 4a. push a branch with a stolen dev token [2]
4b. deploy it with the wildcard role (T10) [1]
The root costs 1: BOLA, as the risk table said. The useful part is re-scoring after each fix, because the attacker simply moves to the next cheapest path:
| Fixed so far | Path costs (1 / 2 / 3 / 4) | Cheapest | Next ticket |
|---|---|---|---|
| nothing | 1 / 5 / 4 / 3 | path 1 | T1 |
| T1 | 4 / 5 / 4 / 3 | path 4 | T10 |
| T1, T10 | 4 / 5 / 4 / ✖ | paths 1 and 3 | T2, T9 |
| T1, T10, T2, T9 | ✖ / 5 / ✖ / ✖ | path 2 | T8, or Mongo network access |
The tree agrees with the top-5 order, which is a good cross-check. If the tree and your High/Medium/Low ratings disagree, one of them is wrong, and finding out which is time well spent.
The minimum cut
A cut is a set of controls that blocks every path to the root. Under an OR you must block each
branch; under an AND, blocking one child is enough. The smallest such set is the minimum cut.
Here it's five controls: the owner check (1a), JWT validation in the service (1c), MongoDB reachable
only from the app and the DAG over a private endpoint (2b), FIDO2 for admins (3a), and the pinned
sub (4b).
Look at the last row of the table. After the top 5, the data path is still open: T8 scored only 3 as a single row, but it's the last open branch to the goal. That's what a tree shows that a risk matrix can't. For the branches with the highest impact, add a second block, so one misconfiguration doesn't reopen the path (defense in depth, L04).
🛠 Try it (~5 min)
Take the top threat from your own model and turn it into a goal. Draw at least three OR branches, and include your human and build paths. Score the leaves, find the cheapest path, then mark your top-5 fixes as ✖ and re-score. Is any branch still open?
🧠 Extra takeaways
- Score leaves with a 1-5 attacker cost: OR takes the minimum, AND takes the sum.
- Re-score after each fix; the attacker moves to the next cheapest path, and that's your next ticket.
- A minimum cut blocks every path with the fewest controls: every OR branch needs a block, and one block inside an AND is enough.
- A tree can reveal a low-rated threat that is the last open path to a high-value goal.
| Term | Meaning |
|---|---|
| Attacker cost | A rough 1-5 score of how hard a leaf of an attack tree is for an attacker |
| Cheapest path | The route through an attack tree with the lowest total cost |
| Cut | A set of controls that together block every path to the attack tree's goal |
| Minimum cut | The smallest cut |
Assume the mitigation fails: one detection per top threat
Prevention has a failure rate
Your top-5 fixes will hold most of the time. But a refactor can drop the owner check, a new node group can come up with the old IMDS setting, and an engineer can approve a phishing prompt. So ask a second question of each top threat: if this happened tonight, which log line would show it, and who would see it? Your top 5 are your detection priorities: where L61 picked detections from attacker groups' habits, these come from your own diagram.
Four questions per threat
For each top-5 threat, write down:
SIGNAL what the attack looks like when it works
SOURCE which log already records it (or must start to)
RULE the condition that raises an alert
RESPONSE who is alerted, and what they check first
If no log records the signal, that's a finding too. T12 in the worked example (no record of who read which policy) turns out to block the T1 detection, which is a reason to move it up the backlog.
🧪 Worked example: detections for policy-lookup-api
| Threat | Signal | Source | Rule sketch |
|---|---|---|---|
| T1 BOLA | Someone requests a honeytoken policy, PL-000000, a fake record no customer owns; or one consumer touches many policy IDs |
App audit log (needs T12), Kong logs | Any request for the honey ID; more than 30 distinct IDs or 20% 404s per consumer per hour |
| T10 deploy role | Failed AssumeRoleWithWebIdentity calls on the deploy role (a branch tried); a prod rollout by any other identity |
CloudTrail, EKS audit log | Any failure; any patch on the prod Deployment not made by the deploy role |
| T9 admin path | A prod admin session from a new device or country, then pods/exec or GetSecretValue |
Identity Center sign-in events in CloudTrail, EKS audit log | Admin sign-in followed by exec within 30 minutes, outside a change window |
| T2 spoofing | Requests reaching the service from a pod that isn't Kong | App access log (peer address) | Any caller outside Kong's pods |
| T4 data exposure | A response containing a field that isn't on the DTO allow-list | Contract test in CI | Build fails on any new field |
Three notes on the table. The honeytoken (L62) is the best rule here: no legitimate user ever requests
PL-000000, so an alert is almost never a false positive. The EKS audit log only exists if
control-plane audit logging is switched on for the cluster, so check that first (L52). And T4's
"detection" runs at build time: some failures are best caught before they ship.
Test the detection, not just the control
An alert that has never fired is only a hypothesis. Run T1's abuse-case test against staging, including a read of the honey ID, and confirm that the alert arrives where the runbook says it will. Keep the rule in git next to the threat model (detection-as-code, L62). For the volume rule, L44's deep dive on spotting enumeration in gateway logs shows what normal and abnormal look like.
🛠 Try it (~5 min)
For the top threat in your own model, fill in the four lines: signal, source, rule, response. Then check one thing for real: does the log you named exist today, and is it kept long enough to answer "since when?" (L60)?
🧠 Extra takeaways
- Every top-5 threat gets a detection, because every control eventually fails or regresses.
- Write signal, source, rule and response; a missing log is a finding of its own.
- Honeytoken records give high-confidence alerts for object-level access bugs.
- Fire each detection on purpose in staging; an untested alert is a guess.
| Term | Meaning |
|---|---|
| Detection priority | A threat chosen for monitoring because it's in the top of your threat model |
| Honeytoken | A fake record or credential that no legitimate user touches, so any use is an alert |
| Detection-as-code | Alert rules kept in version control, reviewed and tested like code |
The quiz for this lesson and the Tests section need JavaScript.