CyberNugget

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.
Deep dive 1

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
Deep dive 2

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.