Choreo ThreatModel: Comprehensive Walkthrough
# For Hex publication/readers:
Mix.install([
{:choreo, "~> 0.14.1"},
# {:choreo, path: Path.expand("../..", __DIR__), force: true},
{:kino_vizjs, "~> 0.9.0"}
])
# For local development:
# Mix.install([
# {:choreo, path: Path.expand("~/repos/elixir/choreo")},
# {:kino_vizjs, "~> 0.9.0"}
# ])
Section
Rendering diagrams: This livebook uses
Choreo.Lab.SirenandChoreo.Lab.Sketchfor Mermaid diagrams, plusKino.VizJSfor DOT/Graphviz views. Some sequence examples also use Livebook's nativeKino.Mermaidsupport.
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.
API Approaches: Programmatic Pipe API vs Lab DSL
Choreo provides two complementary approaches to threat modeling:
- Programmatic Pipe API (
Choreo.ThreatModel): The canonical, explicit interface (ThreatModel.new() |> ThreatModel.add_trust_boundary(...) |> ThreatModel.add_process(...) |> ThreatModel.data_flow(...)). Ideal for automated security scans, static code ingestion, and CI/CD policy pipelines. - Lab DSL (
Choreo.Lab.DSL.ThreatModel): An expressive sketch syntax usingthreat_model do ... end, semantic element constructors (external_entity,process,data_store,service,db), scoped boundary blocks (boundary "Name", level: 0 do ... end), direct flows (~>), and security modifiers (|> encrypted("HTTPS", protocol: :https)). Ideal for Livebooks, architectural risk reviews, and rapid STRIDE sketching.
Example 1 demonstrates the canonical programmatic pipe API, while subsequent examples showcase the concise Lab DSL.
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.
import Choreo.Lab.DSL.ThreatModel
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.Layout.tabs(
Siren: Choreo.Lab.Siren.new(ThreatModel.to_mermaid(web_app)),
Graphviz: Kino.VizJS.render(ThreatModel.to_dot(web_app)),
Sketch: Choreo.Lab.Sketch.new(ThreatModel.to_mermaid(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 =
threat_model do
boundary "internet", level: 0 do
mobile_app = external_entity("Mobile App")
spa = external_entity("Web SPA")
end
boundary "dmz", level: 1 do
api_gateway =
process("API Gateway",
privilege: :none,
description: "Rate limiting, routing, WAF"
)
end
boundary "vpc", level: 2 do
auth_service =
process("Auth Service",
privilege: :admin,
description: "Issues and validates JWTs"
)
order_service = process("Order Service", privilege: :user)
payment_webhook =
process("Payment Webhook",
privilege: :none,
description: "Receives async callbacks from Stripe"
)
orders_db = data_store("Orders DB", sensitivity: :confidential)
redis_cache = data_store("Redis", sensitivity: :internal)
end
mobile_app ~> api_gateway |> encrypted("REST + mTLS")
spa ~> api_gateway |> encrypted("HTTPS")
api_gateway ~> auth_service |> encrypted("gRPC")
api_gateway ~> order_service |> encrypted("gRPC")
order_service ~> orders_db |> encrypted("SQL/TLS")
order_service ~> redis_cache |> flow("Redis protocol")
payment_webhook ~> order_service |> flow("Event bus")
end
Kino.Layout.tabs(
Siren: Choreo.Lab.Siren.new(ThreatModel.to_mermaid(microservices)),
Graphviz: Kino.VizJS.render(ThreatModel.to_dot(microservices)),
Sketch: Choreo.Lab.Sketch.new(ThreatModel.to_mermaid(microservices))
)
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, meta) do
# Cross-boundary flows with personal data that are unencrypted need DPA review
if meta[:encrypted] do
[]
else
[
%{
id: "GDPR-FLOW-#{from}-#{to}",
category: :compliance,
target: {from, to},
description: "Unencrypted data flow #{from} → #{to} may require Data Processing Agreement.",
severity: :high,
mitigation: "Review DPA coverage and SCCs for third-party processors."
}
]
end
end
end
saas =
threat_model do
boundary "public", level: 0 do
tenant_admin = external_entity("Tenant Admin")
end
boundary "tenant_isolation", level: 2 do
app_server = process("App Server", privilege: :user)
tenant_db = data_store("Tenant DB", sensitivity: :confidential, retention: "90d")
end
tenant_admin ~> app_server |> encrypted()
app_server ~> tenant_db |> encrypted()
end
# 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))
Example 4: Sequence Diagrams
While Data Flow Diagrams illustrate security boundaries, Sequence Diagrams visualize interactions chronologically. Choreo supports both Mermaid.js and PlantUML formats.
bt = String.duplicate("`", 3)
plantuml_block = bt <> "plantuml\n" <> ThreatModel.to_plantuml(microservices) <> "\n" <> bt
tabs = [
{"Mermaid", Kino.Mermaid.new(ThreatModel.to_sequence(microservices))},
{"PlantUML", Kino.Markdown.new(plantuml_block)}
]
Kino.Layout.tabs(tabs)
You can copy these payloads directly into PlantText, Mermaid Live Editor, or your Confluence macro blocks securely.
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 =
threat_model do
boundary "app", level: 2 do
api = process("API", id: :api, privilege: :admin)
vault = data_store("Vault", id: :vault, sensitivity: :restricted)
end
api ~> vault
end
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 =
threat_model do
api = process("API")
db = data_store("DB")
api ~> db
end
IO.puts("Issues found:")
Analysis.validate(sloppy) |> Enum.each(fn {sev, msg} -> IO.puts(" <#{sev}> #{msg}") end)
Now fix each issue step by step:
hardened =
threat_model do
boundary "app", level: 2 do
api = process("API", privilege: :user)
db = data_store("DB", sensitivity: :confidential)
end
api ~> db |> encrypted()
end
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))
Example 5: Multigraph (Parallel Data Flows)
Sometimes multiple distinct data payloads cross boundaries between the same entities (e.g., an HTTPS request and a WebSocket notification channel).
parallel_flows =
threat_model do
boundary "internet", level: 0 do
user = external_entity("User")
end
boundary "app", level: 2 do
api = process("API")
end
user ~> api |> encrypted("Payload (HTTPS)")
user ~> api |> unencrypted("Signal (WS)")
end
IO.puts("Number of data flows: #{length(ThreatModel.flows(parallel_flows))}")
Kino.VizJS.render(ThreatModel.to_dot(parallel_flows))
Example 6: Ingress, Blast Radius & Attack Path Highlighting
Threat modeling goes beyond cataloging static elements: security engineers need to trace ingress vectors, compute worst-case downstream compromise blast radius, and highlight multi-hop attack vectors on diagrams.
review_model =
threat_model do
internet = boundary("Internet", level: 0)
dmz = boundary("DMZ", level: 1)
internal = boundary("Internal Zone", level: 2)
vault = boundary("Data Vault", level: 3)
user = external("End User", boundary: internet, role: :user)
partner = external("Partner API", boundary: internet, role: :partner)
proxy = process("Reverse Proxy", boundary: dmz, privilege: :none, controls: [:rate_limiting, :waf])
api = process("Core API", boundary: internal, privilege: :user, controls: [:auth, :input_validation])
billing = process("Billing Service", boundary: internal, privilege: :admin)
db = database("Customer DB", boundary: vault, sensitivity: :restricted, controls: [:encryption_at_rest])
webhook = external("Partner Webhook", boundary: internet)
user ~> proxy |> authenticated("Login") |> encrypted()
partner ~> proxy |> authenticated("Partner API Call") |> encrypted()
proxy ~> api |> encrypted("Forward request")
api ~> billing |> carries(:payment_intent, :confidential) |> encrypted()
billing ~> db |> carries(:card_token, :restricted) |> protects([:tls, :integrity])
billing ~> webhook |> encrypted("Payment callback")
end
IO.puts("=== Ingress Entry Points ===")
ThreatModel.entry_points(review_model) |> IO.inspect()
IO.puts("\n=== Egress Exit Points ===")
ThreatModel.exit_points(review_model) |> IO.inspect()
IO.puts("\n=== Blast Radius if Proxy is compromised ===")
ThreatModel.blast_radius(review_model, :proxy) |> IO.inspect()
Visualizing Highlighted Attack Paths
You can highlight the attack paths directly on the diagram using ThreatModel.highlight_attack_paths/2:
highlighted_review = ThreatModel.highlight_attack_paths(review_model)
Kino.Layout.tabs(
Siren: Choreo.Lab.Siren.new(ThreatModel.to_mermaid(highlighted_review)),
Graphviz: Kino.VizJS.render(ThreatModel.to_dot(highlighted_review)),
Sketch: Choreo.Lab.Sketch.new(ThreatModel.to_mermaid(highlighted_review))
)
Example 7: Mitigations, Controls & Markdown Threat Matrices
Choreo tracks applied security controls (like MFA, rate limiting, encryption at rest, WAF) and automatically evaluates whether specific STRIDE threats are mitigated.
all_threats = Analysis.stride_threats(review_model)
unmitigated = ThreatModel.unmitigated_threats(review_model)
IO.puts("Total STRIDE Threats: #{length(all_threats)}")
IO.puts("Unmitigated Threats: #{length(unmitigated)}")
IO.puts("Mitigated Threats: #{length(all_threats) - length(unmitigated)}")
# Generate an executive GitHub Flavored Markdown threat table
table_markdown = ThreatModel.to_markdown(review_model)
Kino.Markdown.new(table_markdown)
Example 8: Reviewer Layer — Residual Risk, Control Gaps, and Findings
After the architecture is modeled, reviewers often need a concise checklist: what risk remains, where controls are missing, where sensitive data can leave the system, and which findings should be handled first.
risk_snapshot = %{
inherent: ThreatModel.Analysis.risk_score(review_model),
residual: ThreatModel.residual_risk_score(review_model)
}
Kino.Markdown.new("```elixir\n#{inspect(risk_snapshot, pretty: true)}\n```")
Boundary matrices summarize flows between trust zones, including encryption/authentication coverage and maximum observed sensitivity.
review_model
|> ThreatModel.boundary_matrix()
|> inspect(pretty: true)
|> then(fn code ->
Kino.Markdown.new("```elixir\n#{code}\n```")
end)
Control gaps turn common review heuristics into an actionable checklist.
control_gaps_md =
review_model
|> ThreatModel.control_gaps()
|> Enum.map(fn gap ->
"* `#{inspect(gap.target)}` — missing #{Enum.map_join(gap.missing, ", ", &to_string/1)}: #{gap.reason}"
end)
|> Enum.join("\n")
Kino.Markdown.new(control_gaps_md)
Exfiltration paths reverse the usual attack-path question: "Can confidential or restricted data flow outward to an external party?"
review_model
|> ThreatModel.exfiltration_paths()
|> Enum.map(&Enum.join(&1, " → "))
|> inspect(pretty: true)
|> Kino.Text.new()
For a release review, prioritized_findings/2 combines validation issues, exposed stores, unencrypted boundary flows, high-risk processes, exfiltration paths, and control gaps into a ranked finding list.
findings_md =
review_model
|> ThreatModel.prioritized_findings(max_findings: 8)
|> Enum.map(fn finding ->
"""
### #{finding.id}: #{finding.title}
- Severity: `#{finding.severity}`
- Kind: `#{finding.kind}`
- Target: `#{inspect(finding.target)}`
- Recommendation: #{finding.recommendation}
"""
end)
|> Enum.join("\n")
Kino.Markdown.new(findings_md)
Cheat Sheet
Lab DSL Syntax (Choreo.Lab.DSL.ThreatModel)
| Syntax | Description |
|---|---|
threat_model do ... end |
Define a threat model block |
boundary "Name", level: 0 do ... end |
Scoped trust boundary block with boundary inheritance |
b = boundary("Name", level: 0) / trust_boundary(...) / zone |
Standalone trust boundary definition |
u = external_entity("User") / user(...) / client(...) |
External entity node (browser, actor, third-party) |
p = process("API") / service(...) / worker(...) |
Process node (service, function, application code) |
db = data_store("DB") / database(...) / cache(...) |
Data store node (database, bucket, queue, cache) |
a ~> b |
Direct data flow edge |
| `a ~> b | > encrypted("HTTPS", protocol: :https)` |
| `a ~> b | > unencrypted("HTTP", protocol: :http)` |
| `a ~> b | > authenticated("Login")` |
| `a ~> b | > carries(:token, :restricted)` |
| `a ~> b | > controls([:waf, :rate_limiting])` |
| `a ~> b | > flow("Query", protocol: :sql)` |
| `a ~> b | > on("Label", encrypted: true, protocol: :https)` |
edge a ~> b, "Label", encrypted: true, protocol: :https |
Explicit edge statement with label and options |
edge a ~> b, encrypted: true |
Explicit edge statement with options |
Programmatic Pipe API & Analysis (Choreo.ThreatModel)
| Task / Feature | Command |
|---|---|
| Create Model | ThreatModel.new/1 (Opts: :strict) |
| Add Boundaries | ThreatModel.add_trust_boundary/3 (Opts: :level, :label, :style, :color, :fillcolor) |
| Add Elements | ThreatModel.add_external_entity/3, add_process/3 (Opts: :privilege, :controls), add_data_store/3 (Opts: :sensitivity, :retention, :controls) |
| Connect Data Flows | ThreatModel.data_flow/4 (Opts: :label, :protocol, :encrypted, :authenticated, :data, :sensitivity, :controls) |
| Ingress & Egress | ThreatModel.entry_points/1, ThreatModel.exit_points/1 |
| Blast Radius | ThreatModel.blast_radius/2 |
| Attack Path Visuals | ThreatModel.highlight_attack_paths/2, ThreatModel.clear_highlight/1 |
| Generate Threats | ThreatModel.Analysis.stride_threats/2 (Supports :rules, :only_unmitigated, :category, :severity) |
| Filter Threats | ThreatModel.unmitigated_threats/2, ThreatModel.threats_for/3 |
| Markdown Reporting | ThreatModel.to_markdown/2 (Opts: :summary) |
| Security Audits | ThreatModel.Analysis.unencrypted_boundary_flows/1, exposed_data_stores/1, high_risk_processes/1, attack_paths/2 |
| Reviewer Layer | ThreatModel.residual_risk_score/2, control_gaps/1, exfiltration_paths/2, boundary_matrix/1, prioritized_findings/2 |
| Integrity Validation | ThreatModel.Analysis.validate/2 (Opts: :require_levels), threat_summary/1 |
| Render Formats | ThreatModel.to_dot/2 (Graphviz DFD), ThreatModel.to_mermaid/2, to_sequence/2, to_plantuml/2 |
| Themes | ThreatModel.theme/2 (:default, :dark, :warm, :forest, :ocean) |
Summary
| Task | Function |
|---|---|
| Model architecture | ThreatModel.new/1, 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 |
| Mitigations & controls | Analysis.unmitigated_threats/2, Analysis.threats_for/3 |
| Trace entry & exit | Analysis.entry_points/1, Analysis.exit_points/1 |
| Blast radius calculation | Analysis.blast_radius/2 |
| Attack path visualizer | Analysis.highlight_attack_paths/2, ThreatModel.clear_highlight/1 |
| Markdown threat matrices | Analysis.to_markdown/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/2 |
| Residual risk | Analysis.residual_risk_score/2, ThreatModel.residual_risk_score/2 |
| Control gaps | Analysis.control_gaps/1, ThreatModel.control_gaps/1 |
| Exfiltration paths | Analysis.exfiltration_paths/2, ThreatModel.exfiltration_paths/2 |
| Boundary flow matrix | Analysis.boundary_matrix/1, ThreatModel.boundary_matrix/1 |
| Prioritized findings | Analysis.prioritized_findings/2, ThreatModel.prioritized_findings/2 |
| Validate model | Analysis.validate/2 |
| Summarise threats | Analysis.threat_summary/1 |
| Render DFD | ThreatModel.to_dot/2 (themes: :default, :dark, :warm, :forest, :ocean) |
| Render sequence | ThreatModel.to_sequence/2 (Mermaid), ThreatModel.to_plantuml/2 |
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.