Choreo ThreatModel: Comprehensive Walkthrough
Section
Mix.install([
{:choreo, "~> 0.6"},
{:kino_vizjs, "~> 0.5.0"}
])
> Rendering diagrams: This livebook uses Kino.VizJS to render DOT diagrams inline. If you prefer PlantUML, copy any DOT output into PlantText, PlantUML Online Server, or use the VS Code PlantUML extension. You can also install Graphviz locally and run dot -Tpng diagram.dot -o diagram.png.
What is Threat Modeling?
Threat modeling is a structured way to identify, quantify, and address security risks in a system. Instead of waiting for a penetration test to reveal vulnerabilities, you analyze the architecture before writing code and ask: “What could go wrong?”
STRIDE is a popular mnemonic for threat categories:
| Category | Question | Example |
|---|---|---|
| Spoofing | Can an attacker pretend to be someone else? | Stolen credentials, forged tokens |
| Tampering | Can data be modified in transit or at rest? | Man-in-the-middle, SQL injection |
| Repudiation | Can actions be denied? | Missing audit logs |
| Information Disclosure | Is sensitive data exposed? | Unencrypted backups, verbose errors |
| Denial of Service | Can the system be overloaded? | DDoS, resource exhaustion |
| Elevation of Privilege | Can a user gain more access? | Horizontal/vertical privilege escalation |
Choreo.ThreatModel lets you describe your architecture as code, define trust boundaries, and automatically generate STRIDE threats with severity scoring.
Example 1: Classic Three-Tier Web Application
Let’s start with the simplest useful model: a user, a web API, and a database. This mirrors the classic pytm getting-started example.
alias Choreo.ThreatModel
alias Choreo.ThreatModel.Analysis
web_app =
ThreatModel.new()
|> ThreatModel.add_trust_boundary("internet", level: 0, label: "Internet")
|> ThreatModel.add_trust_boundary("app", level: 2, label: "Application Zone")
|> ThreatModel.add_trust_boundary("data", level: 3, label: "Data Layer")
|> ThreatModel.add_external_entity(:user,
label: "Customer",
boundary: "internet",
description: "End user browsing the site"
)
|> ThreatModel.add_process(:web_api,
label: "Web API",
boundary: "app",
privilege: :user,
description: "Handles HTTP requests"
)
|> ThreatModel.add_data_store(:postgres,
label: "Postgres",
boundary: "data",
sensitivity: :confidential,
description: "Primary application database"
)
|> ThreatModel.data_flow(:user, :web_api,
label: "HTTPS login",
encrypted: true
)
|> ThreatModel.data_flow(:web_api, :postgres,
label: "SQL query"
)
Kino.VizJS.render(ThreatModel.to_dot(web_app))
Notice the color coding:
- Red dashed edges cross trust boundaries unencrypted.
- Orange edges cross boundaries but are encrypted.
- Green nodes live in higher-trust zones.
Generating STRIDE Threats
threats = Analysis.stride_threats(web_app)
threats
|> Enum.sort_by(& &1.severity, :desc)
|> Enum.each(fn t ->
target = if is_tuple(t.target), do: "#{elem(t.target, 0)} → #{elem(t.target, 1)}", else: t.target
IO.puts("[#{String.upcase(to_string(t.severity))}] #{t.id} — #{t.category} @ #{target}")
IO.puts(" #{t.description}")
IO.puts(" Mitigation: #{t.mitigation}\n")
end)
The model automatically flagged the unencrypted web_api → postgres flow as high-risk information disclosure because it crosses from the application zone into the data layer without encryption.
Validation
Analysis.validate(web_app)
|> Enum.each(fn {sev, msg} ->
icon = if sev == :error, do: "❌", else: "⚠️"
IO.puts("#{icon} #{msg}")
end)
Example 2: Microservices with an API Gateway
A more realistic architecture: an API Gateway fronts multiple services. Some flows are internal; some cross from the public internet into the VPC.
microservices =
ThreatModel.new()
|> ThreatModel.add_trust_boundary("internet", level: 0)
|> ThreatModel.add_trust_boundary("dmz", level: 1)
|> ThreatModel.add_trust_boundary("vpc", level: 2)
|> ThreatModel.add_external_entity(:mobile_app, label: "Mobile App", boundary: "internet")
|> ThreatModel.add_external_entity(:spa, label: "Web SPA", boundary: "internet")
|> ThreatModel.add_process(:api_gateway,
label: "API Gateway",
boundary: "dmz",
privilege: :none,
description: "Rate limiting, routing, WAF"
)
|> ThreatModel.add_process(:auth_service,
label: "Auth Service",
boundary: "vpc",
privilege: :admin,
description: "Issues and validates JWTs"
)
|> ThreatModel.add_process(:order_service,
label: "Order Service",
boundary: "vpc",
privilege: :user
)
|> ThreatModel.add_process(:payment_webhook,
label: "Payment Webhook",
boundary: "vpc",
privilege: :none,
description: "Receives async callbacks from Stripe"
)
|> ThreatModel.add_data_store(:orders_db,
label: "Orders DB",
boundary: "vpc",
sensitivity: :confidential
)
|> ThreatModel.add_data_store(:redis_cache,
label: "Redis",
boundary: "vpc",
sensitivity: :internal
)
|> ThreatModel.data_flow(:mobile_app, :api_gateway, label: "REST + mTLS", encrypted: true)
|> ThreatModel.data_flow(:spa, :api_gateway, label: "HTTPS", encrypted: true)
|> ThreatModel.data_flow(:api_gateway, :auth_service, label: "gRPC", encrypted: true)
|> ThreatModel.data_flow(:api_gateway, :order_service, label: "gRPC", encrypted: true)
|> ThreatModel.data_flow(:order_service, :orders_db, label: "SQL/TLS", encrypted: true)
|> ThreatModel.data_flow(:order_service, :redis_cache, label: "Redis protocol")
|> ThreatModel.data_flow(:payment_webhook, :order_service, label: "Event bus")
Kino.VizJS.render(ThreatModel.to_dot(microservices, theme: :dark))
Cross-Boundary Analysis
Which flows cross a trust boundary without encryption?
Analysis.unencrypted_boundary_flows(microservices)
|> Enum.each(fn {from, to} ->
IO.puts("🔓 Unencrypted boundary crossing: #{from} → #{to}")
end)
Exposed Data Stores
Which databases are reachable from an external entity?
Analysis.exposed_data_stores(microservices)
|> IO.inspect(label: "Exposed data stores")
High-Risk Processes
Which processes touch sensitive data?
Analysis.high_risk_processes(microservices)
|> IO.inspect(label: "High-risk processes")
Attack Paths
What are the complete paths from an external entry point to a data store?
Analysis.attack_paths(microservices)
|> Enum.each(fn path ->
IO.puts("🎯 #{Enum.join(path, " → ")}")
end)
Threat Summary
summary = Analysis.threat_summary(microservices)
IO.puts("Total threats: #{summary.total}")
IO.puts("\nBy severity:")
Enum.each(summary.by_severity, fn {sev, count} ->
IO.puts(" #{sev}: #{count}")
end)
Example 3: Multi-Tenant SaaS with Custom Compliance Rules
Real organizations have compliance requirements beyond STRIDE. The :rules option lets you inject custom threat generators via the Choreo.ThreatModel.Analysis.Rule behaviour.
defmodule GDPRRule do
@behaviour Analysis.Rule
@impl true
def threats_for_element(_model, id, data) do
sensitivity = data[:sensitivity]
if sensitivity in [:confidential, :restricted] do
[
%{
id: "GDPR-#{id}-1",
category: :compliance,
target: id,
description: "#{data.label} stores personal data without documented retention policy.",
severity: :high,
mitigation: "Implement automated data purging and Right-to-be-Forgotten endpoints."
}
]
else
[]
end
end
@impl true
def threats_for_flow(_model, from, to) do
# Every cross-boundary flow with personal data needs a DPA
[
%{
id: "GDPR-FLOW-#{from}-#{to}",
category: :compliance,
target: {from, to},
description: "Data flow #{from} → #{to} may require Data Processing Agreement.",
severity: :medium,
mitigation: "Review DPA coverage and SCCs for third-party processors."
}
]
end
end
saas =
ThreatModel.new()
|> ThreatModel.add_trust_boundary("public", level: 0)
|> ThreatModel.add_trust_boundary("tenant_isolation", level: 2)
|> ThreatModel.add_external_entity(:tenant_admin, label: "Tenant Admin", boundary: "public")
|> ThreatModel.add_process(:app_server,
label: "App Server",
boundary: "tenant_isolation",
privilege: :user
)
|> ThreatModel.add_data_store(:tenant_db,
label: "Tenant DB",
boundary: "tenant_isolation",
sensitivity: :confidential,
retention: "90d"
)
|> ThreatModel.data_flow(:tenant_admin, :app_server, encrypted: true)
|> ThreatModel.data_flow(:app_server, :tenant_db, encrypted: true)
# Base STRIDE only
base_threats = Analysis.stride_threats(saas)
IO.puts("Base STRIDE threats: #{length(base_threats)}")
# STRIDE + GDPR custom rules
all_threats = Analysis.stride_threats(saas, rules: [GDPRRule])
IO.puts("With GDPR rules: #{length(all_threats)}")
gdpr_only = Enum.filter(all_threats, &(&1.category == :compliance))
IO.puts("GDPR-specific threats:")
Enum.each(gdpr_only, fn t ->
target = if is_tuple(t.target), do: "flow", else: t.target
IO.puts(" #{t.id} @ #{target} — #{t.description}")
end)
Kino.VizJS.render(ThreatModel.to_dot(saas))
Generating Sequence Diagrams (PlantUML)
While Data Flow Diagrams illustrate security boundaries, Sequence Diagrams visualize interactions chronologically. Choreo enables automatic sequence extraction.
alias Choreo.ThreatModel.Render.PlantUML
plantuml_string = PlantUML.to_sequence(microservices)
IO.puts(plantuml_string)
Copy the resulting payload directly into an online PlantUML compiler.
Deep Dive: Understanding Severity Scoring
Choreo assigns severity based on element properties and flow context. You don’t have to guess — the scoring is derived from the model itself.
Severity rules of thumb:
| Factor | Impact on Severity |
|---|---|
| Crosses a trust boundary | Bumps up by one level |
| Unencrypted | Bumps up by one level |
sensitivity: :restricted |
Critical |
sensitivity: :confidential |
High |
sensitivity: :internal |
Medium |
privilege: :admin |
Higher impact if compromised |
privilege: :user |
Standard impact |
privilege: :none |
Lower impact |
# Experiment: change sensitivity and watch severity shift
restricted_model =
ThreatModel.new()
|> ThreatModel.add_trust_boundary("app", level: 2)
|> ThreatModel.add_process(:api, boundary: "app", privilege: :admin)
|> ThreatModel.add_data_store(:vault, boundary: "app", sensitivity: :restricted)
|> ThreatModel.data_flow(:api, :vault)
Analysis.stride_threats(restricted_model)
|> Enum.filter(&(&1.target == :vault))
|> Enum.each(fn t ->
IO.puts("#{t.category} → #{t.severity}")
end)
Fixing Issues: Validation-Driven Hardening
Start with a sloppy model, then use validate/1 as a checklist to harden it.
sloppy =
ThreatModel.new()
|> ThreatModel.add_process(:api, label: "API")
|> ThreatModel.add_data_store(:db, label: "DB")
|> ThreatModel.data_flow(:api, :db)
IO.puts("Issues found:")
Analysis.validate(sloppy) |> Enum.each(fn {sev, msg} -> IO.puts(" #{msg}") end)
Now fix each issue step by step:
hardened =
ThreatModel.new()
|> ThreatModel.add_trust_boundary("app", level: 2)
|> ThreatModel.add_process(:api, label: "API", boundary: "app", privilege: :user)
|> ThreatModel.add_data_store(:db, label: "DB", boundary: "app", sensitivity: :confidential)
|> ThreatModel.data_flow(:api, :db, encrypted: true)
IO.puts("After hardening:")
Analysis.validate(hardened) |> IO.inspect()
Kino.VizJS.render(ThreatModel.to_dot(hardened))
Rendering for Stakeholders
Dark theme for presentations
Kino.VizJS.render(ThreatModel.to_dot(microservices, theme: :dark))
Export to PlantUML
To generate Sequence Diagrams in PlantUML format, use the dedicated PlantUML renderer:
plantuml = Choreo.ThreatModel.Render.PlantUML.to_sequence(microservices)
IO.puts(plantuml)
You can copy this directly into PlantText or your Confluence PlantUML macro blocks securely.
Summary
| Task | Function |
|---|---|
| Model architecture |
ThreatModel.new/0, add_trust_boundary/3, add_external_entity/3, add_process/3, add_data_store/3, data_flow/4 |
| Auto-generate threats |
Analysis.stride_threats/2 |
| Custom rules |
Implement Analysis.Rule behaviour, pass via rules: [MyRule] |
| Find unencrypted flows |
Analysis.unencrypted_boundary_flows/1 |
| Find exposed databases |
Analysis.exposed_data_stores/1 |
| Find risky processes |
Analysis.high_risk_processes/1 |
| Attack paths |
Analysis.attack_paths/1 |
| Validate model |
Analysis.validate/1 |
| Summarise threats |
Analysis.threat_summary/1 |
| Render |
ThreatModel.to_dot/2 (themes: :default, :dark) |
Threat modeling as code means your security review is version-controlled, diffable, and repeatable. Every pull request can include an updated threat model alongside the code changes.