Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion build/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,25 @@
# Build from the repo root:
# docker build -f build/Dockerfile -t tmforge-cli .
# docker buildx build -f build/Dockerfile --platform linux/amd64,linux/arm64 -t tmforge-cli .
# Behind a package mirror, point the restore at it:
# docker build -f build/Dockerfile --build-arg NUGET_FEED=<mirror-v3-index-url> -t tmforge-cli .

# Build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src

# The NuGet v3 index to restore from. Defaults to nuget.org; override it on a network that reaches
# only an internal mirror, where the checked-in NuGet.config source is unreachable.
ARG NUGET_FEED=https://api.nuget.org/v3/index.json

# Copy repo-level MSBuild configuration first for better layer caching.
COPY global.json NuGet.config Directory.Build.props Directory.Packages.props stylecop.json .editorconfig ./
COPY src/ ./src/

# Publish the CLI as a framework-dependent, portable (AnyCPU) app. It runs on whatever
# architecture the base image is, so no per-RID build is needed here.
RUN dotnet publish src/ThreatModelForge.Cli/ThreatModelForge.Cli.csproj -c Release -o /app/tmforge
RUN dotnet publish src/ThreatModelForge.Cli/ThreatModelForge.Cli.csproj -c Release -o /app/tmforge \
-p:RestoreSources="${NUGET_FEED}"

# Runtime stage
# Standard runtime image: it has a shell so the PATH wrapper script below works.
Expand Down
16 changes: 14 additions & 2 deletions build/Dockerfile.api
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
# Build from the repo root:
# docker build -f build/Dockerfile.api -t tmforge .
# docker buildx build -f build/Dockerfile.api --platform linux/amd64,linux/arm64 -t tmforge .
# Behind package mirrors, point the restores at them:
# docker build -f build/Dockerfile.api --build-arg NUGET_FEED=<mirror-v3-index-url> \
# --build-arg NPM_REGISTRY=<mirror-npm-url> -t tmforge .
# Run it:
# docker run --rm -p 8080:8080 tmforge # then open http://localhost:8080/

Expand All @@ -14,22 +17,31 @@
# SPA in a dedicated node stage instead and skip that step during publish (/p:BuildStudio=false).
FROM mcr.microsoft.com/azurelinux/base/nodejs:24 AS spa
WORKDIR /studio

# The npm registry to install from. Defaults to the public one; override it on a network that
# reaches only an internal mirror.
ARG NPM_REGISTRY=https://registry.npmjs.org/

# Install deps first (cached until the lockfile changes).
COPY src/ThreatModelForge.Studio/package.json src/ThreatModelForge.Studio/package-lock.json ./
RUN npm ci
RUN npm config set registry "${NPM_REGISTRY}" && npm ci
COPY src/ThreatModelForge.Studio/ ./
RUN npm run build

# publish the API (.NET)
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src

# The NuGet v3 index to restore from. See NPM_REGISTRY above.
ARG NUGET_FEED=https://api.nuget.org/v3/index.json

COPY global.json NuGet.config Directory.Build.props Directory.Packages.props stylecop.json .editorconfig ./
COPY src/ ./src/

# Drop the pre-built SPA into the API's wwwroot, then publish without re-running the npm step.
COPY --from=spa /studio/dist/ ./src/ThreatModelForge.Api/wwwroot/
RUN dotnet publish src/ThreatModelForge.Api/ThreatModelForge.Api.csproj -c Release -o /app /p:BuildStudio=false
RUN dotnet publish src/ThreatModelForge.Api/ThreatModelForge.Api.csproj -c Release -o /app \
-p:BuildStudio=false -p:RestoreSources="${NUGET_FEED}"

# runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
Expand Down
41 changes: 36 additions & 5 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ tmforge <command> [options] <file>
| [`rename`](#rename) | Author | Rename an element. |
| [`set`](#set) | Author | Set an element/flow's name or properties. |
| [`page`](#page) | Author | List, add, rename, reorder, or remove pages (diagrams). |
| [`layout`](#layout) | Author | Auto-lay-out the diagram (layered; no hand-placed coordinates). |
| [`layout`](#layout) | Author | Auto-lay-out the diagram (layered, boundary aware; places flow labels). |
| [`rules`](#rules) | Analyze | Compile an MTMT `.tb7` template into a versioned rule pack. |
| [`analyze`](#analyze) | Analyze | Evaluate the analysis rules against a model. |
| [`analysis`](#analysis) | Analyze | Validate a stored analysis document (and check whether it is stale), or compare two of them. |
Expand Down Expand Up @@ -511,19 +511,38 @@ tmforge page reorder payments.tm7 --page "Payments service" --to 1
### `layout`

Apply a deterministic **layered auto-layout** so you never hand-place coordinates: components are
arranged left-to-right by their data flows and connectors are re-routed. Trust boundaries are left in
place, so run this to tidy a graph (it arranges the data-flow graph rather than preserving boundary
placement).
arranged left-to-right by their data flows, connectors are re-routed, and flow labels are placed
clear of the shapes and of one another.

Layout is **trust-boundary aware**. Every component keeps the boundary it was inside, each boundary
is resized around exactly the members it holds, and the boundaries are then arranged by the flows
between them. That matters because boundary membership is what the analysis is derived from: an
arrangement that moved a component out of its boundary would change what the model means, not just
how it looks. Columns wrap onto a new row instead of running past the right-hand edge, because the
Microsoft Threat Modeling Tool's drawing surface is bounded and taller than it is wide.

```text
tmforge layout [--page <name|index>] [--node-spacing <n>] [--layer-spacing <n>] [--json] <model>
tmforge layout [--page <name|index>] [--node-spacing <n>] [--layer-spacing <n>] [--labels] [--check] [--json] <model>
```

| Option | Meaning |
| --- | --- |
| `--labels` | Place only the flow labels and leave every shape exactly where it is. Use this when the geometry is hand-placed or comes from a manifest and only the labels need sorting out. |
| `--check` | Report obstructed flow labels and write nothing. Exits `1` when any remain, so a publishing gate can require a legible diagram. |

```bash
tmforge layout payments.tm7
tmforge layout payments.tm7 --node-spacing 60 --layer-spacing 120
tmforge layout payments.tm7 --labels # keep the authored geometry, fix the labels
tmforge layout payments.tm7 --check --json # gate on legibility
```

A flow's name is drawn as a **single unwrapped line** centred on its connector, so a long name needs
far more room than the flow it names — at the tool's default font a fifty-character name is wider
than a typical trust boundary. `tmforge apply` places labels as well as it can, but placement cannot
shorten text: when `--check` still reports overlaps, the remedy is shorter flow names (with the
sentence moved into a property such as `Description`) or fewer objects per page.

---

## Analysis, reporting & conversion
Expand Down Expand Up @@ -867,6 +886,12 @@ A `.tm7` target embeds the Threat Model Forge knowledge base by default so the f
`--knowledge-base <file.tb7>` to embed a specific one instead. A knowledge base already present in the
source model (for example, a file authored in the tool) is preserved.

A `.tm7` target also gets its flow labels placed, because the tool draws each flow's name on its
connector and most source formats carry no label position at all — `tmforge-json`, the canonical wire
model Studio and the API exchange, records only a flow's endpoints and name. Without this, every label
converted from one of them would land on its connector's midpoint and flows sharing a pair of
endpoints would print their names on top of each other. A label the source did position is preserved.

```bash
tmforge convert payments.tm7 --to drawio --out payments.drawio
tmforge convert payments.drawio --to tm7 --out payments.tm7
Expand Down Expand Up @@ -1003,6 +1028,12 @@ tmforge apply model.json --out model.tm7
tmforge apply model.json --dry-run
```

Shapes go exactly where the manifest asks, but a flow's label has no coordinates to declare: the tool
draws the name on the connector itself, so two flows between one pair of elements would print their
names on the same spot. Every write to `.tm7` therefore places the labels for you, adjusting only the
connectors' curve handles — nothing the analysis reads, and never a label somebody has already moved.
Run [`tmforge layout --check`](#layout) on the result to confirm none is still covered.

A manifest is a model's *source*, not a model. The read-only verbs (`open`, `list`, `show`,
`analyze`, …) take a model file, so pointing one at a manifest reports that and names the `apply`
command to run first. The same recognition lets **Studio** open a manifest directly — see
Expand Down
21 changes: 20 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,23 @@ docker build -f build/Dockerfile.api -t tmforge .
docker run --rm -p 8080:8080 tmforge # -> http://localhost:8080/
```

### Building behind a package mirror

Both image builds restore from the public package feeds by default. On a network that reaches only
an internal mirror, point them at it — the build needs no other change:

```bash
docker build -f build/Dockerfile.api \
--build-arg NUGET_FEED=https://<mirror>/nuget/v3/index.json \
--build-arg NPM_REGISTRY=https://<mirror>/npm/ \
-t tmforge .
```

| Build argument | Default | Used by |
| --- | --- | --- |
| `NUGET_FEED` | `https://api.nuget.org/v3/index.json` | Both images; overrides the source in `NuGet.config`. |
| `NPM_REGISTRY` | `https://registry.npmjs.org/` | `Dockerfile.api` only, for the Studio SPA. |

- Studio: `http://localhost:8080/`
- API: `http://localhost:8080/v1/...`
- OpenAPI: `http://localhost:8080/openapi/v1.json`
Expand Down Expand Up @@ -197,7 +214,9 @@ docker build -f build/Dockerfile -t tmforge-cli .
docker run --rm -v "$PWD:/work" tmforge-cli tmforge analyze model.tm7
```

The image mounts your files at `/work`, so paths in your commands are relative to it.
The image mounts your files at `/work`, so paths in your commands are relative to it. Behind a
package mirror, pass `--build-arg NUGET_FEED=<mirror-v3-index-url>` — see
[Building behind a package mirror](#building-behind-a-package-mirror).

## CI/CD

Expand Down
12 changes: 12 additions & 0 deletions src/ThreatModelForge.Analysis/Tm7ExportPreparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ public static void Prepare(ThreatModel model, RuleSet ruleSet)
throw new ArgumentNullException(nameof(ruleSet));
}

// Give any flow label that has no recorded position one, before the shift below, so the
// normalization accounts for where the labels actually ended up. The tool prints a flow's
// name on its connector, and a model arriving from a format that carries no connector
// geometry — the canonical tmforge-json the API and Studio speak — has no positions at
// all, so every label would otherwise land on its connector's midpoint and flows sharing a
// pair of endpoints would print their names on top of each other. A label an author placed
// is left exactly where it is.
foreach (DrawingSurfaceModel surface in model.DrawingSurfaceList)
{
DiagramLabels.DeconflictUnplaced(surface);
}

// Shift each surface so no element sits below the tool's minimum drawing coordinate. This is
// independent of the knowledge base, so it runs before the foreign-knowledge-base short
// circuit below.
Expand Down
2 changes: 1 addition & 1 deletion src/ThreatModelForge.Cli/CommandCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ internal static class CommandCatalog
new CommandInfo("rename", "Rename an element.", "id, name", RenameCommand.Run),
new CommandInfo("set", "Set an element/flow's name or properties (protocol, port, auth, ...).", "id, name, properties{}", SetCommand.Run),
new CommandInfo("page", "List, add, rename, reorder, or remove pages (diagrams).", "ls: count, items[]; add: index, name, id; rename: id, name; rm: id, name, remaining; reorder: id, name, index", PageCommand.Run),
new CommandInfo("layout", "Auto-lay-out the diagram (layered; no hand-placed coordinates).", "pages, components", LayoutCommand.Run),
new CommandInfo("layout", "Auto-lay-out the diagram (layered, boundary aware; --labels places only the flow labels, --check reports obstructed ones).", "pages, components, labelsMoved, labelOverlaps; with --check also overlaps[]{flow,obstructedBy,kind,area}", LayoutCommand.Run),
new CommandInfo("rules", "Compile MTMT templates into versioned analysis rule packs.", "operation,input,output,strict,status,packId,packName,sourceCount,emittedCount,skippedCount,warningCount,categoryDistribution{},diagnostics[]", RulesCommand.Run),
new CommandInfo("analyze", "Analyze a threat model against its analysis rules.", "a SARIF-style model report (runs[].results[]); see docs/cli-reference.md", AnalyzeCommand.Run),
new CommandInfo("analysis", "Validate a stored tmforge-analysis document (optionally against its model).", "operation, path, status, stale, schemaVersion, findingCount, problems[]", AnalysisCommand.Run),
Expand Down
Loading
Loading