Not recorded yet. The lesson below is complete and runnable today — the video is added later without touching this page.
recordings/C2.2.mp4Threat models are written once, by hand, against a system that has since changed.
Stage 5: derive assets, entry points and attack vectors mechanically from the synthesised map.
Turn an architecture map into a ranked threat model, then diff it after one entry point is added.
The model layer is the one place where the same input legitimately produces different output, which makes every naive experiment on it unrepeatable. Method is not a formality here; it is the only thing separating a finding from a coincidence.
appsec/threatmodel.py reads the assets out of db.py's schema and the entry points out of the tree. Run it and one asset comes back UNCLASSIFIED — policies, which nobody has said is worth anything. drift() then compares a stored model against the tree, so "the threat model is out of date" becomes the two entry points that appeared since.A threat model derived from the tree rather than remembered from a workshop: assets from the schema, entry points from the code, trust boundaries from the layout — plus the column a machine cannot fill in, and a drift check that turns 'this is out of date' into a list.
Threat models are written once, by hand, against a system that has changed since.
Derive assets, entry points and attack vectors mechanically from the map the estate already holds.
The diff between two runs. A threat model that produces no diff when the estate changed is not being derived.
Measure this
| what to measure | denominator or target |
|---|---|
| diff between two threat-model runs | count of changed rows; no diff when the estate changed means it is not being derived |
same prompt, same model, three runs, three outputs
|
v
+--------------------------------------------+
| n trials . fixed seeds . a control arm |
| report a RATE with an interval, not a case |
+--------------------------------------------+
without method, a finding and a coincidence look identicalStage 5 is the one everybody claims to do and almost nobody re-runs.
A threat model produced in a workshop describes the system as it was on the day of the workshop. It is stale the moment an entry point is added, and entry points get added in the course of ordinary work, without anyone calling it a change to the threat model. So this stage does not write a threat model — it derives one, from evidence the estate already holds, and the useful artefact is the diff between two runs.
STRIDE gives six questions. Against an agentic system each has a shape a web-application threat model does not:
| STRIDE | In an agentic system |
|---|---|
| Spoofing | agents share a service account, so "which agent" is unanswerable |
| Tampering | untrusted content the agent read becomes an instruction it follows |
| Repudiation | the delegation chain is not on the token, so no log answers "on whose behalf" |
| Information disclosure | an over-broad tool return, or egress that permits anything |
| Denial of service | an unbounded loop, or a budget nobody set |
| Elevation of privilege | a role assumable by *, or a scope that included refunds because it included payments |
The architecture map says what the code could reach. It cannot say whether that path is exposed, what identity walks it, or whether anything can leave at the end of it — and those three decide whether a finding is a fire.
| Input | What only it can tell you |
|---|---|
| architecture | components, flows, sinks, trust levels |
| CSPM | that the bucket behind that sink is public today |
| IAM | who can assume the role, and whether MFA is required |
| network | is it internet-facing, and can anything leave |
| entitlements | what the identity may do once it is through |
Read only the first and you produce a model that is identical for two deployments of the same repository — one behind a private load balancer with no egress, one on the internet with a wildcard trust policy. It is wrong about both.
This lesson runs the threat-model-stride skill. You do not write the code; you read the procedure, execute it, and read what it produced.
This is skills/appsec/threat-model-stride/SKILL.md, verbatim. The frontmatter is what routes a request to it; the body is the procedure a model follows.
The deterministic half of the skill — the part that has to give the same answer twice so two runs can be diffed. Embedded from skills/appsec/threat-model-stride/scripts/.
cybertravels/appsec/threatmodel.py derives the asset list from db.py's CREATE TABLE statements rather than from a workshop, and one of them comes back UNCLASSIFIED — policies, which nobody has said is worth anything. That is the same reading this skill performs on a generated scaffold: work from the catalogue, not from the application's own queries, because the table the application never queries is the one nobody put a policy on.
Five synthetic inputs, standing in for what a real estate already holds.
Mermaid, so it renders here and on the lesson page without a library. Double arrows are trust-boundary crossings — the edges every finding turned out to live on.
Not one line of CyberTravels' source changes. Only the four evidence inputs around it do.
The skill — skills/appsec/threat-model-stride/SKILL.md
name: threat-model-stride
description: >-
Build a STRIDE threat model for an agentic system from the evidence an estate
already holds — source code, CSPM findings, IAM policies, network policies and
the tool surface — and emit a ranked threat table plus a trust-boundary
diagram. Use when asked what could go wrong with an architecture, to
threat-model an agent, a pipeline or a service, when a threat model needs
regenerating after an architecture change, or when someone asks which STRIDE
categories a design actually exposes.
allowed-tools: Read, Grep, Glob, BashA threat model written in a workshop describes the system as it was on the day of the workshop. This one is derived: every threat traces to a line of evidence that already exists somewhere in the estate, so regenerating it is cheap and the useful artefact is the diff between two runs.
STRIDE gives six categories. Against an agentic system each one has a specific shape that a web-application threat model does not:
| STRIDE | In an agentic system |
|---|---|
| Spoofing | An agent calls downstream as a shared service account, so "which agent" is unanswerable |
| Tampering | Untrusted content the agent read becomes an instruction it follows |
| Repudiation | The delegation chain is not on the token, so no log answers "on whose behalf" |
| Information disclosure | An over-broad tool return, or egress that permits anything |
| Denial of service | An unbounded loop, or a budget nobody set |
| Elevation of privilege | A role assumable by *, or a scope that includes refunds because it included payments |
Threat-modelling an agent, an MCP server, or a review pipeline; re-running a model after an architecture change; or answering "which STRIDE categories does this design actually expose" with something better than an opinion.
Five evidence sources. Any one alone produces a model that is wrong in a predictable direction — code alone cannot tell you whether a path is exposed, and CSPM alone cannot tell you what reaches it.
| Input | What only it can tell you |
|---|---|
architecture | components, flows, sinks, trust levels — what could be reached |
cspm | live posture findings: what is public today |
iam | which roles exist, who may assume them, whether MFA is required |
network | ingress exposure and egress policy — can anything leave |
entitlements | what the running identity may do once it is through |
1 — Load the five inputs. Refuse to proceed on fewer. A model built on architecture alone should say so in its output rather than silently scoring as though the estate were hardened.
2 — Walk each entry point to each sink. For every reachable pair, ask the six STRIDE questions. A category that has no evidence behind it is not a threat; do not invent one to fill the row.
3 — Score from evidence, not from feeling. Base severity comes from the asset. Then adjust only where an input says so: internet-facing, no WAF, a live CSPM finding on the resource, a role that holds write, a trust policy with a wildcard, egress open. Record which adjustment fired — the reasons are the part a reviewer argues with.
4 — Emit the trust-boundary diagram. Nodes are components, edges are flows, and an edge crossing from a lower trust level to a higher one is a boundary. Boundaries are where findings live; render them differently from ordinary edges.
5 — Diff against the previous run. Report new threats and escalated ones. A pull request that changes only infrastructure introduces no new threat and raises every existing score, so a gate that counts arrivals alone waves it through.
Input — the fixture committed at the top of scripts/threat_model.py. Edit it and re-run: the buckets, counts and verdicts below are derived from it, not hard-coded.
Output — the opening lines of a real run:
id entry sink score why
--------------------------------------------------------------------------------------------
T-10 I upload_voucher store 11 a live CSPM finding on the resource this path reaches
T-01 S get_booking load_booking 11 the running role is assumable by *
T-05 D get_booking load_booking 10 internet-facing with no WAF in front of it
T-06 E get_booking load_booking 10 the identity holds write, not just read
T-03 R get_booking load_booking 10 no MFA on the assumable role, so the actor is not established
T-04 I get_booking load_booking 9 no open CSPM finding on the resource
The run continues past this. test_skills.py executes the script on every build, with no model configured — so what CI proves is that it runs and refuses legibly, not that it prints these lines. The output above is a recorded run against a real model, and nothing re-diffs it: a model's answer is not reproducible, and this repository does not claim otherwise anywhere it can be checked.
{
"inputs_present": ["architecture", "cspm", "iam", "network", "entitlements"],
"threats": [
{"id": "T-01", "stride": "S|T|R|I|D|E", "entry": "str", "sink": "str",
"asset": "str", "score": 0, "reasons": ["internet-facing", "..."],
"evidence": {"source": "cspm|iam|network|entitlements|architecture",
"detail": "str"}}
],
"boundaries": [{"from": "str", "to": "str", "trust": "0->2"}],
"diagram": "mermaid or svg source",
"diff": {"new": ["T-07"], "escalated": [{"id": "T-01", "from": 13, "to": 17}]}
}
The skill — skills/research/generated-schema-audit/SKILL.md
name: generated-schema-audit
description: >-
Audit a generated database scaffold for tables that hold secrets and carry no
access policy, working from the catalogue rather than from the application's
own queries. Use after any scaffold, migration or agent-authored schema
change.
allowed-tools: Read, Grep, GlobA generated scaffold produces a schema that runs. Whether it is safe is a different question and the application cannot answer it, because the application only touches the tables it uses. The catalogue knows about all of them, and the gap between those two sets is where this finding lives.
After a scaffold generator, an agent-authored migration, or any schema change nobody reviewed statement by statement.
1 — Read the statements as written. Table by table, column by column. Flag every column that holds a credential, a token, a session or personal data — an api_key column in a profiles table is the shape to look for.
2 — Check policy per table, not per application feature. For each table: does an access policy exist at all, and does it name a subject. "No policy" and "a policy that permits everyone" are different findings.
3 — Enumerate from the catalogue. Query the system catalogue for every table in the schema. Compare against the tables the application references. The difference is the set nobody has looked at.
4 — Classify by sensitivity and policy together. Sensitive with no policy is critical. Non-sensitive with no policy is a finding with a lower number and the same cause.
5 — Report per table with the statement that created it. A finding that points at a line in a migration gets fixed; one that says "enable RLS" gets discussed.
Input — the fixture committed at the top of scripts/generated_schema_audit.py. Edit it and re-run: the buckets, counts and verdicts below are derived from it, not hard-coded.
Output — the opening lines of a real run:
tables created : profiles, posts
findings : 2
table severity why
profiles critical RLS disabled - open via the public API
posts high policy matches every row
The application works. Every feature passes. `profiles` holds an api_key
The run continues past this. test_skills.py executes the script on every build, with no model configured — so what CI proves is that it runs and refuses legibly, not that it prints these lines. The output above is a recorded run against a real model, and nothing re-diffs it: a model's answer is not reproducible, and this repository does not claim otherwise anywhere it can be checked.
{
"statements": [{"table": "str", "columns": ["str"], "sensitive_columns": ["str"]}],
"policies": [{"table": "str", "policy": "none|open|scoped", "subject": "str|null"}],
"catalogue": {"tables": ["str"], "referenced_by_app": ["str"], "unreviewed": ["str"]},
"findings": [{"table": "str", "severity": "critical|high|medium", "created_by": "str"}]
}
Three ways to do this lesson. Pick one. They run the same procedure against the same code — what changes is who drives it, and how much you see.
The agent reads the lesson, explains the idea, fetches the code, runs the check and answers your questions as you go. This is the one to take if you are here to learn.
Install it, once. From the folder you cloned. --all does every agent it finds on your machine:
python3 scripts/install_skills.py --all --lessons C2.2Then restart your agent and open it in that folder. On Windows, if python3 says Python was not found, use python instead.
Then pick c2-2-threat-modelling-from-what-the-estate:
| agent | install just for this agent | start it |
|---|---|---|
| Claude Code | python3 scripts/install_skills.py --tool claude --lessons C2.2 | type /c2-2-threat-modelling-from-what-the-estate |
| GitHub Copilot (VS Code) | python3 scripts/install_skills.py --tool copilot --lessons C2.2 | type /c2-2-threat-modelling-from-what-the-estate in Chat |
| Cursor | python3 scripts/install_skills.py --tool cursor --lessons C2.2 | type / in Agent chat and search for it |
| Codex | python3 scripts/install_skills.py --tool agents --lessons C2.2 | type $c2-2-threat-modelling-from-what-the-estate, or run /skills |
No agent, no conversation. The same lesson executes and prints the same readback. Take this one if you have no assistant, if you are reviewing rather than learning, or if you want the result without the teaching around it.
python3 scripts/lesson.py C2.2Neither of the above, and no model needed. The first command writes CyberTravels exactly as it stood at the end of this lesson; the second prints the diff — the lines this lesson added or changed, and nothing from any later one.
python3 scripts/checkpoint.py --at C2.2 --out work/cybertravels
python3 scripts/checkpoint.py --at C2.2 --diffOn Windows, type python wherever these say python3 — and use \ instead of / in the paths. Everything else is identical. A0.0 step 2 explains why.
Ways 1 and 2 need a model; way 3 does not. A signed-in Claude Code CLI needs no API key, and any OpenAI-compatible endpoint works too. With neither, the audit exits 2 and says so rather than inventing an answer. A0.0 sets this up.
Ways 1 and 2 both end with the same readback: What I did, What changed, The number and Read this next. A step that was skipped or refused is reported as exactly that, never as a pass.
If you did the last lesson’s challenge, expect this to stop. Most challenges ask you to edit the code, and ways 1 and 2 will not overwrite your edits: you get stopped, not a failure, with two options — --out for a fresh folder that keeps your work, or --force to discard it and start this lesson clean.
The skill loads with its routing description and procedure, then derives twelve threats across all six STRIDE categories from five synthetic inputs, each carrying the evidence line that set its score. It emits a mermaid diagram marking the two trust-boundary crossings. Re-running against a hardened estate — same code, four different evidence inputs — keeps every row and drops the maximum severity from 11 to 1.
Point the skill at one of your own services. The work is not the model, it is collecting the five inputs: if any of them is "in somebody's head", that is the input your threat model is currently guessing at, and the guess is always the optimistic one.
Tools used
Skill source: skills/appsec/threat-model-stride