The Threat Model Forge engine API exposes a small, versioned /v1 HTTP surface over the real
.NET engine, and serves the Studio single-page app from its root, so the API and
UI ship as one hosted artifact.
The API contract is the single source of truth: the OpenAPI document at /openapi/v1.json is what
Studio's typed client is generated from. A checked-in copy lives at
src/ThreatModelForge.Api/openapi/v1.json.
# From source: serves the built Studio SPA at the root.
dotnet run --project src/ThreatModelForge.Api # http://localhost:5205/
# Container: the published engine API + Studio image (pulls on first run).
docker run --rm -p 8080:8080 ghcr.io/hacks4snacks/tmforge # http://localhost:8080/Any non-API path falls back to Studio's index.html (so client-side routes resolve), while /v1
and /openapi are matched first.
| Method & path | Tag | Purpose |
|---|---|---|
GET /v1/health |
System | Liveness probe. |
GET /v1/formats |
Formats | List supported formats and their capabilities. |
POST /v1/detect |
Formats | Detect a file's format from its bytes. |
GET /v1/stencils |
Catalog | List element stencils. |
GET /v1/stencil-packs |
Catalog | List stencil packs. |
GET /v1/rules |
Catalog | List analysis rules. |
GET /v1/rule-packs |
Catalog | List rule packs. |
GET /v1/rule-bundle |
Catalog | Report which custom rule packs this host loaded, and any load diagnostics. |
GET /v1/property-schema |
Catalog | List the typed custom-property schema the rules read. |
POST /v1/model/analyze |
Model | Analyze a model and return findings. |
POST /v1/model/analysis |
Model | One analysis action: findings and threats from a single rule evaluation, plus the effective rule packs and diagnostics. |
POST /v1/model/analysis-document |
Model | Record the analysis as a versioned, reconcilable tmforge-analysis document. |
POST /v1/model/threats |
Model | Generate the STRIDE threat register (rule threats plus the model's author overlay). |
POST /v1/model/threat-register |
Model | Split the register by origin and standing: manual, current-generated, stale-generated, and entries whose rule was not part of the run. |
POST /v1/model/read |
Model | Parse uploaded bytes (base64) into the canonical model. |
POST /v1/model/convert?to=<format> |
Model | Convert a model to another format. |
POST /v1/model/export/tm7 |
Model | Export a model as a .tm7 file. |
POST /v1/model/report?format=<html|svg> |
Report | Render a model to an HTML or SVG report. |
POST /v1/model/analysis-report?format=<sarif|html|json> |
Report | Render the analysis findings as SARIF, HTML, or JSON. |
GET /openapi/v1.json |
n/a | The OpenAPI document. |
<format> is one of tm7, tmforge-json, drawio, or vsdx. See
Formats & interoperability.
Failures are answered as RFC 9457 problem documents
(application/problem+json), and the status separates what you can fix from what you cannot:
| Status | Meaning | Examples |
|---|---|---|
400 |
The request was unusable as sent. Retrying it unchanged cannot help. | Body that is not JSON; a missing ?to=; an unregistered format id; content that is not valid base64; uploaded bytes that are not the format they claim to be. |
404 |
The path is not part of the /v1 API, or the content was not recognized. |
A mistyped endpoint; POST /v1/detect on bytes matching no known format. |
500 |
The server failed. This one is worth reporting. | Anything unexpected — the classification above is deliberately narrow, so a real fault is never disguised as your mistake. |
The detail of a 400 names what was unusable, so the request can be corrected without guesswork:
{
"status": 400,
"title": "The request could not be processed as sent.",
"detail": "No threat model format with id 'nonsense'.",
"instance": "/v1/model/convert"
}An unmatched path under /v1 answers 404 as the API rather than falling through to the Studio's
HTML shell, so a mistyped endpoint fails as a client error instead of returning a page a JSON client
cannot parse. Paths outside /v1 still reach the SPA, which is what makes client-side routing work.
Findings and threats are the same detection: a threat is a finding from a rule that declares a threat
category, kept for its lifecycle (open → mitigated → accepted). Asking for them separately makes the
engine evaluate every enabled rule twice for one user action, so a UI that shows both should call
POST /v1/model/analysis, which evaluates once and projects both:
curl -s -X POST http://localhost:8080/v1/model/analysis \
-H 'Content-Type: application/json' --data @model.tmforge.json
# { "findings": […], "threats": […], "rulePacks": […], "diagnostics": [] }POST /v1/model/analyze and POST /v1/model/threats remain available and return exactly what the
combined action returns for their half; they exist for callers that genuinely need only one
projection, and they do not materialize the other.
Custom rules are deployment configuration, not request input: this host never loads rules from a
request body, so a caller cannot inject detection logic. Name the packs (files or directories) with
the TmForge:Rules setting and they are read once at startup, then applied to every rule-reading
endpoint — catalogs, analysis, threats, reports, and .tm7 export — as one effective bundle:
# a single pack, or a ';'-separated list
TmForge__Rules='/etc/tmforge/corporate.tmrules.json' dotnet ThreatModelForge.Api.dllConfirm what actually loaded before trusting a clean run:
curl http://localhost:8080/v1/rule-bundle
# { "rulePacks": [ { "id": "corporate", "version": "2.1", "fingerprint": "sha256:…", "ruleCount": 12 } ],
# "diagnostics": [] }A model may pin the packs it was reviewed with; a missing or changed pack becomes an error finding
with rule id rule-pack-mismatch. See
Custom rules on every surface.
curl http://localhost:8080/v1/healthcurl http://localhost:8080/v1/formats # what can be read/written and how faithfully
curl http://localhost:8080/v1/rules # the analysis rules
curl http://localhost:8080/v1/rule-packs # the rule packs (core-hygiene, stride-completeness, ...)
curl http://localhost:8080/v1/property-schema # typed custom properties the rules read
curl http://localhost:8080/v1/stencils # authoring stencilsPOST /v1/detect sniffs uploaded bytes and reports the matching format.
POST /v1/model/analyze returns findings for a supplied model, the same rule engine tmforge analyze
uses. This is how Studio's Analyze button overlays findings on the canvas.
Each finding's id is stable: {ruleId}:{diagram}:{target}:{occurrence}, where the diagram and
target segments are the element ids from the request model (they read model when the finding is
about the model or a whole diagram). The same model analyzed twice produces the same ids, and
enabling or disabling an unrelated rule leaves the other ids alone — so a caller can reconcile a
finding against a previous run. See
finding identity for the details and the one caveat.
POST /v1/model/analysis-document returns a versioned tmforge-analysis document — the analysis as
evidence rather than as something to render:
Every finding carries exactly one disposition:
| Disposition | Meaning |
|---|---|
generated-threat |
Threat-bearing and not yet triaged. |
unresolved |
Threat-bearing and explicitly marked as needing investigation. |
accepted |
Threat-bearing and accepted as a risk. |
mitigated |
Threat-bearing and mitigated. |
hygiene |
The rule declares no threat category, so this is a modelling-quality observation, not a risk. |
suppressed |
A suppression silenced it; it is recorded rather than dropped. |
The four threat-bearing dispositions always carry a threatId that joins to
POST /v1/model/threats; hygiene and suppressed never do. That separation is the point: a
reviewer should not have to accept "this diagram has no trust boundary" as a risk in order to clear
a gate.
Two things make the document comparable between runs. The fingerprints let a consumer detect that a stored analysis no longer describes the model or rule selection in front of it — the model fingerprint covers the structural model only, so triaging a threat does not report the model as changed, while loading a pack or disabling a rule does move the analyzer fingerprint. And the document carries no timestamp, so two analyses of the same inputs are byte-identical and a diff shows only what actually changed. When a run happened is something CI already records; putting it here would cost the property the artifact exists for.
POST /v1/model/threats returns the model's STRIDE threat register the same rule findings as
analyze, framed as threats and overlaid with the model's author-owned state. The request model's
threats overlay carries risk acceptance, per-threat edits (state, priority, mitigation, description),
and manually-authored threats (manual: true, keyed in the reserved manual: namespace, scoped to
element ids or model-wide). Those edits and manual threats round-trip into the exported .tm7 register,
so a threat accepted or authored in Studio opens in the Microsoft Threat Modeling Tool. This powers
Studio's threat panel and the tmforge threats verb.
A manual entry whose id is not a usable identity — malformed, or already claimed by another threat —
is reported in diagnostics rather than silently dropped, and the first entry to claim an id keeps it.
POST /v1/model/threat-register classifies every register entry against one analysis run and returns
the counts plus a per-entry state:
| State | Meaning |
|---|---|
manual |
An author wrote it. Rules never produce or retire it. |
current-generated |
The current rules still produce it. |
stale-generated |
Stored, but the rule ran and no longer produces it. |
indeterminate-generated |
Its rule was not part of this run, so its standing cannot be judged. |
Applying a generation result never deletes, so triage survives every re-run — which is exactly why a
left-over entry needs to be distinguishable from a live one. Staleness is only ever claimed when the
rule actually ran; an entry whose rule is missing or disabled is counted in indeterminateGenerated
and its rule named in unavailableRuleIds, never assumed stale.
The counts are not a partition and must not be summed. What "stored" means also depends on the
format: a .tm7 carries the full generated register, while canonical tmforge-json persists only
author-owned state — so on this endpoint staleGenerated is most useful as triage that no longer
matches any threat the rules produce.
# Convert (target chosen by the `to` query parameter):
# POST /v1/model/convert?to=drawio
# Export a .tm7 specifically:
# POST /v1/model/export/tm7Both .tm7 paths embed the Threat Model Forge knowledge base and write typed properties, so the
exported file opens in the Microsoft Threat Modeling Tool. See
Formats.
POST /v1/model/report?format=html (or format=svg) renders a report from a model, the hosted
equivalent of tmforge report. Multi-page models render every diagram: the HTML report has one
section per page, and the SVG stacks the pages. HTML reports generate the enabled rule-backed threats
on demand, overlay the model's manual threats and triage, and show rule id, STRIDE category, scope,
priority, mitigation, references, and decision note. SVG output renders diagrams only.
POST /v1/model/analysis-report?format=sarif (or html / json) renders the analysis artifacts
instead — the findings evidence, not the threat-model document. These are the same artifacts
tmforge analyze --reportFolder writes, so a report served here and a file written in CI are the
same document. An unrecognized format falls back to the readable findings HTML.
curl -s -X POST 'http://localhost:8080/v1/model/analysis-report?format=sarif' \
-H 'Content-Type: application/json' --data @model.tmforge.json -o findings.sarifBoth report endpoints run the host's configured rule packs and honor the model's own disabled selection, so a report always matches the analysis it claims to describe.
The document is produced by the ASP.NET Core OpenAPI integration and served at /openapi/v1.json.
After changing the /v1 contract, refresh the checked-in copy so Studio's generated client stays in
sync (Studio's npm run gen:api reads that file). See the
Studio guide.
- The container listens on port 8080; from source it listens on 5205.
- In development the API permits CORS from the Studio dev server at
http://localhost:5199. - The API is stateless: it operates on the model bytes you send it, so it scales horizontally. See Deployment.
- Studio guide: the browser client of this API.
- Deployment: running the API in containers and Kubernetes.
- Formats & interoperability: the formats the model endpoints speak.
{ "schema": "tmforge-analysis", "version": 1, "model": { "name": "Payments", "fingerprint": "sha256:b5c79b9a…" }, "analyzer": { "name": "ThreatModelForge.Analysis", "version": "0.7.0.0", "fingerprint": "sha256:6f6d4de7…" }, "rulePacks": [], "findings": [ { "id": "TM1013:7e3f1d52…:f1:0", "ruleId": "TM1013", "severity": "warning", "message": "…", "diagram": "7e3f1d52…", "elementIds": ["f1"], "disposition": "generated-threat", "threatId": "80f5b13b…:TM1013" } ], "diagnostics": [] }