diff --git a/README.md b/README.md index 1fbc726..b41a563 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ code, in your browser, your terminal, and your CI pipeline.** The Microsoft Threat Modeling Tool (MTMT) is Windows-only, GUI-only, and can't run in a pipeline. Threat Model Forge keeps its file format, reading and writing `.tm7` files **byte-for-byte losslessly**, and removes everything else: author models in a browser Studio or a headless CLI, -diff and merge them like source code, validate them against **built-in security and hygiene +diff and merge them like source code, analyze them against **built-in security and hygiene rules**, and gate a build on the result. No Windows, no GUI required. **Try it now, no install:** the full editor and validation engine run client-side (WebAssembly) at @@ -57,7 +57,7 @@ tmforge new payments.tm7 --name "Payments" tmforge add process payments.tm7 --name "Checkout API" tmforge add store payments.tm7 --name "Orders DB" tmforge add boundary payments.tm7 --name "Azure VNet" -tmforge lint payments.tm7 # validate: exits 2 on findings, CI-ready +tmforge analyze payments.tm7 # analyze: exits 2 on findings, CI-ready tmforge report payments.tm7 --out payments.html ``` @@ -77,7 +77,7 @@ open as-is; see [Formats & interoperability](docs/formats.md). - **Convert** between `.tm7`, `tmforge-json`, draw.io, and Visio. - **Report** to self-contained HTML (with inline SVG diagrams), or `render` the diagram right in your terminal. -- **Validate in CI** with the `tmforge` CLI (`tmforge lint`), gating builds on SARIF-reported +- **Analyze in CI** with the `tmforge` CLI (`tmforge analyze`), gating builds on SARIF-reported findings. ## Documentation @@ -145,7 +145,7 @@ Pull the published multi-arch images from GitHub Container Registry: docker run --rm -p 8080:8080 ghcr.io/hacks4snacks/tmforge # -> http://localhost:8080/ # CLI tool -docker run --rm -v "$PWD:/work" ghcr.io/hacks4snacks/tmforge-cli tmforge lint model.tm7 +docker run --rm -v "$PWD:/work" ghcr.io/hacks4snacks/tmforge-cli tmforge analyze model.tm7 ``` Published tags include `latest`, the release version (e.g. `0.1.0`), `0.1`, and `edge` (latest diff --git a/build/Dockerfile b/build/Dockerfile index 043fe6a..f93836d 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -28,14 +28,14 @@ RUN printf '#!/bin/sh\nexec dotnet /opt/tmforge/tmforge.dll "$@"\n' > /usr/local && chmod +x /usr/local/bin/tmforge # Build-time smoke test: author a throwaway model and drive it through the CLI to prove the image -# runs. `report` must succeed (exit 0); `lint` must run the analysis — exit 0 (clean) or 2 +# runs. `report` must succeed (exit 0); `analyze` must run the analysis — exit 0 (clean) or 2 # (findings), but not 1 (could not run). RUN set -eu; \ tmforge new /tmp/smoke.tm7 --name smoke; \ tmforge report /tmp/smoke.tm7 --out /tmp/smoke.html; \ - code=0; tmforge lint /tmp/smoke.tm7 || code=$?; [ "$code" -eq 0 ] || [ "$code" -eq 2 ]; \ + code=0; tmforge analyze /tmp/smoke.tm7 || code=$?; [ "$code" -eq 0 ] || [ "$code" -eq 2 ]; \ rm -f /tmp/smoke.tm7 /tmp/smoke.html -# Default to the umbrella command's help; override with e.g. `docker run tmforge lint ...`. +# Default to the umbrella command's help; override with e.g. `docker run tmforge analyze`. ENTRYPOINT ["tmforge"] CMD ["--help"] diff --git a/docs/README.md b/docs/README.md index b167092..cdb8de5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,13 +19,13 @@ Microsoft Threat Modeling Tool (MTMT). | Guide | What it covers | | --- | --- | | [Overview & features](overview.md) | Concepts, the three surfaces (CLI, Studio, API), formats, and rules at a glance. | -| [Quick start](quickstart.md) | Author, validate, and report on your first model. | +| [Quick start](quickstart.md) | Author, analyze, and report on your first model. | | [Installation](installation.md) | Prebuilt binaries, container images, the .NET global tool, and building from source. | | [CLI reference](cli-reference.md) | Every `tmforge` command, its options, exit codes, and JSON output. | | [Studio guide](studio-guide.md) | Browser-based diagram authoring with the React Studio SPA. | | [Engine API reference](api-reference.md) | The versioned `/v1` HTTP surface and its endpoints. | | [Formats & interoperability](formats.md) | `.tm7`, `tmforge-json`, draw.io, and Visio import/export and fidelity. | -| [Validation rules & CI](validation-rules.md) | The built-in rule set, rule packs, suppressions, and gating a build. | +| [Analysis rules & CI](analysis-rules.md) | The built-in rule set, rule packs, suppressions, and gating a build. | | [Deployment](deployment.md) | Running the engine API + Studio in containers, Kubernetes, and CI/CD. | ## The three surfaces diff --git a/docs/validation-rules.md b/docs/analysis-rules.md similarity index 79% rename from docs/validation-rules.md rename to docs/analysis-rules.md index a6c046a..b170e55 100644 --- a/docs/validation-rules.md +++ b/docs/analysis-rules.md @@ -1,15 +1,15 @@ -# Validation rules & CI +# Analysis rules & CI Threat Model Forge ships a built-in rule set that runs against any model and flags completeness, -diagram-hygiene, and security-property issues. The same engine backs `tmforge lint`, Studio's -**Validate** button, and the API's `POST /v1/model/validate`. +diagram-hygiene, and security-property issues. The same engine backs `tmforge analyze`, Studio's +**Analyze** button, and the API's `POST /v1/model/analyze`. -## Running validation +## Running analysis ```bash -tmforge lint model.tm7 # human-readable -tmforge lint model.tm7 --json # machine-readable envelope -tmforge lint model.tm7 --reportFolder ./out # + SARIF, HTML, and JSON listing +tmforge analyze model.tm7 # human-readable +tmforge analyze model.tm7 --json # machine-readable envelope +tmforge analyze model.tm7 --reportFolder ./out # + SARIF, HTML, and JSON listing ``` ### Exit codes @@ -25,7 +25,7 @@ The dedicated `2` lets CI **fail on findings** while distinguishing them from a ### Severities Findings carry a severity: **Error**, **Warning**, or **Info**. By default only **Error** findings set -the "found issues" exit code (`2`); pass `--max-severity warning` (or `info`) to `tmforge lint` to gate +the "found issues" exit code (`2`); pass `--max-severity warning` (or `info`) to `tmforge analyze` to gate the build on those severities too. ## Rule packs @@ -42,6 +42,7 @@ that declare them. List them from the API with `GET /v1/rule-packs`. | `data-protection` | Data Protection | Data-at-rest protection: encryption, access control, integrity, retention. | | `transport-security` | Transport Security | Data-in-transit protection across trust boundaries. | | `identity-access` | Identity & Access | Authentication, least privilege, and access to components. | +| `availability` | Availability | Recoverability and audit-trail durability: backups for important data. | ## Built-in rules @@ -72,6 +73,7 @@ snapshot. `tmforge` and the engine's `GET /v1/rules` report the live rule set. | 1009 | Edge missing protocol description | Info | A flow mentions its protocol in the description text. | | 1010 | Edge missing port | Warning | A flow declares a port when it can't be inferred from the protocol. | | 1013 | Edge missing data classification | Warning | A flow declares a data classification. | +| 1029 | Unaudited boundary process | Warning | A process receiving input across a trust boundary writes to an audit-log store so its actions can be attributed. | ### Input Validation (`input-validation`) @@ -91,6 +93,7 @@ snapshot. `tmforge` and the engine's `GET /v1/rules` report the live rule set. | 1022 | Credentials in log store | Warning | A store recording log data does not also store credentials. | | 1025 | Weak or unapproved cipher | Warning | A flow or store declaring an encryption algorithm uses an approved authenticated cipher (AES-GCM, AES-CBC+HMAC, or ChaCha20-Poly1305). | | 1027 | Cached credential read | Warning | A flow reading from a credential store is not cached (`Cached=No`), so a rotated or revoked credential is not served stale. | +| 1030 | Sensitive data to external | Warning | A flow carrying sensitive data (EUII, EUPI, customer content, account data, or access-control data) is not sent to an external interactor. | ### Transport Security (`transport-security`) @@ -107,12 +110,18 @@ snapshot. `tmforge` and the engine's `GET /v1/rules` report the live rule set. | 1024 | Over-privileged process | Warning | A process does not run as a highly privileged account (root/admin/system). | | 1026 | Shared static identity | Warning | A single `Identity` is not asserted by flows from 2+ distinct sources; each calling principal has its own scoped identity. | +### Availability (`availability`) + +| ID | Rule | Severity | Checks | +| --- | --- | --- | --- | +| 1028 | Data store without backup | Warning | A store holding credentials or audit/log data declares a backup (`Backup=Yes`), so its contents can be recovered after loss or a destructive attack. | + ## Rule help Every finding carries the rule's **ID** (for example `TM1016`). To see what a rule checks and how to clear it: -- **Studio**: open the **Validation** panel and click the **?** on a rule to expand its description +- **Studio**: open the **Analysis Rules** panel and click the **?** on a rule to expand its description and fix guidance in place. That text ships with the engine, so it always matches the rule that ran. - **CLI / API**: `GET /v1/rules` returns each rule's `description`, `helpText`, and `helpUri`, and both the HTML report and SARIF carry the rule's `helpUri` for code-scanning dashboards. @@ -136,21 +145,22 @@ tmforge set model.tm7 --id --property AuthenticationScheme=OAuth Common rule-checked properties include `Protocol`, `Port`, `DataType` / data classification, `AuthenticationScheme`, `SanitizesInput` / `SanitizesOutput`, `Isolation`, `AccessControl`, `Signed`, -`AuthenticatesItself`, `RunningAs`, `Algorithm`, and encryption/at-rest flags. In -[Studio](studio-guide.md), edit the same properties in the inspector and re-**Validate**. +`AuthenticatesItself`, `RunningAs`, `Algorithm`, `StoresCredentials` / `StoresLogData`, `Backup`, and +encryption/at-rest flags. In [Studio](studio-guide.md), edit the same properties in the inspector and +re-**Analyze**. ## Customizing the rule set ### Selecting rules and packs -When a model is loaded from the native **`tmforge-json`** format, any embedded validation selection +When a model is loaded from the native **`tmforge-json`** format, any embedded analysis selection (disabled packs or rule ids) is honored automatically, so a model can carry its own policy. Other formats (for example `.tm7`) use the full rule set unless you pass an explicit `--ruleset`. ### Custom rule set file ```bash -tmforge lint model.tm7 --ruleset ./my-ruleset.xml +tmforge analyze model.tm7 --ruleset ./my-ruleset.xml ``` ### Rule variables @@ -158,7 +168,7 @@ tmforge lint model.tm7 --ruleset ./my-ruleset.xml Some rules read variables supplied on the command line (repeatable): ```bash -tmforge lint model.tm7 --define key=value --define another=value +tmforge analyze model.tm7 --define key=value --define another=value ``` ## Suppressions @@ -166,7 +176,7 @@ tmforge lint model.tm7 --define key=value --define another=value Filter known/accepted findings with a suppression document: ```bash -tmforge lint model.tm7 --suppressionFile ./suppressions.json +tmforge analyze model.tm7 --suppressionFile ./suppressions.json ``` Suppressions are matched per model path and applied before evaluation, so suppressed findings don't @@ -181,7 +191,7 @@ affect the exit code. - **JSON listing**: a structured enumeration of the model. ```bash -tmforge lint model.tm7 --reportFolder "$CI_ARTIFACTS/threatmodel" +tmforge analyze model.tm7 --reportFolder "$CI_ARTIFACTS/threatmodel" ``` ## CI integration @@ -193,21 +203,21 @@ to your CI. name: threat-model on: [pull_request] jobs: - validate: + analyze: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Download tmforge run: | curl -fsSL -o tmforge.tar.gz \ - https://github.com/OWNER/REPO/releases/download/v0.1.0/tmforge-0.1.0-linux-x64.tar.gz + https://github.com/hacks4snacks/tmforge/releases/download/v0.1.0/tmforge-0.1.0-linux-x64.tar.gz tar -xzf tmforge.tar.gz echo "$PWD/tmforge-0.1.0-linux-x64" >> "$GITHUB_PATH" - - name: Validate threat models + - name: Analyze threat models run: | set -e for model in $(git ls-files '*.tm7'); do - tmforge lint "$model" --reportFolder "reports/$(basename "$model")" + tmforge analyze "$model" --reportFolder "reports/$(basename "$model")" done - name: Upload SARIF if: always() @@ -216,11 +226,11 @@ jobs: sarif_file: reports ``` -`tmforge lint` returns `2` when a model has findings, which fails the step; `1` signals a tool error. +`tmforge analyze` returns `2` when a model has findings, which fails the step; `1` signals a tool error. See the [deployment guide](deployment.md#cicd) for container-based pipelines. ## See also -- [CLI reference: `lint`](cli-reference.md#lint): all options. -- [Overview & features](overview.md): where validation fits. -- [Engine API reference](api-reference.md): `POST /v1/model/validate` and the catalog endpoints. +- [CLI reference: `analyze`](cli-reference.md#analyze): all options. +- [Overview & features](overview.md): where analysis fits. +- [Engine API reference](api-reference.md): `POST /v1/model/analyze` and the catalog endpoints. diff --git a/docs/api-reference.md b/docs/api-reference.md index 64061b8..989bb71 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -33,7 +33,7 @@ and `/openapi` are matched first. | `GET /v1/rules` | Catalog | List analysis rules. | | `GET /v1/rule-packs` | Catalog | List rule packs. | | `GET /v1/property-schema` | Catalog | List the typed custom-property schema the rules read. | -| `POST /v1/model/validate` | Model | Validate a model and return findings. | +| `POST /v1/model/analyze` | Model | Analyze a model and return findings. | | `POST /v1/model/read` | Model | Parse uploaded bytes (base64) into the canonical model. | | `POST /v1/model/convert?to=` | Model | Convert a model to another format. | | `POST /v1/model/export/tm7` | Model | Export a model as a `.tm7` file. | @@ -55,7 +55,7 @@ curl http://localhost:8080/v1/health ```bash curl http://localhost:8080/v1/formats # what can be read/written and how faithfully -curl http://localhost:8080/v1/rules # the validation rules +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 stencils @@ -65,10 +65,10 @@ curl http://localhost:8080/v1/stencils # authoring stencils `POST /v1/detect` sniffs uploaded bytes and reports the matching format. -### Validate a model +### Analyze a model -`POST /v1/model/validate` returns findings for a supplied model, the same rule engine `tmforge lint` -uses. This is how Studio's **Validate** button overlays findings on the canvas. +`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. ### Convert / export @@ -79,6 +79,10 @@ uses. This is how Studio's **Validate** button overlays findings on the canvas. # POST /v1/model/export/tm7 ``` +Both `.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](formats.md#tm7-and-the-microsoft-threat-modeling-tool). + ### Report `POST /v1/model/report?format=html` (or `format=svg`) renders a report from a model, the hosted diff --git a/docs/cli-reference.md b/docs/cli-reference.md index c795fab..4cb046c 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -43,7 +43,9 @@ tmforge [options] | [`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). | -| [`lint`](#lint) | Validate | Evaluate the rule set against a model. | +| [`analyze`](#analyze) | Analyze | Evaluate the analysis rules against a model. | +| [`threats`](#threats) | Analyze | Report threats — the persisted, triaged view of the analysis findings (`--write` to persist). | +| [`accept`](#accept) | Analyze | Accept a generated threat's risk (records a justification). | | [`report`](#report) | Report | Generate a self-contained HTML report. | | [`convert`](#convert) | Convert | Convert a model between file formats. | | [`apply`](#apply) | Author | Build a model from a declarative JSON manifest (all-or-nothing). | @@ -120,7 +122,7 @@ tmforge stencils --pack azure --json List the built-in **typed property schema**: the custom properties the linter reads and Studio edits, with each property's value kind, allowed values, and default. This is the same schema the API serves at `GET /v1/property-schema`; use it to discover closed enums (for example `Channel`, -`Encrypted`, `AccessControl`, or the approved cipher list) without running `lint` first. +`Encrypted`, `AccessControl`, or the approved cipher list) without running `analyze` first. ```text tmforge properties [--base ] [--explain] [--json] @@ -133,7 +135,7 @@ tmforge properties --base datastore --json | jq '.data.properties' ``` Add `--explain` to map each property **value** to the rule id and severity it triggers, so you can -predict lint behavior before running [`lint`](#lint). A value shown as `(unset/condition)` means the +predict analysis behavior before running [`analyze`](#analyze). A value shown as `(unset/condition)` means the rule fires when the property is absent or by a computed condition. ```bash @@ -305,6 +307,9 @@ tmforge new payments.tm7 --name "Payments" tmforge new payments.tmforge.json --format tmforge-json ``` +A `.tm7` written here — and by every authoring verb, `apply`, and `convert --to tm7` — embeds the +knowledge base, so it opens in the Microsoft Threat Modeling Tool without a separate export step. + ### `add` Add an element to a page (the first diagram by default; target another with `--page`). Use a **positional kind** for a generic element, **or** @@ -320,7 +325,7 @@ tmforge add --stencil [options] | `--name ` | Element name (defaults to the stencil label when using `--stencil`). | | `--alias ` | Stable authoring handle. Resolvable by `connect`/`set`/`remove`/`rename`/`show`, and gives the element a **deterministic** id (the same alias yields the same id across rebuilds) so reports and docs can cite it. | | `--boundary ` | Place the element inside this trust boundary (by alias, name, or GUID) and record membership, so `export` and boundary-aware rules see it. | -| `--stencil ` | Concrete stencil from the catalog; stamps `StencilType=` plus preset defaults. | +| `--stencil ` | Concrete stencil from the catalog; stamps `StencilType=` plus preset defaults. On `.tm7` export the stencil becomes a first-class element type in the Microsoft Threat Modeling Tool's palette. | | `--left ` / `--top ` | Explicit coordinates (otherwise auto-laid-out). | | `--width ` / `--height ` | Size (boundaries default to 260×180 so they enclose in `render`). | | `--page ` | Target page: a 1-based index or a page name (default: the first page; one is created if the model has none). | @@ -452,15 +457,15 @@ tmforge layout payments.tm7 --node-spacing 60 --layer-spacing 120 --- -## Validation, reporting & conversion +## Analysis, reporting & conversion -### `lint` +### `analyze` -Evaluate a rule set against the model. See [Validation rules & CI](validation-rules.md) for the +Evaluate the analysis rules against the model. See [Analysis rules & CI](analysis-rules.md) for the full rule catalog, packs, and suppressions. ```text -tmforge lint [--ruleset ] [--suppressionFile ] [--reportFolder ] [--define name=value ...] [--max-severity ] [--json] +tmforge analyze [--ruleset ] [--suppressionFile ] [--reportFolder ] [--define name=value ...] [--max-severity ] [--json] ``` | Option | Meaning | @@ -483,15 +488,69 @@ The distinct `2` lets CI fail on findings while separating them from a broken in gate with `--max-severity warning` (or `info`) to also fail the build on those severities. ```bash -tmforge lint payments.tm7 -tmforge lint payments.tm7 --reportFolder ./findings -tmforge lint payments.tm7 --suppressionFile suppressions.json --json +tmforge analyze payments.tm7 +tmforge analyze payments.tm7 --reportFolder ./findings +tmforge analyze payments.tm7 --suppressionFile suppressions.json --json ``` -> When a model is loaded from the native `tmforge-json` format, its embedded validation selection +> When a model is loaded from the native `tmforge-json` format, its embedded analysis selection > (disabled packs/rules) is honored automatically. Other formats use the full rule set or an > explicit `--ruleset`. +### `threats` + +Report the model's **STRIDE threats** — the persisted, triaged view of the analysis findings. +Detection is entirely the rule set: `threats` runs the same rules as [`analyze`](#analyze), keeps the +findings from threat-bearing rules, and frames each as a STRIDE threat against its element or flow. +The difference from `analyze` is **lifecycle** — where a finding is transient and gated, a threat is +persisted and triaged (`open -> mitigated -> accepted`). With `--write`, the threats are persisted into +the model's register, keyed so a re-run updates in place and never overwrites prior triage. + +```text +tmforge threats [--write] [--json] +``` + +| Option | Meaning | +| --- | --- | +| `--write` | Persist the threats into the model's register (preserves prior triage). | + +Each threat carries a STRIDE category, the rule's mitigation, and external references (CWE / CAPEC). +There is no separate threat catalog: **extend coverage the way detection is extended — add a rule to a +rule pack** (see [Analysis rules](analysis-rules.md)). Rules that represent structural or naming +hygiene (rather than a security weakness) are not reported as threats. + +```bash +tmforge threats payments.tm7 # report (read-only), same findings as lint +tmforge threats payments.tm7 --write # persist into the register +tmforge threats payments.tm7 --json +``` + +After `--write`, triage the register with [`list threats`](#list) and [`accept`](#accept). + +### `accept` + +Record **inline risk acceptance** for a generated threat. Acceptance is a threat *state*: the threat +moves to `NotApplicable` with your justification, stays visible in the register and report, and no +longer counts as open. Because it is scoped to a single threat (one pattern on one interaction), it +can never silently swallow an unrelated finding — unlike a blanket suppression. + +```text +tmforge accept --threat --reason [--json] +``` + +| Option | Meaning | +| --- | --- | +| `--threat ` | The threat to accept: its register key, interaction key, or numeric id (from `list threats`). | +| `--reason ` | The justification, stored with the threat and shown in the register and report. | + +```bash +tmforge threats payments.tm7 --write +tmforge list threats payments.tm7 +tmforge accept --threat 3 --reason "Mitigated by the upstream WAF; residual risk accepted." payments.tm7 +``` + +Acceptance round-trips through `.tm7` and is honored identically by the CLI, the `/v1` API, and Studio. + ### `report` Generate a self-contained HTML report with an inline SVG diagram per page, or export just the @@ -515,21 +574,27 @@ tmforge report payments.tm7 --format svg --out payments.svg Convert between formats. The target is chosen by `--to` or inferred from the `--out` extension. ```text -tmforge convert [--to ] [--out ] [--json] +tmforge convert [--to ] [--out ] [--knowledge-base ] [--json] ``` | Format id | Extension | Notes | | --- | --- | --- | -| `tm7` | `.tm7` | Lossless, byte-stable. | +| `tm7` | `.tm7` | Lossless, byte-stable; embeds a knowledge base so it opens in MTMT. | | `tmforge-json` | `.tmforge.json` | Canonical wire model. | | `drawio` | `.drawio` | draw.io / diagrams.net (structural). | | `vsdx` | `.vsdx` | Microsoft Visio (structural). | See [Formats & interoperability](formats.md) for fidelity details. +A `.tm7` target embeds the Threat Model Forge knowledge base by default so the file opens in the +[Microsoft Threat Modeling Tool](formats.md#tm7-and-the-microsoft-threat-modeling-tool); pass +`--knowledge-base ` 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. + ```bash tmforge convert payments.tm7 --to drawio --out payments.drawio tmforge convert payments.drawio --to tm7 --out payments.tm7 +tmforge convert payments.tmforge.json --to tm7 --out payments.tm7 --knowledge-base Custom.tb7 ``` --- @@ -630,5 +695,5 @@ returns `data.id` / `data.source` / `data.target`. ## See also - [Quick start](quickstart.md): the commands in a worked example. -- [Validation rules & CI](validation-rules.md): the rule catalog and gating. +- [Analysis rules & CI](analysis-rules.md): the rule catalog and gating. - [Formats & interoperability](formats.md): conversion fidelity. diff --git a/docs/deployment.md b/docs/deployment.md index 5104516..4a5625e 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -186,7 +186,7 @@ For batch validation/conversion, mount your working directory into the CLI image published image: ```bash -docker run --rm -v "$PWD:/work" ghcr.io/hacks4snacks/tmforge-cli tmforge lint model.tm7 +docker run --rm -v "$PWD:/work" ghcr.io/hacks4snacks/tmforge-cli tmforge analyze model.tm7 docker run --rm -v "$PWD:/work" ghcr.io/hacks4snacks/tmforge-cli tmforge convert model.tm7 --to drawio --out model.drawio ``` @@ -194,7 +194,7 @@ Or build it from source: ```bash docker build -f build/Dockerfile -t tmforge-cli . -docker run --rm -v "$PWD:/work" tmforge-cli tmforge lint model.tm7 +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. @@ -214,21 +214,21 @@ container image. https://github.com/hacks4snacks/tmforge/releases/download/v0.1.0/tmforge-0.1.0-linux-x64.tar.gz tar -xzf tmforge.tar.gz echo "$PWD/tmforge-0.1.0-linux-x64" >> "$GITHUB_PATH" -- name: Validate - run: tmforge lint model.tm7 --reportFolder reports +- name: Analyze + run: tmforge analyze model.tm7 --reportFolder reports ``` ### With the CLI container ```yaml -- name: Validate +- name: Analyze run: | docker run --rm -v "$PWD:/work" ghcr.io/hacks4snacks/tmforge-cli:0.1.0 \ - tmforge lint /work/model.tm7 --reportFolder /work/reports + tmforge analyze /work/model.tm7 --reportFolder /work/reports ``` Both fail the step on findings (exit `2`) and on tool errors (exit `1`). Upload the `reports/` -SARIF for code-scanning annotations. See [Validation rules & CI](validation-rules.md#ci-integration). +SARIF for code-scanning annotations. See [Analysis rules & CI](analysis-rules.md#ci-integration). ### Azure DevOps example @@ -237,8 +237,8 @@ SARIF for code-scanning annotations. See [Validation rules & CI](validation-rule curl -fsSL -o tmforge.tar.gz \ https://github.com/hacks4snacks/tmforge/releases/download/v0.1.0/tmforge-0.1.0-linux-x64.tar.gz tar -xzf tmforge.tar.gz - ./tmforge-0.1.0-linux-x64/tmforge lint model.tm7 --reportFolder $(Build.ArtifactStagingDirectory)/threatmodel - displayName: Validate threat model + ./tmforge-0.1.0-linux-x64/tmforge analyze model.tm7 --reportFolder $(Build.ArtifactStagingDirectory)/threatmodel + displayName: Analyze threat model ``` ## Supply-chain verification @@ -264,4 +264,4 @@ assembly's informational version). - [Installation](installation.md): all install channels. - [Engine API reference](api-reference.md): endpoints and hosting notes. -- [Validation rules & CI](validation-rules.md): gating pipelines on findings. +- [Analysis rules & CI](analysis-rules.md): gating pipelines on findings. diff --git a/docs/formats.md b/docs/formats.md index 8a91521..5ca1a29 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -28,12 +28,37 @@ Threat Model Forge reuses the exact serializer type graph MTMT produces, `.tm7` two tools **byte-for-byte identically**, including the rich threat data model. This is the canonical format; when you mutate a `.tm7` with the CLI, it's written back through this byte-stable writer. +### `tm7` and the Microsoft Threat Modeling Tool + +For a file to open in MTMT it must carry a **knowledge base** (the tool dereferences it without a null +check). So when you export a model that doesn't already have one, Threat Model Forge embeds its own +clean-room default — **“Threat Model Forge Core”**: the generic DFD element types, the six STRIDE +categories, a threat type per built-in rule, and a **standard element type for every authoring +stencil** so the tool's palette mirrors Threat Model Forge's. This happens on **every** `.tm7` write +(the CLI authoring verbs, `convert --to tm7`, and Studio/API export), so a `.tm7` is MTMT-ready at +every step. + +- **Typed properties.** Schema properties (`Protocol`, `Encrypted`, `DataType`, …) are written as + first-class **typed tool properties** — the drop-downs MTMT shows in its properties pane — rather + than free-form custom attributes. Free-text properties (like `Port`) and any value outside a + property's options are kept as custom attributes, so nothing is dropped. +- **Stencils as element types.** An element placed from a stencil (`azure-sql`, `entra-id`, …) is + exported as the matching standard element type, so it appears in MTMT as that stencil rather than a + bare generic. +- **Existing knowledge bases are preserved.** A model that already carries a knowledge base — a file + authored in MTMT, or one you supply with `tmforge convert --knowledge-base ` — is written + back untouched. + +Analysis reads typed and custom properties identically, so an exported or tool-authored `.tm7` is +validated the same as one authored with custom attributes. + ### `tmforge-json` (canonical wire model) The shape Studio and the API speak: elements, flows, trust boundaries, names, and geometry, plus an -optional validation selection (disabled packs/rules). Multi-page models carry a `diagrams` array (one -entry per page, with its name); single-page models keep the flat `elements`/`flows` shape, so older -readers keep working. Knowledge-base attributes and generated threats are not represented, so it's +optional analysis selection (disabled packs/rules) and a risk-acceptance triage overlay. Multi-page +models carry a `diagrams` array (one entry per page, with its name); single-page models keep the flat +`elements`/`flows` shape, so older readers keep working. Knowledge-base attributes and the full +generated-threat register are not represented — only risk-acceptance triage round-trips — so it's structural rather than lossless. Use it to bridge Studio and the CLI. ### `drawio` (draw.io / diagrams.net) diff --git a/docs/installation.md b/docs/installation.md index aeffc6a..a3d2286 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -30,7 +30,7 @@ are attached to each GitHub Release for six platforms: ```bash # Replace OWNER/REPO, the version, and the RID for your platform. curl -fsSL -o tmforge.tar.gz \ - https://github.com/OWNER/REPO/releases/download/v0.1.0/tmforge-0.1.0-linux-x64.tar.gz + https://github.com/hacks4snacks/tmforge/releases/download/v0.1.0/tmforge-0.1.0-linux-x64.tar.gz tar -xzf tmforge.tar.gz ./tmforge-0.1.0-linux-x64/tmforge --version ``` @@ -41,7 +41,7 @@ Move the binary somewhere on your `PATH`, e.g. `sudo mv tmforge-0.1.0-linux-x64/ ```powershell Invoke-WebRequest -Uri ` - https://github.com/OWNER/REPO/releases/download/v0.1.0/tmforge-0.1.0-win-x64.zip ` + https://github.com/hacks4snacks/tmforge/releases/download/v0.1.0/tmforge-0.1.0-win-x64.zip ` -OutFile tmforge.zip Expand-Archive tmforge.zip -DestinationPath tmforge .\tmforge\tmforge.exe --version @@ -71,14 +71,14 @@ sha256sum -c checksums.txt Run the published image (pulls automatically on first use): ```bash -docker run --rm -v "$PWD:/work" ghcr.io/hacks4snacks/tmforge-cli tmforge lint model.tm7 +docker run --rm -v "$PWD:/work" ghcr.io/hacks4snacks/tmforge-cli tmforge analyze model.tm7 ``` Or build it from source: ```bash docker build -f build/Dockerfile -t tmforge-cli . -docker run --rm -v "$PWD:/work" tmforge-cli tmforge lint model.tm7 +docker run --rm -v "$PWD:/work" tmforge-cli tmforge analyze model.tm7 ``` The working directory is mounted at `/work`, so paths in your commands are relative to it. diff --git a/docs/overview.md b/docs/overview.md index daa5758..86001f3 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -104,7 +104,7 @@ what you set in Studio, the CLI, or MTMT stays consistent. connectivity, naming, trust-boundary modeling, and declared security properties. - **CI integration**: SARIF + HTML findings reports, suppression files, rule-set overrides, and a distinct exit code for "found issues" vs. "tool error." See - [Validation rules & CI](validation-rules.md). + [Analysis rules & CI](analysis-rules.md). ### Reporting @@ -116,16 +116,6 @@ what you set in Studio, the CLI, or MTMT stays consistent. - **Formats**: `.tm7` (lossless), `tmforge-json` (canonical wire model), `.drawio` (draw.io / diagrams.net), and `.vsdx` (Microsoft Visio). See [Formats](formats.md). -## What's in v0.1 (and what isn't) - -**In scope:** `.tm7` read / write / validate / report, browser and CLI authoring, multi-format -import/export, git-native diff/merge, and CI validation. - -**Deferred:** in-product **STRIDE threat generation** and knowledge-base compilation are not in -v0.1. You author and validate models; automated threat suggestion is planned as a later, optional -generation pack. (The `.tm7` threat *data model* is fully preserved: threats authored in MTMT -round-trip and are listed by `tmforge list threats`.) - ## Next steps - [Quick start](quickstart.md): build your first model end to end. diff --git a/docs/quickstart.md b/docs/quickstart.md index 988c9cd..cddb570 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -86,10 +86,10 @@ Add `--json` to any command for machine-readable output. ## 5. Validate ```bash -tmforge lint payments.tm7 +tmforge analyze payments.tm7 ``` -`lint` evaluates the built-in rule set and reports findings. Its exit code is meaningful: +`analyze` evaluates the built-in rule set and reports findings. Its exit code is meaningful: | Exit code | Meaning | | --- | --- | @@ -101,10 +101,10 @@ That lets CI **fail on findings** while distinguishing them from a broken invoca SARIF + HTML reports: ```bash -tmforge lint payments.tm7 --reportFolder ./findings +tmforge analyze payments.tm7 --reportFolder ./findings ``` -See [Validation rules & CI](validation-rules.md) for the full rule list, suppressions, and gating. +See [Analysis rules & CI](analysis-rules.md) for the full rule list, suppressions, and gating. ## 6. Report @@ -139,7 +139,7 @@ docker run --rm -p 8080:8080 tmforge # then open http://localhost:8080/ ``` In Studio you can drag stencils, draw flows, edit flow properties in the inspector, click -**Validate** to see findings overlaid on the diagram, and **Export tmforge-json** to round-trip +**Analyze** to see findings overlaid on the diagram, and **Export tmforge-json** to round-trip through the CLI. See the [Studio guide](studio-guide.md). ## A minimal CI gate @@ -147,7 +147,7 @@ through the CLI. See the [Studio guide](studio-guide.md). ```bash # Exit 2 if findings at or above --max-severity are reported (default: error-severity only); # exit 1 on tool errors. Use --max-severity warning to also gate a build on warnings. -tmforge lint payments.tm7 --max-severity warning --reportFolder "$CI_ARTIFACTS/threatmodel" +tmforge analyze payments.tm7 --max-severity warning --reportFolder "$CI_ARTIFACTS/threatmodel" ``` Full pipeline examples are in the [deployment guide](deployment.md#cicd). diff --git a/docs/studio-guide.md b/docs/studio-guide.md index e0f231a..4623d03 100644 --- a/docs/studio-guide.md +++ b/docs/studio-guide.md @@ -85,13 +85,13 @@ properties below the typed ones. ## Validating against the engine -Click **Validate** to send the whole model (every page) to the live `/v1` engine. Findings come back +Click **Analyze** to send the whole model (every page) to the live `/v1` engine. Findings come back and are **overlaid on the offending nodes and edges**, so you can see exactly what to fix. In a multi-page model, tabs that carry findings are badged, and clicking a finding jumps to its page. Open -the inspector, set the missing property (e.g. a flow's protocol), and re-validate. +the inspector, set the missing property (e.g. a flow's protocol), and re-analyze. If the engine is offline, Studio falls back to an offline stub so the canvas keeps working; connect -it to a running API to get the real rule set. See [Validation rules & CI](validation-rules.md) for +it to a running API to get the real rule set. See [Analysis rules & CI](analysis-rules.md) for what the rules check. ## Importing and exporting @@ -103,7 +103,7 @@ Studio round-trips through the canonical **`tmforge-json`** wire model: - **Import JSON**: load a `.tmforge.json` document back onto the canvas. This is the bridge between visual authoring and the [CLI](cli-reference.md): export from Studio, -then `tmforge lint` / `tmforge report` / `tmforge convert` in a pipeline, or vice versa. +then `tmforge analyze` / `tmforge report` / `tmforge convert` in a pipeline, or vice versa. > **Format parsing lives in the engine, not the browser.** The canvas never parses `.tm7`, `.vsdx`, > or `.drawio` itself. Use the API's `convert` / `read` endpoints or the CLI for those. Studio @@ -127,7 +127,8 @@ and every element both versions changed differently is listed as a conflict for modal shows a notice to that effect. Pick **Ours** or **Theirs** for each conflict (the default keeps yours), then **Load into editor** to -drop the resolved model onto the canvas, or **Download .tm7** to save it. Structural conflicts (an +drop the resolved model onto the canvas, or **Download .tm7** to save it (the downloaded `.tm7` embeds +the knowledge base, so it opens in the Microsoft Threat Modeling Tool). Structural conflicts (an element deleted on one side and edited on the other, or a data flow left dangling) keep your version and are flagged for you to fix after loading. diff --git a/src/README.md b/src/README.md index 98ba815..e9ab4bc 100644 --- a/src/README.md +++ b/src/README.md @@ -15,7 +15,7 @@ renders it, editing mutates it, and the CLI and API expose it. | --- | --- | | [`ThreatModelForge.Core`](ThreatModelForge.Core) | The `ThreatModel` object graph and its byte-for-byte-lossless `.tm7`/`.tb7` IO (`DataContractSerializer`), the knowledge-base types, and shared serialization abstractions. | | [`ThreatModelForge.Formats`](ThreatModelForge.Formats) | Pluggable format layer (`IThreatModelFormat` + `ThreatModelFormatRegistry`). `.tm7` is the first provider; draw.io, Visio (`.vsdx`), and `tmforge-json` map to and from the canonical model. | -| [`ThreatModelForge.Analysis`](ThreatModelForge.Analysis) | The analysis object model: the base rule types that rule sets derive from, plus the machinery `tmforge lint` uses to evaluate a model. | +| [`ThreatModelForge.Analysis`](ThreatModelForge.Analysis) | The analysis object model: the base rule types that rule sets derive from, plus the machinery `tmforge analyze` uses to evaluate a model. | | [`ThreatModelForge.Analysis.Rules`](ThreatModelForge.Analysis.Rules) | The built-in rule set: completeness/hygiene checks and security-property checks. | | [`ThreatModelForge.Analysis.Reporting`](ThreatModelForge.Analysis.Reporting) | Report writers for analysis *findings*: SARIF (for CI/code-scanning) and self-contained HTML. | | [`ThreatModelForge.Reporting`](ThreatModelForge.Reporting) | Renders the threat *model itself* to a self-contained HTML report with inline SVG diagrams (no native graphics libraries). | diff --git a/src/ThreatModelForge.Analysis.Reporting/FindingsHtmlReportWriter.cs b/src/ThreatModelForge.Analysis.Reporting/FindingsHtmlReportWriter.cs index 4bcdf44..d1cee76 100644 --- a/src/ThreatModelForge.Analysis.Reporting/FindingsHtmlReportWriter.cs +++ b/src/ThreatModelForge.Analysis.Reporting/FindingsHtmlReportWriter.cs @@ -9,7 +9,7 @@ namespace ThreatModelForge.Analysis.Reporting /// /// Writes threat-model analysis findings (a ) as a self-contained - /// HTML document. Used by tmforge lint --reportFolder. + /// HTML document. Used by tmforge analyze --reportFolder. /// public class FindingsHtmlReportWriter : ReportWriter { @@ -71,7 +71,7 @@ public override void Write(ModelReport report) this.Inner.WriteEndElement(); // head this.Inner.WriteStartElement("body"); - this.Inner.WriteElementString("h1", $"Threat Model Forge Lint Report for {report.ThreatModelName}"); + this.Inner.WriteElementString("h1", $"Threat Model Forge Analysis Report for {report.ThreatModelName}"); this.WriteSummaryBlock(report); this.WriteMessageBlock(report); diff --git a/src/ThreatModelForge.Analysis.Reporting/README.md b/src/ThreatModelForge.Analysis.Reporting/README.md index baafbe4..acf1c5a 100644 --- a/src/ThreatModelForge.Analysis.Reporting/README.md +++ b/src/ThreatModelForge.Analysis.Reporting/README.md @@ -5,7 +5,7 @@ machine- and human-readable formats. Given a `ModelReport` produced by `ThreatModelForge.Analysis` (the findings from evaluating a rule set against a model), this library serializes those findings for consumption by CI and by -people. It is used by the `tmforge lint` command (via `--reportFolder`). +people. It is used by the `tmforge analyze` command (via `--reportFolder`). The core pieces are: diff --git a/src/ThreatModelForge.Analysis.Reporting/SarifReportWriter.cs b/src/ThreatModelForge.Analysis.Reporting/SarifReportWriter.cs index 81b006a..6d3df73 100644 --- a/src/ThreatModelForge.Analysis.Reporting/SarifReportWriter.cs +++ b/src/ThreatModelForge.Analysis.Reporting/SarifReportWriter.cs @@ -57,7 +57,7 @@ public SarifReportWriter(Stream stream, string reportFilePath) public string? DocumentFilePath { get; set; } /// - /// Gets or sets a value indicating whether the linter outputs have suppressed messages. + /// Gets or sets a value indicating whether the analyzer outputs have suppressed messages. /// public bool HasSuppressedMessage { get; set; } diff --git a/src/ThreatModelForge.Analysis.Rules/CachedCredentialReadRule.cs b/src/ThreatModelForge.Analysis.Rules/CachedCredentialReadRule.cs index 4d7e783..f311118 100644 --- a/src/ThreatModelForge.Analysis.Rules/CachedCredentialReadRule.cs +++ b/src/ThreatModelForge.Analysis.Rules/CachedCredentialReadRule.cs @@ -33,6 +33,15 @@ public CachedCredentialReadRule() new PropertyBinding("flow", CachedCustomAttributeName, "Yes"), }; + /// + public override StrideCategory? Stride => StrideCategory.InformationDisclosure; + + /// + public override IReadOnlyList ThreatReferences => new[] + { + ThreatReference.Cwe(522), + }; + /// public override void Evaluate(RuleEvaluationContext context) { diff --git a/src/ThreatModelForge.Analysis.Rules/CleartextTrustBoundaryCrossingRule.cs b/src/ThreatModelForge.Analysis.Rules/CleartextTrustBoundaryCrossingRule.cs index feaf9ac..8d2ead0 100644 --- a/src/ThreatModelForge.Analysis.Rules/CleartextTrustBoundaryCrossingRule.cs +++ b/src/ThreatModelForge.Analysis.Rules/CleartextTrustBoundaryCrossingRule.cs @@ -51,6 +51,16 @@ public CleartextTrustBoundaryCrossingRule() new PropertyBinding("flow", "Protocol", "HTTP", "FTP"), }; + /// + public override StrideCategory? Stride => StrideCategory.InformationDisclosure; + + /// + public override IReadOnlyList ThreatReferences => new[] + { + ThreatReference.Cwe(319), + ThreatReference.Capec(157), + }; + /// public override void Evaluate(RuleEvaluationContext context) { diff --git a/src/ThreatModelForge.Analysis.Rules/CredentialsInLogStoreRule.cs b/src/ThreatModelForge.Analysis.Rules/CredentialsInLogStoreRule.cs index f7a6429..f3ced3c 100644 --- a/src/ThreatModelForge.Analysis.Rules/CredentialsInLogStoreRule.cs +++ b/src/ThreatModelForge.Analysis.Rules/CredentialsInLogStoreRule.cs @@ -32,6 +32,15 @@ public CredentialsInLogStoreRule() new PropertyBinding("datastore", "StoresCredentials"), }; + /// + public override StrideCategory? Stride => StrideCategory.InformationDisclosure; + + /// + public override IReadOnlyList ThreatReferences => new[] + { + ThreatReference.Cwe(532), + }; + /// public override void Evaluate(RuleEvaluationContext context) { diff --git a/src/ThreatModelForge.Analysis.Rules/DataStoreMissingBackupRule.cs b/src/ThreatModelForge.Analysis.Rules/DataStoreMissingBackupRule.cs new file mode 100644 index 0000000..3a2f0a8 --- /dev/null +++ b/src/ThreatModelForge.Analysis.Rules/DataStoreMissingBackupRule.cs @@ -0,0 +1,98 @@ +namespace ThreatModelForge.Analysis.Rules +{ + using System; + using ThreatModelForge.Model; + using ThreatModelForge.Model.Abstracts; + + /// + /// Rule that flags a data store holding important data (credentials or audit/log data) that declares + /// no backup, so destruction or corruption of the store's data is unrecoverable. + /// + /// + /// A data store flagged as holding credentials (StoresCredentials = Yes) or log/audit data + /// (StoresLogData = Yes) whose Backup property is No or unset has no recovery path: + /// accidental deletion, corruption, or a destructive attack (for example ransomware) permanently loses + /// the secrets an application depends on or the audit trail that proves what happened — a loss of + /// availability, and for audit data a loss of the evidence needed for non-repudiation. + /// + public class DataStoreMissingBackupRule : Rule + { + /// + /// Custom attribute that records whether a store is backed up. + /// + public const string BackupCustomAttributeName = "Backup"; + + /// + /// Initializes a new instance of the class. + /// + public DataStoreMissingBackupRule() + : base(RuleIDs.DataStoreMissingBackupRule, MessageSeverity.Warning, RulePackCatalog.Availability) + { + this.FullDescription = DataStoreMissingBackupRuleResources.FullDescription; + this.HelpText = DataStoreMissingBackupRuleResources.HelpText; + this.HelpUri = RuleDocumentation.HelpUriFor(this.ID); + } + + /// + public override IReadOnlyList PropertyBindings => new[] + { + new PropertyBinding("datastore", BackupCustomAttributeName, "No"), + new PropertyBinding("datastore", "StoresCredentials"), + new PropertyBinding("datastore", "StoresLogData"), + }; + + /// + public override StrideCategory? Stride => StrideCategory.DenialOfService; + + /// + public override IReadOnlyList ThreatReferences => new[] + { + ThreatReference.Attack("T1485"), + ThreatReference.Attack("T1490"), + }; + + /// + public override void Evaluate(RuleEvaluationContext context) + { + _ = context ?? throw new ArgumentNullException(nameof(context)); + + foreach (DrawingSurfaceModel diagram in context.Model.DrawingSurfaceList) + { + foreach (Entity component in diagram.Components()) + { + if (!component.IsStorageComponent()) + { + continue; + } + + if (!HoldsImportantData(component) || IsBackedUp(component)) + { + continue; + } + + string text = string.Format( + System.Globalization.CultureInfo.CurrentCulture, + DataStoreMissingBackupRuleResources.MessageText, + GetEntityDisplayText(component)); + context.Writer.Write(this.CreateMessage(component, diagram, text)); + } + } + } + + private static bool HoldsImportantData(Entity component) + { + return IsYes(component, "StoresCredentials") || IsYes(component, "StoresLogData"); + } + + private static bool IsBackedUp(Entity component) + { + return IsYes(component, BackupCustomAttributeName); + } + + private static bool IsYes(Entity component, string propertyName) + { + return component.TryGetCustomPropertyValue(propertyName, out string? value) && + string.Equals(value, "Yes", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/ThreatModelForge.Analysis.Rules/DataStoreMissingBackupRuleResources.Designer.cs b/src/ThreatModelForge.Analysis.Rules/DataStoreMissingBackupRuleResources.Designer.cs new file mode 100644 index 0000000..0ba9fef --- /dev/null +++ b/src/ThreatModelForge.Analysis.Rules/DataStoreMissingBackupRuleResources.Designer.cs @@ -0,0 +1,90 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace ThreatModelForge.Analysis.Rules { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class DataStoreMissingBackupRuleResources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal DataStoreMissingBackupRuleResources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("ThreatModelForge.Analysis.Rules.DataStoreMissingBackupRuleResources", typeof(DataStoreMissingBackupRuleResources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to the full description of the data-store-missing-backup rule. + /// + internal static string FullDescription { + get { + return ResourceManager.GetString("FullDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to the help text of the data-store-missing-backup rule. + /// + internal static string HelpText { + get { + return ResourceManager.GetString("HelpText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The {0} holds important data but declares no backup. + /// + internal static string MessageText { + get { + return ResourceManager.GetString("MessageText", resourceCulture); + } + } + } +} diff --git a/src/ThreatModelForge.Analysis.Rules/DataStoreMissingBackupRuleResources.resx b/src/ThreatModelForge.Analysis.Rules/DataStoreMissingBackupRuleResources.resx new file mode 100644 index 0000000..9bc9784 --- /dev/null +++ b/src/ThreatModelForge.Analysis.Rules/DataStoreMissingBackupRuleResources.resx @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + A data store that holds important data but has no backup cannot be recovered if its contents are deleted, corrupted, or destroyed. Loss of a credential store strands the application that depends on those secrets; loss of an audit-log store destroys the evidence needed to prove what happened. Either outcome is a loss of availability, and destroying an audit trail also enables repudiation. + +This rule fires when a storage component flagged as holding credentials (StoresCredentials = Yes) or log/audit data (StoresLogData = Yes) has its Backup property set to No or left unset. Stores that are not flagged as holding important data are not reported. + + + Set Backup = Yes on the store once a tested backup and restore path exists, so the credentials or audit trail it holds can be recovered after accidental deletion, corruption, or a destructive attack. Keep at least one backup copy isolated from the primary store so a single compromise cannot destroy both. + + + The {0} holds important data but declares no backup; set Backup = Yes with a tested restore path so its contents can be recovered after loss or a destructive attack. + + diff --git a/src/ThreatModelForge.Analysis.Rules/EdgeMissingDataClassificationRule.cs b/src/ThreatModelForge.Analysis.Rules/EdgeMissingDataClassificationRule.cs index 2553cb8..8e84c01 100644 --- a/src/ThreatModelForge.Analysis.Rules/EdgeMissingDataClassificationRule.cs +++ b/src/ThreatModelForge.Analysis.Rules/EdgeMissingDataClassificationRule.cs @@ -25,6 +25,15 @@ public EdgeMissingDataClassificationRule() new PropertyBinding("flow", "DataType"), }; + /// + public override StrideCategory? Stride => StrideCategory.InformationDisclosure; + + /// + public override IReadOnlyList ThreatReferences => new[] + { + ThreatReference.Cwe(200), + }; + /// public override void Evaluate(RuleEvaluationContext context) { diff --git a/src/ThreatModelForge.Analysis.Rules/EdgeMissingProtocolRule.cs b/src/ThreatModelForge.Analysis.Rules/EdgeMissingProtocolRule.cs index 0b7ba99..c2454c0 100644 --- a/src/ThreatModelForge.Analysis.Rules/EdgeMissingProtocolRule.cs +++ b/src/ThreatModelForge.Analysis.Rules/EdgeMissingProtocolRule.cs @@ -25,6 +25,15 @@ public EdgeMissingProtocolRule() new PropertyBinding("flow", "Protocol"), }; + /// + public override StrideCategory? Stride => StrideCategory.Tampering; + + /// + public override IReadOnlyList ThreatReferences => new[] + { + ThreatReference.Cwe(319), + }; + /// public override void Evaluate(RuleEvaluationContext context) { diff --git a/src/ThreatModelForge.Analysis.Rules/OverPrivilegedProcessRule.cs b/src/ThreatModelForge.Analysis.Rules/OverPrivilegedProcessRule.cs index 0d1b208..1b6aae2 100644 --- a/src/ThreatModelForge.Analysis.Rules/OverPrivilegedProcessRule.cs +++ b/src/ThreatModelForge.Analysis.Rules/OverPrivilegedProcessRule.cs @@ -31,6 +31,16 @@ public OverPrivilegedProcessRule() new PropertyBinding("process", "RunningAs", "System", "Root/Admin"), }; + /// + public override StrideCategory? Stride => StrideCategory.ElevationOfPrivilege; + + /// + public override IReadOnlyList ThreatReferences => new[] + { + ThreatReference.Cwe(250), + ThreatReference.Capec(233), + }; + /// public override void Evaluate(RuleEvaluationContext context) { diff --git a/src/ThreatModelForge.Analysis.Rules/RuleDocumentation.cs b/src/ThreatModelForge.Analysis.Rules/RuleDocumentation.cs index 6313a4d..861e428 100644 --- a/src/ThreatModelForge.Analysis.Rules/RuleDocumentation.cs +++ b/src/ThreatModelForge.Analysis.Rules/RuleDocumentation.cs @@ -25,12 +25,8 @@ public static class RuleDocumentation /// /// The canonical, public documentation page for the built-in rule set. /// - /// - /// OWNER/REPO is a placeholder until the public repository is published; replace it - /// here to activate real help links across every surface. - /// public const string RulesReferenceUrl = - "https://github.com/OWNER/REPO/blob/main/docs/validation-rules.md"; + "https://github.com/hacks4snacks/tmforge/blob/main/docs/validation-rules.md"; /// /// Builds the documentation URL for a single rule. diff --git a/src/ThreatModelForge.Analysis.Rules/RuleIDs.cs b/src/ThreatModelForge.Analysis.Rules/RuleIDs.cs index 08221d7..f0b1368 100644 --- a/src/ThreatModelForge.Analysis.Rules/RuleIDs.cs +++ b/src/ThreatModelForge.Analysis.Rules/RuleIDs.cs @@ -144,5 +144,20 @@ internal class RuleIDs /// Rule that flags a flow that caches a credential read from a credential store. /// public const int CachedCredentialReadRule = 1027; + + /// + /// Rule that flags a data store holding important data (credentials or audit logs) with no backup. + /// + public const int DataStoreMissingBackupRule = 1028; + + /// + /// Rule that flags a process receiving cross-boundary input with no reachable audit-log store. + /// + public const int UnauditedBoundaryProcessRule = 1029; + + /// + /// Rule that flags a flow carrying sensitive data whose destination is an external interactor. + /// + public const int SensitiveDataToExternalRule = 1030; } } diff --git a/src/ThreatModelForge.Analysis.Rules/RulePackCatalog.cs b/src/ThreatModelForge.Analysis.Rules/RulePackCatalog.cs index 3e763a7..64c0dfe 100644 --- a/src/ThreatModelForge.Analysis.Rules/RulePackCatalog.cs +++ b/src/ThreatModelForge.Analysis.Rules/RulePackCatalog.cs @@ -29,6 +29,9 @@ public static class RulePackCatalog /// Rules that check authentication, least privilege, and access to components. public const string IdentityAccess = "identity-access"; + /// Rules that check availability and recoverability: backups, redundancy, and audit-trail durability. + public const string Availability = "availability"; + private static readonly IReadOnlyList> OrderedPacks = new List> { new KeyValuePair(CoreHygiene, "Core Hygiene"), @@ -37,6 +40,7 @@ public static class RulePackCatalog new KeyValuePair(DataProtection, "Data Protection"), new KeyValuePair(TransportSecurity, "Transport Security"), new KeyValuePair(IdentityAccess, "Identity & Access"), + new KeyValuePair(Availability, "Availability"), }; /// diff --git a/src/ThreatModelForge.Analysis.Rules/SensitiveDataToExternalRule.cs b/src/ThreatModelForge.Analysis.Rules/SensitiveDataToExternalRule.cs new file mode 100644 index 0000000..9e01b5e --- /dev/null +++ b/src/ThreatModelForge.Analysis.Rules/SensitiveDataToExternalRule.cs @@ -0,0 +1,120 @@ +namespace ThreatModelForge.Analysis.Rules +{ + using System; + using System.Collections.Generic; + using System.Linq; + using ThreatModelForge.Model; + using ThreatModelForge.Model.Abstracts; + + /// + /// Rule that flags a flow carrying sensitive data whose destination is an external interactor, so the + /// data leaves the system's trust into a party the model does not control. + /// + /// + /// A flow whose data classification (DataType) is sensitive — end-user identifiable or + /// pseudonymous information, customer content, account data, or access-control data — that terminates + /// at an external interactor sends that data outside the system boundary. Unless the disclosure is + /// intended and the external party is authorized to receive it, this is an information-disclosure path + /// that should be justified or removed. + /// + public class SensitiveDataToExternalRule : Rule + { + /// + /// Custom attribute that records the data classification of a flow. + /// + public const string DataTypeCustomAttributeName = "DataType"; + + /// + /// The data classifications treated as sensitive for the purpose of this rule. + /// + private static readonly HashSet SensitiveClassifications = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "EUII", + "EUPI", + "Customer Content", + "Account Data", + "Access Control Data", + }; + + /// + /// Initializes a new instance of the class. + /// + public SensitiveDataToExternalRule() + : base(RuleIDs.SensitiveDataToExternalRule, MessageSeverity.Warning, RulePackCatalog.DataProtection) + { + this.FullDescription = SensitiveDataToExternalRuleResources.FullDescription; + this.HelpText = SensitiveDataToExternalRuleResources.HelpText; + this.HelpUri = RuleDocumentation.HelpUriFor(this.ID); + } + + /// + public override IReadOnlyList PropertyBindings => new[] + { + new PropertyBinding("flow", DataTypeCustomAttributeName, "EUII", "EUPI", "Customer Content", "Account Data", "Access Control Data"), + }; + + /// + public override StrideCategory? Stride => StrideCategory.InformationDisclosure; + + /// + public override IReadOnlyList ThreatReferences => new[] + { + ThreatReference.Cwe(200), + }; + + /// + public override void Evaluate(RuleEvaluationContext context) + { + _ = context ?? throw new ArgumentNullException(nameof(context)); + + foreach (DrawingSurfaceModel diagram in context.Model.DrawingSurfaceList) + { + foreach (Connector connector in diagram.Lines.Values.OfType()) + { + if (!TryGetSensitiveClassification(connector, out string classification)) + { + continue; + } + + if (!TargetIsExternalInteractor(diagram, connector)) + { + continue; + } + + string text = string.Format( + System.Globalization.CultureInfo.CurrentCulture, + SensitiveDataToExternalRuleResources.MessageText, + GetEntityDisplayText(connector), + classification); + context.Writer.Write(this.CreateMessage(connector, diagram, text)); + } + } + } + + private static bool TryGetSensitiveClassification(Connector connector, out string classification) + { + classification = string.Empty; + if (!connector.TryGetCustomPropertyValue(DataTypeCustomAttributeName, out string? value) || + string.IsNullOrWhiteSpace(value)) + { + return false; + } + + string trimmed = value!.Trim(); + if (!SensitiveClassifications.Contains(trimmed)) + { + return false; + } + + classification = trimmed; + return true; + } + + private static bool TargetIsExternalInteractor(DrawingSurfaceModel diagram, Connector connector) + { + return diagram.Borders.TryGetValue(connector.TargetGuid, out object? value) && + value is Entity target && + target.IsExternalInteractor(); + } + } +} diff --git a/src/ThreatModelForge.Analysis.Rules/SensitiveDataToExternalRuleResources.Designer.cs b/src/ThreatModelForge.Analysis.Rules/SensitiveDataToExternalRuleResources.Designer.cs new file mode 100644 index 0000000..fa1cc4d --- /dev/null +++ b/src/ThreatModelForge.Analysis.Rules/SensitiveDataToExternalRuleResources.Designer.cs @@ -0,0 +1,90 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace ThreatModelForge.Analysis.Rules { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class SensitiveDataToExternalRuleResources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal SensitiveDataToExternalRuleResources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("ThreatModelForge.Analysis.Rules.SensitiveDataToExternalRuleResources", typeof(SensitiveDataToExternalRuleResources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to the full description of the sensitive-data-to-external rule. + /// + internal static string FullDescription { + get { + return ResourceManager.GetString("FullDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to the help text of the sensitive-data-to-external rule. + /// + internal static string HelpText { + get { + return ResourceManager.GetString("HelpText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The {0} sends {1} to an external interactor. + /// + internal static string MessageText { + get { + return ResourceManager.GetString("MessageText", resourceCulture); + } + } + } +} diff --git a/src/ThreatModelForge.Analysis.Rules/SensitiveDataToExternalRuleResources.resx b/src/ThreatModelForge.Analysis.Rules/SensitiveDataToExternalRuleResources.resx new file mode 100644 index 0000000..5c16f30 --- /dev/null +++ b/src/ThreatModelForge.Analysis.Rules/SensitiveDataToExternalRuleResources.resx @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Sensitive data that flows to an external interactor leaves the system's trust and enters a party the model does not control. Once disclosed, the data cannot be recalled, and the receiving party may retain, forward, or expose it beyond the system's intent. + +This rule fires when a flow whose DataType is a sensitive classification (EUII, EUPI, Customer Content, Account Data, or Access Control Data) has an external interactor as its destination. Flows carrying non-sensitive or public classifications, and flows between internal components, are not reported. A flow flagged here that represents an intended, authorized disclosure can be suppressed. + + + Confirm the external party is authorized to receive data of this classification and that a data-sharing agreement or user consent covers it. Minimize what is sent — redact, tokenize, or aggregate the data so no more than necessary leaves the boundary — and encrypt it in transit. If the disclosure is intended and approved, suppress the finding; otherwise reroute or remove the flow. + + + The {0} sends {1} to an external interactor; confirm the recipient is authorized for this data, minimize what is sent, or remove the flow. + + diff --git a/src/ThreatModelForge.Analysis.Rules/SharedStaticIdentityRule.cs b/src/ThreatModelForge.Analysis.Rules/SharedStaticIdentityRule.cs index 44c6425..8fbe40d 100644 --- a/src/ThreatModelForge.Analysis.Rules/SharedStaticIdentityRule.cs +++ b/src/ThreatModelForge.Analysis.Rules/SharedStaticIdentityRule.cs @@ -32,6 +32,15 @@ public SharedStaticIdentityRule() new PropertyBinding("flow", IdentityCustomAttributeName), }; + /// + public override StrideCategory? Stride => StrideCategory.Spoofing; + + /// + public override IReadOnlyList ThreatReferences => new[] + { + ThreatReference.Cwe(287), + }; + /// public override void Evaluate(RuleEvaluationContext context) { diff --git a/src/ThreatModelForge.Analysis.Rules/UnauditedBoundaryProcessRule.cs b/src/ThreatModelForge.Analysis.Rules/UnauditedBoundaryProcessRule.cs new file mode 100644 index 0000000..78996d6 --- /dev/null +++ b/src/ThreatModelForge.Analysis.Rules/UnauditedBoundaryProcessRule.cs @@ -0,0 +1,109 @@ +namespace ThreatModelForge.Analysis.Rules +{ + using System; + using System.Linq; + using ThreatModelForge.Model; + using ThreatModelForge.Model.Abstracts; + + /// + /// Rule that flags a process receiving input across a trust boundary that has no reachable audit-log + /// store, so the actions it performs on behalf of callers cannot be attributed after the fact. + /// + /// + /// A process that accepts a data flow crossing a trust boundary is handling requests from a less + /// trusted zone. If none of its outbound flows reach a data store flagged as holding log or audit data + /// (StoresLogData = Yes), there is no durable record of who did what, so a caller can later + /// repudiate an action and an operator cannot reconstruct an incident. + /// + public class UnauditedBoundaryProcessRule : Rule + { + /// + /// Initializes a new instance of the class. + /// + public UnauditedBoundaryProcessRule() + : base(RuleIDs.UnauditedBoundaryProcessRule, MessageSeverity.Warning, RulePackCatalog.StrideCompleteness) + { + this.FullDescription = UnauditedBoundaryProcessRuleResources.FullDescription; + this.HelpText = UnauditedBoundaryProcessRuleResources.HelpText; + this.HelpUri = RuleDocumentation.HelpUriFor(this.ID); + } + + /// + public override IReadOnlyList PropertyBindings => new[] + { + new PropertyBinding("datastore", "StoresLogData"), + }; + + /// + public override StrideCategory? Stride => StrideCategory.Repudiation; + + /// + public override IReadOnlyList ThreatReferences => new[] + { + ThreatReference.Cwe(778), + }; + + /// + public override void Evaluate(RuleEvaluationContext context) + { + _ = context ?? throw new ArgumentNullException(nameof(context)); + + foreach (DrawingSurfaceModel diagram in context.Model.DrawingSurfaceList) + { + foreach (Entity component in diagram.Components()) + { + // Only processes handle requests: skip data stores and external interactors. + if (component.IsStorageComponent() || component.IsExternalInteractor()) + { + continue; + } + + if (!HasInboundTrustBoundaryCrossing(diagram, component)) + { + continue; + } + + if (WritesToAuditLog(diagram, component)) + { + continue; + } + + string text = string.Format( + System.Globalization.CultureInfo.CurrentCulture, + UnauditedBoundaryProcessRuleResources.MessageText, + GetEntityDisplayText(component)); + context.Writer.Write(this.CreateMessage(component, diagram, text)); + } + } + } + + private static bool HasInboundTrustBoundaryCrossing(DrawingSurfaceModel diagram, Entity component) + { + return diagram + .Lines + .Values + .OfType() + .Where(c => c.TargetGuid == component.Guid) + .Any(c => diagram.TrustBoundaryCrossings(c).Any()); + } + + private static bool WritesToAuditLog(DrawingSurfaceModel diagram, Entity component) + { + return diagram + .Lines + .Values + .OfType() + .Where(c => c.SourceGuid == component.Guid) + .Any(c => TargetIsAuditLogStore(diagram, c)); + } + + private static bool TargetIsAuditLogStore(DrawingSurfaceModel diagram, Connector connector) + { + return diagram.Borders.TryGetValue(connector.TargetGuid, out object? value) && + value is Entity target && + target.IsStorageComponent() && + target.TryGetCustomPropertyValue("StoresLogData", out string? storesLogData) && + string.Equals(storesLogData, "Yes", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/ThreatModelForge.Analysis.Rules/UnauditedBoundaryProcessRuleResources.Designer.cs b/src/ThreatModelForge.Analysis.Rules/UnauditedBoundaryProcessRuleResources.Designer.cs new file mode 100644 index 0000000..3805f56 --- /dev/null +++ b/src/ThreatModelForge.Analysis.Rules/UnauditedBoundaryProcessRuleResources.Designer.cs @@ -0,0 +1,90 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace ThreatModelForge.Analysis.Rules { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class UnauditedBoundaryProcessRuleResources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal UnauditedBoundaryProcessRuleResources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("ThreatModelForge.Analysis.Rules.UnauditedBoundaryProcessRuleResources", typeof(UnauditedBoundaryProcessRuleResources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to the full description of the unaudited-boundary-process rule. + /// + internal static string FullDescription { + get { + return ResourceManager.GetString("FullDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to the help text of the unaudited-boundary-process rule. + /// + internal static string HelpText { + get { + return ResourceManager.GetString("HelpText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The {0} receives input across a trust boundary but writes to no audit-log store. + /// + internal static string MessageText { + get { + return ResourceManager.GetString("MessageText", resourceCulture); + } + } + } +} diff --git a/src/ThreatModelForge.Analysis.Rules/UnauditedBoundaryProcessRuleResources.resx b/src/ThreatModelForge.Analysis.Rules/UnauditedBoundaryProcessRuleResources.resx new file mode 100644 index 0000000..8db7c16 --- /dev/null +++ b/src/ThreatModelForge.Analysis.Rules/UnauditedBoundaryProcessRuleResources.resx @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + A process that accepts input from a less trusted zone acts on behalf of callers whose identity and intent it cannot fully trust. If it keeps no durable audit trail, there is no record to attribute those actions to a caller, so a caller can repudiate what they did and an operator cannot reconstruct an incident. + +This rule fires when a process receiving a data flow that crosses a trust boundary has no outbound flow to a data store flagged as holding log or audit data (StoresLogData = Yes). Processes that do not receive cross-boundary input, and processes that already write to an audit-log store, are not reported. + + + Route the security-relevant actions of the process to a data store that records audit or log data, and mark that store StoresLogData = Yes. Capture who initiated each action, what was requested, and the outcome, so the actions taken on behalf of a caller can be attributed after the fact. Protect the audit store itself (see the unsigned-audit-log and credentials-in-log rules) so the trail cannot be altered or leaked. + + + The {0} receives input across a trust boundary but writes to no audit-log store; route its security-relevant actions to a store marked StoresLogData = Yes so callers' actions can be attributed. + + diff --git a/src/ThreatModelForge.Analysis.Rules/UnauthenticatedBoundaryProcessRule.cs b/src/ThreatModelForge.Analysis.Rules/UnauthenticatedBoundaryProcessRule.cs index 997b343..6c35249 100644 --- a/src/ThreatModelForge.Analysis.Rules/UnauthenticatedBoundaryProcessRule.cs +++ b/src/ThreatModelForge.Analysis.Rules/UnauthenticatedBoundaryProcessRule.cs @@ -33,6 +33,16 @@ public UnauthenticatedBoundaryProcessRule() new PropertyBinding("process", "AuthenticationScheme", "None"), }; + /// + public override StrideCategory? Stride => StrideCategory.Spoofing; + + /// + public override IReadOnlyList ThreatReferences => new[] + { + ThreatReference.Cwe(306), + ThreatReference.Capec(115), + }; + /// public override void Evaluate(RuleEvaluationContext context) { diff --git a/src/ThreatModelForge.Analysis.Rules/UnauthenticatedExternalSourceRule.cs b/src/ThreatModelForge.Analysis.Rules/UnauthenticatedExternalSourceRule.cs index f6aa83f..7104b2b 100644 --- a/src/ThreatModelForge.Analysis.Rules/UnauthenticatedExternalSourceRule.cs +++ b/src/ThreatModelForge.Analysis.Rules/UnauthenticatedExternalSourceRule.cs @@ -36,6 +36,16 @@ public UnauthenticatedExternalSourceRule() new PropertyBinding("external", "AuthenticationScheme", "None"), }; + /// + public override StrideCategory? Stride => StrideCategory.Spoofing; + + /// + public override IReadOnlyList ThreatReferences => new[] + { + ThreatReference.Cwe(287), + ThreatReference.Capec(151), + }; + /// public override void Evaluate(RuleEvaluationContext context) { diff --git a/src/ThreatModelForge.Analysis.Rules/UnencryptedSecretStoreRule.cs b/src/ThreatModelForge.Analysis.Rules/UnencryptedSecretStoreRule.cs index 6569527..6da9c17 100644 --- a/src/ThreatModelForge.Analysis.Rules/UnencryptedSecretStoreRule.cs +++ b/src/ThreatModelForge.Analysis.Rules/UnencryptedSecretStoreRule.cs @@ -32,6 +32,15 @@ public UnencryptedSecretStoreRule() new PropertyBinding("datastore", "StoresCredentials"), }; + /// + public override StrideCategory? Stride => StrideCategory.InformationDisclosure; + + /// + public override IReadOnlyList ThreatReferences => new[] + { + ThreatReference.Cwe(311), + }; + /// public override void Evaluate(RuleEvaluationContext context) { diff --git a/src/ThreatModelForge.Analysis.Rules/UnprotectedCredentialStoreRule.cs b/src/ThreatModelForge.Analysis.Rules/UnprotectedCredentialStoreRule.cs index 5a4209c..eb192e0 100644 --- a/src/ThreatModelForge.Analysis.Rules/UnprotectedCredentialStoreRule.cs +++ b/src/ThreatModelForge.Analysis.Rules/UnprotectedCredentialStoreRule.cs @@ -32,6 +32,15 @@ public UnprotectedCredentialStoreRule() new PropertyBinding("datastore", "StoresCredentials"), }; + /// + public override StrideCategory? Stride => StrideCategory.InformationDisclosure; + + /// + public override IReadOnlyList ThreatReferences => new[] + { + ThreatReference.Cwe(284), + }; + /// public override void Evaluate(RuleEvaluationContext context) { diff --git a/src/ThreatModelForge.Analysis.Rules/UnsanitizedCrossBoundaryInputRule.cs b/src/ThreatModelForge.Analysis.Rules/UnsanitizedCrossBoundaryInputRule.cs index 41b58b1..a44d90c 100644 --- a/src/ThreatModelForge.Analysis.Rules/UnsanitizedCrossBoundaryInputRule.cs +++ b/src/ThreatModelForge.Analysis.Rules/UnsanitizedCrossBoundaryInputRule.cs @@ -32,6 +32,16 @@ public UnsanitizedCrossBoundaryInputRule() new PropertyBinding("process", "SanitizesInput", "No"), }; + /// + public override StrideCategory? Stride => StrideCategory.Tampering; + + /// + public override IReadOnlyList ThreatReferences => new[] + { + ThreatReference.Cwe(20), + ThreatReference.Capec(66), + }; + /// public override void Evaluate(RuleEvaluationContext context) { diff --git a/src/ThreatModelForge.Analysis.Rules/UnsanitizedExternalOutputRule.cs b/src/ThreatModelForge.Analysis.Rules/UnsanitizedExternalOutputRule.cs index e9a3f8e..75ae370 100644 --- a/src/ThreatModelForge.Analysis.Rules/UnsanitizedExternalOutputRule.cs +++ b/src/ThreatModelForge.Analysis.Rules/UnsanitizedExternalOutputRule.cs @@ -32,6 +32,16 @@ public UnsanitizedExternalOutputRule() new PropertyBinding("process", "SanitizesOutput", "No"), }; + /// + public override StrideCategory? Stride => StrideCategory.Tampering; + + /// + public override IReadOnlyList ThreatReferences => new[] + { + ThreatReference.Cwe(116), + ThreatReference.Capec(63), + }; + /// public override void Evaluate(RuleEvaluationContext context) { diff --git a/src/ThreatModelForge.Analysis.Rules/UnsignedAuditLogStoreRule.cs b/src/ThreatModelForge.Analysis.Rules/UnsignedAuditLogStoreRule.cs index 28f32c6..42a1a8b 100644 --- a/src/ThreatModelForge.Analysis.Rules/UnsignedAuditLogStoreRule.cs +++ b/src/ThreatModelForge.Analysis.Rules/UnsignedAuditLogStoreRule.cs @@ -32,6 +32,15 @@ public UnsignedAuditLogStoreRule() new PropertyBinding("datastore", "StoresLogData"), }; + /// + public override StrideCategory? Stride => StrideCategory.Repudiation; + + /// + public override IReadOnlyList ThreatReferences => new[] + { + ThreatReference.Cwe(778), + }; + /// public override void Evaluate(RuleEvaluationContext context) { diff --git a/src/ThreatModelForge.Analysis.Rules/WeakCipherRule.cs b/src/ThreatModelForge.Analysis.Rules/WeakCipherRule.cs index 3250849..a35f383 100644 --- a/src/ThreatModelForge.Analysis.Rules/WeakCipherRule.cs +++ b/src/ThreatModelForge.Analysis.Rules/WeakCipherRule.cs @@ -47,6 +47,16 @@ public WeakCipherRule() new PropertyBinding("flow", AlgorithmCustomAttributeName, "AES-CBC", "3DES", "RC4", "secretbox", "Other"), }; + /// + public override StrideCategory? Stride => StrideCategory.InformationDisclosure; + + /// + public override IReadOnlyList ThreatReferences => new[] + { + ThreatReference.Cwe(327), + ThreatReference.Capec(97), + }; + /// public override void Evaluate(RuleEvaluationContext context) { diff --git a/src/ThreatModelForge.Analysis.Rules/WeakProcessIsolationRule.cs b/src/ThreatModelForge.Analysis.Rules/WeakProcessIsolationRule.cs index e890e16..e579b6d 100644 --- a/src/ThreatModelForge.Analysis.Rules/WeakProcessIsolationRule.cs +++ b/src/ThreatModelForge.Analysis.Rules/WeakProcessIsolationRule.cs @@ -32,6 +32,15 @@ public WeakProcessIsolationRule() new PropertyBinding("process", "Isolation", "None"), }; + /// + public override StrideCategory? Stride => StrideCategory.ElevationOfPrivilege; + + /// + public override IReadOnlyList ThreatReferences => new[] + { + ThreatReference.Cwe(693), + }; + /// public override void Evaluate(RuleEvaluationContext context) { diff --git a/src/ThreatModelForge.Analysis/ApplyResult.cs b/src/ThreatModelForge.Analysis/ApplyResult.cs new file mode 100644 index 0000000..4a37376 --- /dev/null +++ b/src/ThreatModelForge.Analysis/ApplyResult.cs @@ -0,0 +1,17 @@ +namespace ThreatModelForge.Analysis +{ + /// + /// A summary of an idempotent pass over a model's register. + /// + public sealed class ApplyResult + { + /// Gets the number of new threats added to the register. + public int Added { get; init; } + + /// Gets the number of existing auto-generated threats refreshed in place. + public int Updated { get; init; } + + /// Gets the number of human-triaged threats left untouched. + public int Preserved { get; init; } + } +} diff --git a/src/ThreatModelForge.Analysis/Extensions.cs b/src/ThreatModelForge.Analysis/Extensions.cs index 5aae2fd..c131d8a 100644 --- a/src/ThreatModelForge.Analysis/Extensions.cs +++ b/src/ThreatModelForge.Analysis/Extensions.cs @@ -24,6 +24,10 @@ public static class Extensions private const string StorageComponentGenericTypeId = "GE.DS"; + // The Microsoft Threat Modeling Tool uses a leading "Select" entry as the unset sentinel for + // its list (drop-down) attributes; a selection pointing at it means the property has no value. + private const string UnsetListOption = "Select"; + /// /// Gets the name of the entity. /// @@ -419,6 +423,30 @@ public static bool TryGetCustomPropertyValues(this Entity entity, string propert } } + // Also read the property when it is stored as a typed list (drop-down) attribute — the form + // the Microsoft Threat Modeling Tool uses for schema-backed properties — so a model that + // carries the property as a typed selection (exported or tool-authored) is analyzed the same + // as one that carries it as a custom "name:value" attribute. A selection left on the unset + // sentinel is treated as absent. + foreach (var property in entity.Properties.OfType()) + { + if (!string.Equals(property.DisplayName, propertyName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (property.Value is string[] options && + property.SelectedIndex >= 0 && + property.SelectedIndex < options.Length) + { + string selected = options[property.SelectedIndex] ?? string.Empty; + if (selected.Length > 0 && !string.Equals(selected, UnsetListOption, StringComparison.OrdinalIgnoreCase)) + { + values.Add(selected); + } + } + } + return values.Count > 0; } diff --git a/src/ThreatModelForge.Analysis/FindingCollector.cs b/src/ThreatModelForge.Analysis/FindingCollector.cs new file mode 100644 index 0000000..f15bcf5 --- /dev/null +++ b/src/ThreatModelForge.Analysis/FindingCollector.cs @@ -0,0 +1,35 @@ +namespace ThreatModelForge.Analysis +{ + using System; + using System.Collections.Generic; + + /// + /// A that captures the raw rule findings — each with its source rule + /// and target element/flow — so the threat projection can key every finding to the element or flow + /// it was raised against. This is how validation findings inform the threat register. + /// + internal sealed class FindingCollector : MessageWriter + { + private readonly List messages = new List(); + + /// Gets the collected findings. + public IReadOnlyList Messages => this.messages; + + /// + public override void Write(Message message) + { + if (message == null) + { + throw new ArgumentNullException(nameof(message)); + } + + this.messages.Add(message); + } + + /// + public override void WriteCore(MessageSeverity severity, string messageID, string text) + { + // The projection reads whole Message objects via Write; the flat overload is unused. + } + } +} diff --git a/src/ThreatModelForge.Analysis/GeneratedThreat.cs b/src/ThreatModelForge.Analysis/GeneratedThreat.cs new file mode 100644 index 0000000..1b55190 --- /dev/null +++ b/src/ThreatModelForge.Analysis/GeneratedThreat.cs @@ -0,0 +1,64 @@ +namespace ThreatModelForge.Analysis +{ + using System; + using System.Collections.Generic; + + /// + /// A threat projected from a validation finding: the rule that detected it, its STRIDE category and + /// external references, and the element or flow it was raised against. A generated threat is simply + /// the persistable, lifecycle-bearing form of a finding — the detection is entirely the rule's. + /// + public sealed class GeneratedThreat + { + /// Gets the deterministic identifier of the threat ({targetGuid:N}:{ruleId}). + public string Id { get; init; } = string.Empty; + + /// Gets the identifier of the rule that detected the threat (for example TM1023). + public string RuleId { get; init; } = string.Empty; + + /// Gets the STRIDE category. + public StrideCategory Category { get; init; } + + /// Gets the threat title (the finding text). + public string Title { get; init; } = string.Empty; + + /// Gets the suggested mitigation (the rule's help text). + public string? Mitigation { get; init; } + + /// Gets the rule severity (error, warning, or info). + public string Severity { get; init; } = "warning"; + + /// Gets the coarse priority derived from severity (High / Medium / Low). + public string Priority { get; init; } = "Medium"; + + /// Gets the external catalog references. + public IReadOnlyList References { get; init; } = Array.Empty(); + + /// Gets the source element identifier (the target element for an element-scoped finding). + public Guid SourceGuid { get; init; } + + /// Gets the target element identifier (empty for an element-scoped finding). + public Guid TargetGuid { get; init; } + + /// Gets the flow identifier (empty for an element-scoped finding). + public Guid FlowGuid { get; init; } + + /// Gets the diagram identifier. + public Guid DiagramGuid { get; init; } + + /// Gets the source element display name. + public string? SourceName { get; init; } + + /// Gets the target element display name. + public string? TargetName { get; init; } + + /// Gets the flow display name. + public string? FlowName { get; init; } + + /// Gets a value indicating whether the threat is scoped to a flow (an interaction) rather than a single element. + public bool IsFlowScoped { get; init; } + + /// Gets the human-readable scope string (source -> target for a flow, else the element name). + public string InteractionString { get; init; } = string.Empty; + } +} diff --git a/src/ThreatModelForge.Analysis/GenerationResult.cs b/src/ThreatModelForge.Analysis/GenerationResult.cs new file mode 100644 index 0000000..60269c1 --- /dev/null +++ b/src/ThreatModelForge.Analysis/GenerationResult.cs @@ -0,0 +1,25 @@ +namespace ThreatModelForge.Analysis +{ + using System; + using System.Collections.Generic; + + /// + /// The outcome of a threat-generation pass: every threat projected from the model's validation + /// findings. + /// + public sealed class GenerationResult + { + /// Initializes a new instance of the class. + /// The generated threats. + public GenerationResult(IReadOnlyList threats) + { + this.Threats = threats ?? Array.Empty(); + } + + /// Gets every generated threat. + public IReadOnlyList Threats { get; } + + /// Gets the number of generated threats. + public int Count => this.Threats.Count; + } +} diff --git a/src/ThreatModelForge.Analysis/IsExternalInit.cs b/src/ThreatModelForge.Analysis/IsExternalInit.cs new file mode 100644 index 0000000..d2cc013 --- /dev/null +++ b/src/ThreatModelForge.Analysis/IsExternalInit.cs @@ -0,0 +1,13 @@ +namespace System.Runtime.CompilerServices +{ + using System.ComponentModel; + + /// + /// Enables C# init-only property accessors on netstandard2.0, which does not ship + /// this compiler-support type in-box. Referenced only by the compiler; never used at runtime. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + internal static class IsExternalInit + { + } +} diff --git a/src/ThreatModelForge.Analysis/KnowledgeBaseCatalog.cs b/src/ThreatModelForge.Analysis/KnowledgeBaseCatalog.cs new file mode 100644 index 0000000..2dd4560 --- /dev/null +++ b/src/ThreatModelForge.Analysis/KnowledgeBaseCatalog.cs @@ -0,0 +1,268 @@ +namespace ThreatModelForge.Analysis +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Reflection; + using ThreatModelForge.Editing; + using ThreatModelForge.KnowledgeBase; + + /// + /// Builds Threat Model Forge's own, clean-room knowledge base: the default embedded when a model + /// is exported to .tm7 without one. It declares the generic element types (each drawn from + /// its and given a small, original stencil icon), a + /// standard element type for every authoring stencil (so the Microsoft Threat Modeling Tool's + /// palette mirrors Threat Model Forge's), the six STRIDE categories, and one threat type per + /// threat-bearing analysis rule so the register that Threat Model Forge writes has matching type + /// metadata in the tool. + /// + public static class KnowledgeBaseCatalog + { + /// The manifest name shown for the bundled knowledge base. + public const string DefaultName = "Threat Model Forge Core"; + + private static readonly Guid DefaultId = new Guid("7e3f1d52-0000-4000-8000-746d666f7267"); + + // Maps a stencil's base primitive to the generic element type it derives from, plus the visual + // representation, image placement, and icon that its standard element type inherits. + private static readonly IReadOnlyDictionary GenericBases = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["process"] = new GenericMapping("GE.P", ElementVisualRepresentation.Ellipse, "Centered on stencil", "process"), + ["datastore"] = new GenericMapping("GE.DS", ElementVisualRepresentation.ParallelLines, "Lower right of stencil", "datastore"), + ["external"] = new GenericMapping("GE.EI", ElementVisualRepresentation.Rectangle, "Lower right of stencil", "external"), + ["dataflow"] = new GenericMapping("GE.DF", ElementVisualRepresentation.Line, "Before label", "dataflow"), + ["boundary"] = new GenericMapping("GE.TB.B", ElementVisualRepresentation.BorderBoundary, "Before label", "trustborder"), + }; + + /// + /// Builds the default knowledge base using the built-in analysis rule set for its threat types. + /// + /// A newly constructed . + public static KnowledgeBaseData CreateDefault() + { + using (RuleSet ruleSet = LoadDefaultRuleSet()) + { + return CreateDefault(ruleSet); + } + } + + /// + /// Builds the default knowledge base, deriving one threat type per threat-bearing rule in + /// . + /// + /// The rule set whose threat-bearing rules become threat types. + /// A newly constructed . + public static KnowledgeBaseData CreateDefault(RuleSet ruleSet) + { + if (ruleSet == null) + { + throw new ArgumentNullException(nameof(ruleSet)); + } + + KnowledgeBaseData knowledgeBase = new KnowledgeBaseData + { + Manifest = new Manifest + { + Name = DefaultName, + Id = DefaultId, + Version = "1.0.0", + Author = "Threat Model Forge", + }, + }; + + AddGenericElements(knowledgeBase.GenericElements); + AddStandardElements(knowledgeBase.StandardElements); + AddThreatCategories(knowledgeBase.ThreatCategories); + AddThreatTypes(knowledgeBase.ThreatTypes, ruleSet); + + return knowledgeBase; + } + + /// + /// Determines whether a knowledge base is Threat Model Forge's own default (as opposed to a + /// user-supplied or third-party one), by matching its manifest identifier. Used to decide when + /// an export may safely rebuild and re-type the knowledge base versus leaving a foreign one + /// untouched. + /// + /// The knowledge base to test. + /// when the knowledge base is the built-in default. + public static bool IsDefault(KnowledgeBaseData knowledgeBase) + { + if (knowledgeBase == null) + { + throw new ArgumentNullException(nameof(knowledgeBase)); + } + + return knowledgeBase.Manifest != null && knowledgeBase.Manifest.Id == DefaultId; + } + + private static void AddGenericElements(List elements) + { + elements.Add(GenericElement("GE.P", "Generic Process", "A representation of a generic process.", ElementVisualRepresentation.Ellipse, "Centered on stencil", "process")); + elements.Add(GenericElement("GE.EI", "Generic External Interactor", "A representation of a generic external interactor.", ElementVisualRepresentation.Rectangle, "Lower right of stencil", "external")); + elements.Add(GenericElement("GE.DS", "Generic Data Store", "A representation of a generic data store.", ElementVisualRepresentation.ParallelLines, "Lower right of stencil", "datastore")); + elements.Add(GenericElement("GE.DF", "Generic Data Flow", "A representation of a generic data flow.", ElementVisualRepresentation.Line, "Before label", "dataflow")); + elements.Add(GenericElement("GE.TB.L", "Generic Trust Line Boundary", "A representation of a trust boundary drawn as a line.", ElementVisualRepresentation.LineBoundary, "Before label", "trustline")); + elements.Add(GenericElement("GE.TB.B", "Generic Trust Border Boundary", "A representation of a trust boundary drawn as a border.", ElementVisualRepresentation.BorderBoundary, "Before label", "trustborder")); + elements.Add(GenericElement("GE.A", "Generic Annotation", "A representation of a free-text annotation.", ElementVisualRepresentation.Annotation, "Before label", "annotation")); + } + + private static void AddStandardElements(List elements) + { + foreach (StencilDto stencil in StencilCatalog.All) + { + // The primitive stencils already exist as generic element types; only richer stencils + // (Azure SQL, AKS, a human actor, and so on) become standard element subtypes. + if (string.Equals(stencil.Id, stencil.Base, StringComparison.OrdinalIgnoreCase) || + !GenericBases.TryGetValue(stencil.Base, out GenericMapping mapping)) + { + continue; + } + + elements.Add(new ElementType + { + Id = stencil.Id, + Name = stencil.Label, + Description = string.IsNullOrEmpty(stencil.Blurb) ? stencil.Label : stencil.Blurb, + ParentId = mapping.GenericId, + Representation = mapping.Representation, + ImageLocation = mapping.ImageLocation, + ImageSource = LoadIcon(mapping.Icon), + Hidden = false, + }); + } + } + + private static ElementType GenericElement(string id, string name, string description, ElementVisualRepresentation representation, string imageLocation, string icon) + { + return new ElementType + { + Id = id, + Name = name, + Description = description, + ParentId = "ROOT", + Representation = representation, + ImageLocation = imageLocation, + ImageSource = LoadIcon(icon), + Hidden = false, + }; + } + + private static string LoadIcon(string name) + { + Assembly assembly = typeof(KnowledgeBaseCatalog).Assembly; + string suffix = ".Stencils." + name + ".png"; + string? resource = null; + foreach (string candidate in assembly.GetManifestResourceNames()) + { + if (candidate.EndsWith(suffix, StringComparison.Ordinal)) + { + resource = candidate; + break; + } + } + + if (resource == null) + { + return string.Empty; + } + + using (Stream? stream = assembly.GetManifestResourceStream(resource)) + { + if (stream == null) + { + return string.Empty; + } + + using (MemoryStream buffer = new MemoryStream()) + { + stream.CopyTo(buffer); + return Convert.ToBase64String(buffer.ToArray()); + } + } + } + + private static void AddThreatCategories(List categories) + { + categories.Add(ThreatCategoryFor("S", "Spoofing", "Impersonating something or someone else.")); + categories.Add(ThreatCategoryFor("T", "Tampering", "Unauthorized modification of data or code.")); + categories.Add(ThreatCategoryFor("R", "Repudiation", "Denying an action without other parties being able to prove otherwise.")); + categories.Add(ThreatCategoryFor("I", "Information Disclosure", "Exposure of information to parties not authorized to see it.")); + categories.Add(ThreatCategoryFor("D", "Denial of Service", "Denying or degrading service to legitimate users.")); + categories.Add(ThreatCategoryFor("E", "Elevation of Privilege", "Gaining capabilities without proper authorization.")); + } + + private static ThreatCategory ThreatCategoryFor(string id, string name, string description) + { + return new ThreatCategory + { + Id = id, + Name = name, + ShortDescription = description, + LongDescription = description, + }; + } + + private static void AddThreatTypes(List threatTypes, RuleSet ruleSet) + { + foreach (Rule rule in ruleSet.Rules) + { + if (rule.Stride is not StrideCategory category) + { + continue; + } + + string description = string.IsNullOrEmpty(rule.HelpText) ? rule.FullDescription : rule.HelpText; + ThreatType threatType = new ThreatType + { + Id = rule.ID, + ShortTitle = rule.FullDescription, + Category = CategoryLetter(category), + Description = description, + }; + + threatTypes.Add(threatType); + } + } + + private static string CategoryLetter(StrideCategory category) + { + return category switch + { + StrideCategory.Spoofing => "S", + StrideCategory.Tampering => "T", + StrideCategory.Repudiation => "R", + StrideCategory.InformationDisclosure => "I", + StrideCategory.DenialOfService => "D", + StrideCategory.ElevationOfPrivilege => "E", + _ => string.Empty, + }; + } + + private static RuleSet LoadDefaultRuleSet() + { + Assembly rules = Assembly.Load("ThreatModelForge.Analysis.Rules"); + return RuleSet.LoadDefault(new[] { rules }); + } + + private readonly struct GenericMapping + { + public GenericMapping(string genericId, ElementVisualRepresentation representation, string imageLocation, string icon) + { + this.GenericId = genericId; + this.Representation = representation; + this.ImageLocation = imageLocation; + this.Icon = icon; + } + + public string GenericId { get; } + + public ElementVisualRepresentation Representation { get; } + + public string ImageLocation { get; } + + public string Icon { get; } + } + } +} diff --git a/src/ThreatModelForge.Analysis/PropertyBinding.cs b/src/ThreatModelForge.Analysis/PropertyBinding.cs index a603c37..9a646a6 100644 --- a/src/ThreatModelForge.Analysis/PropertyBinding.cs +++ b/src/ThreatModelForge.Analysis/PropertyBinding.cs @@ -7,7 +7,7 @@ namespace ThreatModelForge.Analysis /// Declares a typed custom property that a rule reads, and (optionally) the values of that /// property the rule flags as risky. A rule owns the policy attached to a property, so an /// authoring surface can join the property schema with the rule set and tell an author which - /// rule consumes a property — and which values to avoid — without a separate lint pass. Because + /// rule consumes a property — and which values to avoid — without a separate analysis pass. Because /// the binding lives on the rule, the rule's severity is never duplicated and the link cannot /// drift from the code that reads the property. /// diff --git a/src/ThreatModelForge.Analysis/README.md b/src/ThreatModelForge.Analysis/README.md index f05bb18..a8977de 100644 --- a/src/ThreatModelForge.Analysis/README.md +++ b/src/ThreatModelForge.Analysis/README.md @@ -1,3 +1,5 @@ # Threat Model Analysis Object Model -This project provides the core object model used to inspect threat model (`.tm7`) documents. It exposes the base types that analysis rules build on, so additional rule sets can live in separate assemblies by deriving from those base classes. The `tmforge lint` command runs rules against a model using this library. +This project provides the core object model used to inspect threat model (`.tm7`) documents. It exposes the base types that analysis rules build on, so additional rule sets can live in separate assemblies by deriving from those base classes. The `tmforge analyze` command runs rules against a model using this library. + +It also projects those findings into the model's persisted **threat register**: a rule that declares a STRIDE category (`Rule.Stride`) becomes a threat via `ThreatGenerator`, which powers the `tmforge threats` command. Validation and threat generation therefore share one detection engine — the difference is lifecycle (a finding is transient and gated; a threat is persisted and triaged). diff --git a/src/ThreatModelForge.Analysis/Rule.cs b/src/ThreatModelForge.Analysis/Rule.cs index 3faf4de..2c5cbc2 100644 --- a/src/ThreatModelForge.Analysis/Rule.cs +++ b/src/ThreatModelForge.Analysis/Rule.cs @@ -87,10 +87,25 @@ public string ID /// Gets the typed custom properties this rule reads, together with the values it flags as /// risky. A rule that reads a property overrides this so an authoring surface can join the /// property schema with the rule set and show which rule (and severity) consumes each - /// property without a separate lint pass. Empty by default. + /// property without a separate analysis pass. Empty by default. /// public virtual IReadOnlyList PropertyBindings => Array.Empty(); + /// + /// Gets the STRIDE category the rule's finding represents, or when the + /// rule is a structural or naming hygiene check rather than a threat. Threat generation reads + /// this to decide which findings become persisted threats, so a rule declares its own threat + /// identity in one place and there is no separate map to drift from. + /// + public virtual StrideCategory? Stride => null; + + /// + /// Gets the external catalog references (CWE / CAPEC / MITRE ATT&CK) for the threat this + /// rule detects. Declared together with on a threat-bearing rule; empty by + /// default. The mitigation is not duplicated here — it is the rule's . + /// + public virtual IReadOnlyList ThreatReferences => Array.Empty(); + /// public void Dispose() { diff --git a/src/ThreatModelForge.Analysis/Stencils/annotation.png b/src/ThreatModelForge.Analysis/Stencils/annotation.png new file mode 100644 index 0000000..be98493 Binary files /dev/null and b/src/ThreatModelForge.Analysis/Stencils/annotation.png differ diff --git a/src/ThreatModelForge.Analysis/Stencils/dataflow.png b/src/ThreatModelForge.Analysis/Stencils/dataflow.png new file mode 100644 index 0000000..d309278 Binary files /dev/null and b/src/ThreatModelForge.Analysis/Stencils/dataflow.png differ diff --git a/src/ThreatModelForge.Analysis/Stencils/datastore.png b/src/ThreatModelForge.Analysis/Stencils/datastore.png new file mode 100644 index 0000000..3a5bc7b Binary files /dev/null and b/src/ThreatModelForge.Analysis/Stencils/datastore.png differ diff --git a/src/ThreatModelForge.Analysis/Stencils/external.png b/src/ThreatModelForge.Analysis/Stencils/external.png new file mode 100644 index 0000000..b02c5d3 Binary files /dev/null and b/src/ThreatModelForge.Analysis/Stencils/external.png differ diff --git a/src/ThreatModelForge.Analysis/Stencils/process.png b/src/ThreatModelForge.Analysis/Stencils/process.png new file mode 100644 index 0000000..b075f7b Binary files /dev/null and b/src/ThreatModelForge.Analysis/Stencils/process.png differ diff --git a/src/ThreatModelForge.Analysis/Stencils/trustborder.png b/src/ThreatModelForge.Analysis/Stencils/trustborder.png new file mode 100644 index 0000000..0480004 Binary files /dev/null and b/src/ThreatModelForge.Analysis/Stencils/trustborder.png differ diff --git a/src/ThreatModelForge.Analysis/Stencils/trustline.png b/src/ThreatModelForge.Analysis/Stencils/trustline.png new file mode 100644 index 0000000..b3ec575 Binary files /dev/null and b/src/ThreatModelForge.Analysis/Stencils/trustline.png differ diff --git a/src/ThreatModelForge.Analysis/StrideCategory.cs b/src/ThreatModelForge.Analysis/StrideCategory.cs new file mode 100644 index 0000000..a517447 --- /dev/null +++ b/src/ThreatModelForge.Analysis/StrideCategory.cs @@ -0,0 +1,28 @@ +namespace ThreatModelForge.Analysis +{ + /// + /// The six STRIDE threat categories. A may declare the category its finding + /// represents (see ); a rule that leaves it unset is a structural or + /// naming hygiene check, not a threat. + /// + public enum StrideCategory + { + /// Impersonating something or someone else. + Spoofing, + + /// Unauthorized modification of data or code. + Tampering, + + /// Denying an action without other parties being able to prove otherwise. + Repudiation, + + /// Exposure of information to parties not authorized to see it. + InformationDisclosure, + + /// Denying or degrading service to legitimate users. + DenialOfService, + + /// Gaining capabilities without proper authorization. + ElevationOfPrivilege, + } +} diff --git a/src/ThreatModelForge.Analysis/ThreatGenerator.cs b/src/ThreatModelForge.Analysis/ThreatGenerator.cs new file mode 100644 index 0000000..43561b2 --- /dev/null +++ b/src/ThreatModelForge.Analysis/ThreatGenerator.cs @@ -0,0 +1,370 @@ +namespace ThreatModelForge.Analysis +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Linq; + using System.Reflection; + using ThreatModelForge.KnowledgeBase; + using ThreatModelForge.Model; + using ThreatModelForge.Model.Abstracts; + + /// + /// Projects the validation findings of a model into its persistent threat register. Detection is + /// entirely the rule set's job — this class does not re-implement any condition. It runs + /// the same rules that analyze runs, keeps only the findings from threat-bearing rules (those + /// whose rule declares a STRIDE category), frames each as a STRIDE threat against its element or + /// flow, and upserts it into the model with an idempotent, triage-preserving write. The difference + /// between a finding and a threat is lifecycle: a finding is transient and gated; a threat is + /// persisted and triaged (open -> mitigated -> accepted). + /// + public static class ThreatGenerator + { + /// Generates threats by running the default rule set over the model. + /// The model to analyze. + /// The generated threats. + public static GenerationResult Generate(ThreatModel model) + { + using (RuleSet ruleSet = LoadDefaultRuleSet()) + { + return Generate(model, ruleSet); + } + } + + /// Generates threats by projecting the given rule set's findings over the model. + /// The model to analyze. + /// The rule set whose findings are projected (the detection source). + /// The generated threats. + public static GenerationResult Generate(ThreatModel model, RuleSet ruleSet) + { + if (model == null) + { + throw new ArgumentNullException(nameof(model)); + } + + if (ruleSet == null) + { + throw new ArgumentNullException(nameof(ruleSet)); + } + + FindingCollector collector = new FindingCollector(); + RuleEvaluationContext context = new RuleEvaluationContext(model, collector); + ruleSet.Evaluate(context); + + List threats = new List(); + HashSet seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (Message message in collector.Messages) + { + Rule? rule = message.Source; + Entity? target = message.Target; + if (rule == null || target == null) + { + continue; + } + + if (rule.Stride is not StrideCategory category) + { + continue; + } + + GeneratedThreat threat = ToThreat(message, rule, target, category); + if (seen.Add(threat.Id)) + { + threats.Add(threat); + } + } + + threats.Sort(CompareThreats); + return new GenerationResult(threats); + } + + /// + /// Idempotently writes the generated threats into the model's register. Threats are keyed by + /// {targetGuid:N}:{ruleId}, so re-running updates in place: a human-triaged threat (any + /// state other than , including an accepted risk) is + /// preserved untouched, an existing auto-generated threat is refreshed, and a newly-detected + /// threat is added. Nothing is deleted, so no triage is ever lost. + /// + /// The model whose register is updated. + /// The generation result to apply. + /// A summary of the changes made. + public static ApplyResult Apply(ThreatModel model, GenerationResult result) + { + if (model == null) + { + throw new ArgumentNullException(nameof(model)); + } + + if (result == null) + { + throw new ArgumentNullException(nameof(result)); + } + + int added = 0; + int updated = 0; + int preserved = 0; + int nextId = NextThreatId(model); + foreach (GeneratedThreat threat in result.Threats) + { + if (model.AllThreatsDictionary.TryGetValue(threat.Id, out Threat? existing)) + { + if (existing.State != ThreatState.AutoGenerated) + { + preserved++; + continue; + } + + UpdateThreat(existing, threat); + updated++; + } + else + { + model.AllThreatsDictionary[threat.Id] = CreateThreat(threat, nextId); + nextId++; + added++; + } + } + + model.ThreatGenerationEnabled = true; + return new ApplyResult { Added = added, Updated = updated, Preserved = preserved }; + } + + /// + /// Records risk acceptance for a threat: moves it to + /// and stores the justification. The threat stays visible in the register and report but is no + /// longer treated as open. Because acceptance is per-threat (one rule finding on one element or + /// flow), it can never silently swallow an unrelated finding. + /// + /// The model containing the threat. + /// The threat identifier (its register key, interaction key, or numeric id). + /// The justification for accepting the risk. + /// if a matching threat was found and accepted; otherwise . + public static bool Accept(ThreatModel model, string threatId, string reason) + { + if (model == null) + { + throw new ArgumentNullException(nameof(model)); + } + + if (string.IsNullOrWhiteSpace(threatId)) + { + throw new ArgumentException("Value cannot be null or empty.", nameof(threatId)); + } + + Threat? threat = FindThreat(model, threatId); + if (threat == null) + { + return false; + } + + threat.State = ThreatState.NotApplicable; + threat.StateInformation = reason ?? string.Empty; + threat.ModifiedAt = DateTime.UtcNow; + return true; + } + + private static RuleSet LoadDefaultRuleSet() + { + Assembly rules = Assembly.Load("ThreatModelForge.Analysis.Rules"); + return RuleSet.LoadDefault(new[] { rules }); + } + + private static GeneratedThreat ToThreat(Message message, Rule rule, Entity target, StrideCategory category) + { + bool isFlow = target is Connector; + Guid sourceGuid = target.Guid; + Guid targetGuid = Guid.Empty; + Guid flowGuid = Guid.Empty; + string? sourceName = NameOf(target); + string? targetName = null; + string? flowName = null; + string interactionString = sourceName ?? target.Guid.ToString(); + + if (target is Connector connector) + { + sourceGuid = connector.SourceGuid; + targetGuid = connector.TargetGuid; + flowGuid = connector.Guid; + flowName = NameOf(connector); + sourceName = NameInDiagram(message.Model, connector.SourceGuid); + targetName = NameInDiagram(message.Model, connector.TargetGuid); + interactionString = (sourceName ?? "?") + " -> " + (targetName ?? "?"); + } + + return new GeneratedThreat + { + Id = string.Format(CultureInfo.InvariantCulture, "{0:N}:{1}", target.Guid, rule.ID), + RuleId = rule.ID, + Category = category, + Title = message.Text ?? rule.FullDescription, + Mitigation = string.IsNullOrWhiteSpace(rule.HelpText) ? null : rule.HelpText, + Severity = rule.Severity.ToString().ToLowerInvariant(), + Priority = PriorityFor(rule.Severity), + References = rule.ThreatReferences, + SourceGuid = sourceGuid, + TargetGuid = targetGuid, + FlowGuid = flowGuid, + DiagramGuid = message.Model?.Guid ?? Guid.Empty, + SourceName = sourceName, + TargetName = targetName, + FlowName = flowName, + IsFlowScoped = isFlow, + InteractionString = interactionString, + }; + } + + private static Threat CreateThreat(GeneratedThreat threat, int id) + { + return new Threat + { + Id = id, + TypeId = threat.RuleId, + SourceGuid = threat.SourceGuid, + TargetGuid = threat.TargetGuid, + FlowGuid = threat.FlowGuid, + DrawingSurfaceGuid = threat.DiagramGuid, + State = ThreatState.AutoGenerated, + InteractionKey = threat.Id, + InteractionString = threat.InteractionString, + Title = threat.Title, + Priority = threat.Priority, + UserThreatCategory = CategoryName(threat.Category), + StateInformation = string.Empty, + ModifiedAt = DateTime.UtcNow, + Properties = BuildProperties(threat), + }; + } + + private static void UpdateThreat(Threat existing, GeneratedThreat threat) + { + existing.TypeId = threat.RuleId; + existing.SourceGuid = threat.SourceGuid; + existing.TargetGuid = threat.TargetGuid; + existing.FlowGuid = threat.FlowGuid; + existing.DrawingSurfaceGuid = threat.DiagramGuid; + existing.InteractionKey = threat.Id; + existing.InteractionString = threat.InteractionString; + existing.Title = threat.Title; + existing.Priority = threat.Priority; + existing.UserThreatCategory = CategoryName(threat.Category); + existing.Properties = BuildProperties(threat); + existing.ModifiedAt = DateTime.UtcNow; + } + + private static Dictionary BuildProperties(GeneratedThreat threat) + { + Dictionary props = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Rule"] = threat.RuleId, + }; + if (!string.IsNullOrWhiteSpace(threat.Mitigation)) + { + props["Mitigation"] = threat.Mitigation!; + } + + if (threat.References.Count > 0) + { + props["References"] = string.Join(", ", threat.References.Select(r => r.Id)); + } + + return props; + } + + private static string PriorityFor(MessageSeverity severity) + { + switch (severity) + { + case MessageSeverity.Error: + return "High"; + case MessageSeverity.Info: + return "Low"; + default: + return "Medium"; + } + } + + private static string CategoryName(StrideCategory category) + { + switch (category) + { + case StrideCategory.InformationDisclosure: + return "Information Disclosure"; + case StrideCategory.DenialOfService: + return "Denial of Service"; + case StrideCategory.ElevationOfPrivilege: + return "Elevation of Privilege"; + default: + return category.ToString(); + } + } + + private static int NextThreatId(ThreatModel model) + { + int max = 0; + foreach (Threat threat in model.AllThreatsDictionary.Values) + { + if (threat.Id > max) + { + max = threat.Id; + } + } + + return max + 1; + } + + private static Threat? FindThreat(ThreatModel model, string threatId) + { + if (model.AllThreatsDictionary.TryGetValue(threatId, out Threat? byKey)) + { + return byKey; + } + + foreach (KeyValuePair pair in model.AllThreatsDictionary) + { + if (string.Equals(pair.Value.InteractionKey, threatId, StringComparison.OrdinalIgnoreCase)) + { + return pair.Value; + } + + if (string.Equals(pair.Value.Id.ToString(CultureInfo.InvariantCulture), threatId, StringComparison.Ordinal)) + { + return pair.Value; + } + } + + return null; + } + + private static string? NameInDiagram(DrawingSurfaceModel? diagram, Guid id) + { + if (diagram != null && diagram.Borders.TryGetValue(id, out object? value) && value is Entity entity) + { + return NameOf(entity); + } + + return null; + } + + private static string? NameOf(Entity entity) + { + return entity.Name() ?? entity.HeaderName(); + } + + private static int CompareThreats(GeneratedThreat left, GeneratedThreat right) + { + int byCategory = left.Category.CompareTo(right.Category); + if (byCategory != 0) + { + return byCategory; + } + + int byRule = string.CompareOrdinal(left.RuleId, right.RuleId); + if (byRule != 0) + { + return byRule; + } + + return string.CompareOrdinal(left.Id, right.Id); + } + } +} diff --git a/src/ThreatModelForge.Analysis/ThreatModelForge.Analysis.csproj b/src/ThreatModelForge.Analysis/ThreatModelForge.Analysis.csproj index 5495d08..9efd551 100644 --- a/src/ThreatModelForge.Analysis/ThreatModelForge.Analysis.csproj +++ b/src/ThreatModelForge.Analysis/ThreatModelForge.Analysis.csproj @@ -9,6 +9,7 @@ + @@ -30,6 +31,10 @@ + + + + netstandard2.0 ThreatModelForge.Analysis diff --git a/src/ThreatModelForge.Analysis/ThreatReference.cs b/src/ThreatModelForge.Analysis/ThreatReference.cs new file mode 100644 index 0000000..65415f1 --- /dev/null +++ b/src/ThreatModelForge.Analysis/ThreatReference.cs @@ -0,0 +1,51 @@ +namespace ThreatModelForge.Analysis +{ + using System; + + /// + /// A first-class reference from a (and the threat projected from its finding) to + /// an external catalog entry — CWE, CAPEC, or MITRE ATT&CK. References are typed rather than + /// free text because external catalogs are a graph of cross-referenced identifiers. Construct with + /// , , or . + /// + public sealed class ThreatReference + { + /// Initializes a new instance of the class. + /// The catalog (for example CWE, CAPEC, ATTACK). + /// The identifier within the catalog (for example CWE-287). + /// An optional canonical URL for the referenced entry. + public ThreatReference(string catalog, string id, string? url = null) + { + this.Catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + this.Id = id ?? throw new ArgumentNullException(nameof(id)); + this.Url = url; + } + + /// Gets the catalog the reference belongs to (for example CWE, CAPEC, ATTACK). + public string Catalog { get; } + + /// Gets the identifier within the catalog (for example CWE-287, CAPEC-151). + public string Id { get; } + + /// Gets an optional canonical URL for the referenced entry. + public string? Url { get; } + + /// Creates a CWE (Common Weakness Enumeration) reference. + /// The numeric CWE identifier. + /// A new reference. + public static ThreatReference Cwe(int id) => + new ThreatReference("CWE", $"CWE-{id}", $"https://cwe.mitre.org/data/definitions/{id}.html"); + + /// Creates a CAPEC (Common Attack Pattern Enumeration and Classification) reference. + /// The numeric CAPEC identifier. + /// A new reference. + public static ThreatReference Capec(int id) => + new ThreatReference("CAPEC", $"CAPEC-{id}", $"https://capec.mitre.org/data/definitions/{id}.html"); + + /// Creates a MITRE ATT&CK technique reference. + /// The technique identifier (for example T1190). + /// A new reference. + public static ThreatReference Attack(string techniqueId) => + new ThreatReference("ATTACK", techniqueId, $"https://attack.mitre.org/techniques/{techniqueId}/"); + } +} diff --git a/src/ThreatModelForge.Analysis/Tm7ExportPreparer.cs b/src/ThreatModelForge.Analysis/Tm7ExportPreparer.cs new file mode 100644 index 0000000..9a9cfc7 --- /dev/null +++ b/src/ThreatModelForge.Analysis/Tm7ExportPreparer.cs @@ -0,0 +1,48 @@ +namespace ThreatModelForge.Analysis +{ + using System; + using ThreatModelForge.Editing; + using ThreatModelForge.KnowledgeBase; + using ThreatModelForge.Model; + + /// + /// Prepares an in-memory model for export to the Microsoft Threat Modeling Tool's .tm7 + /// format so any write path (the CLI authoring verbs, convert, and the engine's Studio/API + /// export) produces a file that opens in the tool. It embeds Threat Model Forge's own knowledge + /// base and projects the element property schema onto the model so known properties render as + /// first-class, typed tool properties rather than free-form custom attributes. + /// + /// + /// A model that already carries a foreign knowledge base (for example, one loaded from a file + /// authored in the tool, or supplied through convert --knowledge-base) is left untouched, + /// because its attributes follow a different naming scheme. Only an absent or Threat Model + /// Forge-authored knowledge base is rebuilt, which keeps the operation idempotent under iterative + /// authoring: re-preparing an already-prepared model rebuilds the same default knowledge base and + /// leaves the already-typed properties in place while typing any newly added ones. + /// + public static class Tm7ExportPreparer + { + /// + /// Ensures the model carries the default knowledge base and has its schema-backed properties + /// typed, unless it already carries a foreign knowledge base. + /// + /// The model to prepare; it is mutated in place. + public static void Prepare(ThreatModel model) + { + if (model == null) + { + throw new ArgumentNullException(nameof(model)); + } + + if (model.KnowledgeBase != null && !KnowledgeBaseCatalog.IsDefault(model.KnowledgeBase)) + { + return; + } + + KnowledgeBaseData knowledgeBase = KnowledgeBaseCatalog.CreateDefault(); + SchemaBackedProperties.Apply(model, knowledgeBase); + StencilSubtypeProjection.Apply(model, knowledgeBase); + model.KnowledgeBase = knowledgeBase; + } + } +} diff --git a/src/ThreatModelForge.Api/Program.cs b/src/ThreatModelForge.Api/Program.cs index bb03d63..5367a67 100644 --- a/src/ThreatModelForge.Api/Program.cs +++ b/src/ThreatModelForge.Api/Program.cs @@ -62,8 +62,11 @@ public static void Main(string[] args) app.MapGet("/v1/property-schema", () => TypedResults.Ok(EngineService.GetPropertySchema())) .WithName("GetPropertySchema") .WithTags("Catalog"); - app.MapPost("/v1/model/validate", (TmForgeModelDto model) => TypedResults.Ok(EngineService.Validate(model))) - .WithName("ValidateModel") + app.MapPost("/v1/model/analyze", (TmForgeModelDto model) => TypedResults.Ok(EngineService.Analyze(model))) + .WithName("AnalyzeModel") + .WithTags("Model"); + app.MapPost("/v1/model/threats", (TmForgeModelDto model) => TypedResults.Ok(EngineService.GenerateThreats(model))) + .WithName("GenerateThreats") .WithTags("Model"); app.MapPost( "/v1/model/merge", diff --git a/src/ThreatModelForge.Api/README.md b/src/ThreatModelForge.Api/README.md index 53f5b8f..164506d 100644 --- a/src/ThreatModelForge.Api/README.md +++ b/src/ThreatModelForge.Api/README.md @@ -20,7 +20,7 @@ Studio's typed client is generated from it. A checked-in copy lives at | `GET /v1/rules` | Catalog | List analysis rules. | | `GET /v1/rule-packs` | Catalog | List rule packs. | | `GET /v1/property-schema` | Catalog | List the typed custom-property schema (the values rules read). | -| `POST /v1/model/validate` | Model | Validate a model and return findings. | +| `POST /v1/model/analyze` | Model | Analyze a model and return findings. | | `POST /v1/model/read` | Model | Parse uploaded bytes (base64) into the canonical model. | | `POST /v1/model/convert?to=` | Model | Convert a model to another format (`tm7`, `drawio`, `vsdx`, `tmforge-json`). | | `POST /v1/model/export/tm7` | Model | Export a model as a `.tm7` file. | diff --git a/src/ThreatModelForge.Api/openapi/v1.json b/src/ThreatModelForge.Api/openapi/v1.json index ca104f2..59a1d7c 100644 --- a/src/ThreatModelForge.Api/openapi/v1.json +++ b/src/ThreatModelForge.Api/openapi/v1.json @@ -163,12 +163,12 @@ } } }, - "/v1/model/validate": { + "/v1/model/analyze": { "post": { "tags": [ "Model" ], - "operationId": "ValidateModel", + "operationId": "AnalyzeModel", "requestBody": { "content": { "application/json": { @@ -196,6 +196,39 @@ } } }, + "/v1/model/threats": { + "post": { + "tags": [ + "Model" + ], + "operationId": "GenerateThreats", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TmForgeModelDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ThreatDto" + } + } + } + } + } + } + } + }, "/v1/model/merge": { "post": { "tags": [ @@ -711,6 +744,102 @@ }, "description": "Describes an authoring stencil: a named, categorized specialization of one of the four DFD\nprimitives (`process`, `datastore`, `external`, `boundary`). A stencil\nmaps to its string StencilDto.Base primitive for analysis, and its identity plus preset\nproperties enrich the model without requiring per-stencil rules." }, + "ThreatDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ruleId": { + "type": "string" + }, + "category": { + "type": "string" + }, + "title": { + "type": "string" + }, + "mitigation": { + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": "string" + }, + "priority": { + "type": [ + "null", + "string" + ] + }, + "references": { + "type": "array", + "items": { + "type": "string" + } + }, + "elementIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "interaction": { + "type": "string" + }, + "state": { + "type": "string" + }, + "justification": { + "type": [ + "null", + "string" + ] + } + } + }, + "ThreatStateDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "state": { + "type": "string" + }, + "justification": { + "type": [ + "null", + "string" + ] + } + } + }, + "TmForgeAnalysisDto": { + "type": "object", + "properties": { + "disabledPacks": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + }, + "disabledRuleIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + } + } + }, "TmForgeDiagramDto": { "type": "object", "properties": { @@ -865,37 +994,23 @@ "$ref": "#/components/schemas/TmForgeDiagramDto" } }, - "validation": { + "analysis": { "oneOf": [ { "type": "null" }, { - "$ref": "#/components/schemas/TmForgeValidationDto" + "$ref": "#/components/schemas/TmForgeAnalysisDto" } ] - } - } - }, - "TmForgeValidationDto": { - "type": "object", - "properties": { - "disabledPacks": { - "type": [ - "null", - "array" - ], - "items": { - "type": "string" - } }, - "disabledRuleIds": { + "threats": { "type": [ "null", "array" ], "items": { - "type": "string" + "$ref": "#/components/schemas/ThreatStateDto" } } } diff --git a/src/ThreatModelForge.Cli/AcceptCommand.cs b/src/ThreatModelForge.Cli/AcceptCommand.cs new file mode 100644 index 0000000..7ec8746 --- /dev/null +++ b/src/ThreatModelForge.Cli/AcceptCommand.cs @@ -0,0 +1,108 @@ +namespace ThreatModelForge.Cli +{ + using System; + using System.IO; + using ThreatModelForge.Analysis; + using ThreatModelForge.Formats; + using ThreatModelForge.Model; + + /// + /// Implements tmforge accept: records inline risk acceptance for a generated threat. + /// Acceptance is a threat state — the threat is moved to NotApplicable with a justification + /// and stays visible in the register and report, no longer counted as open. Because it is scoped to + /// one threat (a single pattern on a single interaction), it can never silently swallow an + /// unrelated finding. + /// + internal static class AcceptCommand + { + /// + /// Runs the accept command. + /// + /// The command arguments (after the verb). + /// Zero on success; a non-zero value on error. + public static int Run(string[] args) + { + if (args == null || args.Length == 0) + { + PrintUsage(); + return 1; + } + + CliArgs parsed = CliArgs.Parse(args, new[] { "threat", "reason" }, Array.Empty()); + if (parsed.Help) + { + PrintUsage(); + return 0; + } + + if (parsed.UnknownFlags.Count > 0) + { + Console.Error.WriteLine("Unknown option: " + parsed.UnknownFlags[0]); + PrintUsage(); + return 1; + } + + string? input = parsed.Positionals.Count > 0 ? parsed.Positionals[0] : null; + string? threatId = parsed.Get("threat"); + string? reason = parsed.Get("reason"); + if (string.IsNullOrEmpty(input) || string.IsNullOrEmpty(threatId)) + { + PrintUsage(); + return 1; + } + + if (string.IsNullOrWhiteSpace(reason)) + { + Console.Error.WriteLine("--reason is required: record why the risk is accepted."); + PrintUsage(); + return 1; + } + + if (!File.Exists(input)) + { + Console.Error.WriteLine("File not found: " + input); + return 1; + } + + (ThreatModel model, IThreatModelFormat? format) = CliModelLoader.Load(input!); + if (format == null || !format.Capabilities.CanWrite) + { + Console.Error.WriteLine("The model's format does not support writing."); + return 1; + } + + if (!ThreatGenerator.Accept(model, threatId!, reason!)) + { + Console.Error.WriteLine("Threat not found: " + threatId + ". Persist threats first with 'tmforge threats --write', then use 'tmforge list threats' to find its id."); + return 1; + } + + AuthoringSupport.Save(model, input!, format); + + if (parsed.Json) + { + CliJson.WriteEnvelope("accept", new + { + threat = threatId, + state = "NotApplicable", + reason, + }); + } + else + { + Console.Error.WriteLine("Accepted " + threatId + " in " + input + ": " + reason); + } + + return 0; + } + + private static void PrintUsage() + { + Console.Error.WriteLine("Accept the risk of a generated threat (marks it not-applicable with a justification)."); + Console.Error.WriteLine("Usage:"); + Console.Error.WriteLine(" tmforge accept --threat --reason [--json] "); + Console.Error.WriteLine(); + Console.Error.WriteLine("--threat accepts the threat's register key, interaction key, or numeric id (from 'tmforge list threats')."); + } + } +} diff --git a/src/ThreatModelForge.Cli/LintArguments.cs b/src/ThreatModelForge.Cli/AnalyzeArguments.cs similarity index 94% rename from src/ThreatModelForge.Cli/LintArguments.cs rename to src/ThreatModelForge.Cli/AnalyzeArguments.cs index 4056e5e..78d5ca7 100644 --- a/src/ThreatModelForge.Cli/LintArguments.cs +++ b/src/ThreatModelForge.Cli/AnalyzeArguments.cs @@ -8,7 +8,7 @@ namespace ThreatModelForge.Cli /// /// Program arguments. /// - internal class LintArguments + internal class AnalyzeArguments { private static readonly string[] ValueOptionNames = new[] { @@ -18,7 +18,7 @@ internal class LintArguments "max-severity", }; - private LintArguments( + private AnalyzeArguments( string path, string ruleSetPath, string suppressionFilePath, @@ -72,7 +72,7 @@ private LintArguments( public bool Json { get; } /// - /// Gets the severity at or above which findings cause a non-zero lint exit code + /// Gets the severity at or above which findings cause a non-zero analyze exit code /// ( is the most severe). Defaults to /// , so warnings and info do not gate a build unless /// --max-severity lowers the bar. @@ -86,12 +86,12 @@ private LintArguments( StringComparer.OrdinalIgnoreCase); /// - /// Tries to parse the arguments into an instance of the class. + /// Tries to parse the arguments into an instance of the class. /// /// The raw program arguments. - /// on success, receives an instance to a new instance of the class. + /// on success, receives an instance to a new instance of the class. /// True if the arguments were parsed succesfully; otherwise, false. - public static bool TryParse(string[] args, out LintArguments? result) + public static bool TryParse(string[] args, out AnalyzeArguments? result) { result = null; if (args.Length < 1) @@ -152,7 +152,7 @@ public static bool TryParse(string[] args, out LintArguments? result) } } - result = new LintArguments( + result = new AnalyzeArguments( path, parsed.Get("ruleset") ?? string.Empty, parsed.Get("suppressionFile") ?? string.Empty, diff --git a/src/ThreatModelForge.Cli/LintCommand.cs b/src/ThreatModelForge.Cli/AnalyzeCommand.cs similarity index 94% rename from src/ThreatModelForge.Cli/LintCommand.cs rename to src/ThreatModelForge.Cli/AnalyzeCommand.cs index f3700e2..61e0f09 100644 --- a/src/ThreatModelForge.Cli/LintCommand.cs +++ b/src/ThreatModelForge.Cli/AnalyzeCommand.cs @@ -12,9 +12,9 @@ using ThreatModelForge.Model; /// - /// Implements the tmforge lint command. + /// Implements the tmforge analyze command. /// - internal static class LintCommand + internal static class AnalyzeCommand { private const int SuccessExitCode = 0; @@ -23,7 +23,7 @@ internal static class LintCommand private const int FindingsExitCode = 2; /// - /// Runs the lint command. + /// Runs the analyze command. /// /// The command arguments (after the verb). /// Program exit code. @@ -36,7 +36,7 @@ public static int Run(string[] args) throw new ArgumentNullException(nameof(args)); } - if (!LintArguments.TryParse(args, out LintArguments? arguments)) + if (!AnalyzeArguments.TryParse(args, out AnalyzeArguments? arguments)) { PrintUsage(); return ErrorExitCode; @@ -80,7 +80,7 @@ public static int Run(string[] args) if (arguments.Json) { - CliJson.WriteEnvelope("lint", context.GenerateReport(ruleSet)); + CliJson.WriteEnvelope("analyze", context.GenerateReport(ruleSet)); } if (writer.MeetsThreshold(arguments.MaxSeverity)) @@ -107,7 +107,7 @@ private static void PrintUsage() private static void ApplyModelRuleSelection(RuleSet ruleSet, string path) { - // The validation selection travels only in the native tmforge-json format; other formats + // The analysis selection travels only in the native tmforge-json format; other formats // (for example .tm7) fall back to the full rule set or an explicit --ruleset override. using (FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read)) { @@ -116,7 +116,7 @@ private static void ApplyModelRuleSelection(RuleSet ruleSet, string path) return; } - if (TmForgeJsonFormat.TryReadValidation( + if (TmForgeJsonFormat.TryReadAnalysis( stream, out IReadOnlyList disabledPacks, out IReadOnlyList disabledRuleIds)) diff --git a/src/ThreatModelForge.Cli/AuthoringSupport.cs b/src/ThreatModelForge.Cli/AuthoringSupport.cs index 18f1f04..3512b2e 100644 --- a/src/ThreatModelForge.Cli/AuthoringSupport.cs +++ b/src/ThreatModelForge.Cli/AuthoringSupport.cs @@ -7,6 +7,7 @@ namespace ThreatModelForge.Cli using System.Linq; using System.Security.Cryptography; using System.Text; + using ThreatModelForge.Analysis; using ThreatModelForge.Editing; using ThreatModelForge.Formats; using ThreatModelForge.Model; @@ -501,6 +502,11 @@ public static bool TryApplyProperties( /// The format provider to write with. public static void Save(ThreatModel model, string path, IThreatModelFormat format) { + if (format is Tm7Format) + { + Tm7ExportPreparer.Prepare(model); + } + string fullPath = Path.GetFullPath(path); string directory = Path.GetDirectoryName(fullPath) ?? "."; string temp = Path.Combine(directory, "." + Path.GetFileName(fullPath) + "." + Guid.NewGuid().ToString("N") + ".tmp"); diff --git a/src/ThreatModelForge.Cli/CommandCatalog.cs b/src/ThreatModelForge.Cli/CommandCatalog.cs index f75b6e0..a7ff922 100644 --- a/src/ThreatModelForge.Cli/CommandCatalog.cs +++ b/src/ThreatModelForge.Cli/CommandCatalog.cs @@ -19,7 +19,7 @@ internal static class CommandCatalog new CommandInfo("diff", "Show a structural diff between two models (added/removed/modified).", "summary, added[], removed[], modified[]", DiffCommand.Run), new CommandInfo("merge", "Three-way merge two models against a common ancestor (git driver).", "status, summary, conflicts[]", MergeCommand.Run), new CommandInfo("stencils", "List the built-in authoring stencils (ids for 'add --stencil').", "packs[], stencils[]", StencilsCommand.Run), - new CommandInfo("properties", "List the typed property schema (custom properties the linter reads).", "bases[], properties[]; with --explain: bases[], explain[]{appliesTo,property,value,rule,severity}", PropertiesCommand.Run), + new CommandInfo("properties", "List the typed property schema (custom properties the analyzer reads).", "bases[], properties[]; with --explain: bases[], explain[]{appliesTo,property,value,rule,severity}", PropertiesCommand.Run), new CommandInfo("schema", "Describe the --json envelope and per-command output shapes.", "envelope{schemaVersion,fields,description}, commands[]{command,data}", SchemaCommand.Run), new CommandInfo("render", "Render the diagram in the terminal (Unicode/ANSI; --plain for ASCII).", null, RenderCommand.Run), new CommandInfo("new", "Create a new threat model.", "path, format, name", NewCommand.Run), @@ -30,7 +30,9 @@ internal static class CommandCatalog 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("lint", "Validate a threat model against a rule set.", "a SARIF-style model report (runs[].results[]); see docs/cli-reference.md", LintCommand.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("threats", "Report threats: the persisted, triaged view of the analysis findings (--write to persist).", "summary{count,written}, threats[]{id,ruleId,category,title,mitigation,severity,references[],scope,interaction}", ThreatsCommand.Run), + new CommandInfo("accept", "Accept a generated threat's risk (marks it not-applicable with a reason).", "threat, state, reason", AcceptCommand.Run), new CommandInfo("report", "Generate an HTML report from a threat model.", "output, format, bytes", ReportCommand.Run), new CommandInfo("convert", "Convert a threat model between file formats.", "input, output, format", ConvertCommand.Run), new CommandInfo("apply", "Build a model from a declarative JSON manifest (all-or-nothing).", "output, format, dryRun, boundaries, elements, flows", ApplyCommand.Run), diff --git a/src/ThreatModelForge.Cli/ConnectCommand.cs b/src/ThreatModelForge.Cli/ConnectCommand.cs index 8acb5f2..a6131fe 100644 --- a/src/ThreatModelForge.Cli/ConnectCommand.cs +++ b/src/ThreatModelForge.Cli/ConnectCommand.cs @@ -164,7 +164,7 @@ private static void PrintUsage() Console.Error.WriteLine(); Console.Error.WriteLine("Both endpoints must be on the same page; --page selects it (default: the first page)."); Console.Error.WriteLine("--source and --target accept a GUID, an element --alias, or a unique element name."); - Console.Error.WriteLine("Set flow properties the linter checks, e.g. --property Protocol=HTTPS --property Port=443 --property DataType=\"Customer Content\"."); + Console.Error.WriteLine("Set flow properties the analyzer checks, e.g. --property Protocol=HTTPS --property Port=443 --property DataType=\"Customer Content\"."); Console.Error.WriteLine("Mark a non-network flow to skip protocol/port/cleartext checks: --property Channel=In-Process|Local-file|Unix-socket|Loopback."); Console.Error.WriteLine("List every property and its allowed values with 'tmforge properties --base flow'."); Console.Error.WriteLine("Values are validated against the schema; pass --force to store an unknown property or value."); diff --git a/src/ThreatModelForge.Cli/ConsoleMessageWriter.cs b/src/ThreatModelForge.Cli/ConsoleMessageWriter.cs index c765ac4..2564c2d 100644 --- a/src/ThreatModelForge.Cli/ConsoleMessageWriter.cs +++ b/src/ThreatModelForge.Cli/ConsoleMessageWriter.cs @@ -64,7 +64,7 @@ public override void WriteCore( /// /// Returns whether any recorded finding is at or above the given severity threshold, where - /// is the most severe. Used to gate the lint exit code. + /// is the most severe. Used to gate the analyze exit code. /// /// The minimum severity that should gate the exit code. /// when a finding met or exceeded the threshold. diff --git a/src/ThreatModelForge.Cli/ConvertCommand.cs b/src/ThreatModelForge.Cli/ConvertCommand.cs index 455faf7..bd364df 100644 --- a/src/ThreatModelForge.Cli/ConvertCommand.cs +++ b/src/ThreatModelForge.Cli/ConvertCommand.cs @@ -2,7 +2,9 @@ namespace ThreatModelForge.Cli { using System; using System.IO; + using ThreatModelForge.Analysis; using ThreatModelForge.Formats; + using ThreatModelForge.KnowledgeBase; using ThreatModelForge.Model; /// @@ -26,7 +28,7 @@ public static int Run(string[] args) return 1; } - CliArgs parsed = CliArgs.Parse(args, new[] { "to", "out" }); + CliArgs parsed = CliArgs.Parse(args, new[] { "to", "out", "knowledge-base" }); if (parsed.Help) { PrintUsage(); @@ -77,6 +79,28 @@ public static int Run(string[] args) ThreatModel model = registry.Load(input!); + string? knowledgeBasePath = parsed.Get("knowledge-base"); + if (!string.IsNullOrEmpty(knowledgeBasePath)) + { + if (target is not Tm7Format) + { + Console.Error.WriteLine("--knowledge-base can only be embedded when the target format is tm7."); + return 1; + } + + if (!File.Exists(knowledgeBasePath)) + { + Console.Error.WriteLine("Knowledge base not found: " + knowledgeBasePath); + return 1; + } + + model.KnowledgeBase = KnowledgeBaseData.Load(knowledgeBasePath!); + } + else if (target is Tm7Format) + { + Tm7ExportPreparer.Prepare(model); + } + string outputPath = !string.IsNullOrEmpty(output) ? output! : Path.ChangeExtension(input!, target.Extensions.Count > 0 ? target.Extensions[0] : ".out"); @@ -102,11 +126,15 @@ private static void PrintUsage() { Console.Error.WriteLine("Threat Model Forge format converter."); Console.Error.WriteLine("Usage:"); - Console.Error.WriteLine(" tmforge convert [--to ] [--out ] [--json] "); + Console.Error.WriteLine(" tmforge convert [--to ] [--out ] [--knowledge-base ] [--json] "); Console.Error.WriteLine(); Console.Error.WriteLine("The target format is taken from --to, or inferred from the --out extension."); Console.Error.WriteLine("If --out is omitted, the input name is reused with the target extension."); Console.Error.WriteLine("Formats: tm7, tmforge-json, drawio, vsdx."); + Console.Error.WriteLine(); + Console.Error.WriteLine("A tm7 export embeds the Threat Model Forge knowledge base by default so the file opens"); + Console.Error.WriteLine("in the Microsoft Threat Modeling Tool; use --knowledge-base to embed a different"); + Console.Error.WriteLine("one. A knowledge base already present in the source model is preserved."); } } } diff --git a/src/ThreatModelForge.Cli/ManifestElement.cs b/src/ThreatModelForge.Cli/ManifestElement.cs index f0273f2..24b3d7b 100644 --- a/src/ThreatModelForge.Cli/ManifestElement.cs +++ b/src/ThreatModelForge.Cli/ManifestElement.cs @@ -20,7 +20,7 @@ internal sealed class ManifestElement /// Gets or sets the alias of the trust boundary this element belongs to. public string? Boundary { get; set; } - /// Gets or sets the typed custom properties the linter reads. + /// Gets or sets the typed custom properties the analyzer reads. public Dictionary? Props { get; set; } } } diff --git a/src/ThreatModelForge.Cli/ManifestFlow.cs b/src/ThreatModelForge.Cli/ManifestFlow.cs index 9c3f065..4443fcd 100644 --- a/src/ThreatModelForge.Cli/ManifestFlow.cs +++ b/src/ThreatModelForge.Cli/ManifestFlow.cs @@ -14,7 +14,7 @@ internal sealed class ManifestFlow /// Gets or sets the flow's display name. public string? Name { get; set; } - /// Gets or sets the typed custom properties the linter reads. + /// Gets or sets the typed custom properties the analyzer reads. public Dictionary? Props { get; set; } } } diff --git a/src/ThreatModelForge.Cli/Properties/Resources.Designer.cs b/src/ThreatModelForge.Cli/Properties/Resources.Designer.cs index ff19c77..72540db 100644 --- a/src/ThreatModelForge.Cli/Properties/Resources.Designer.cs +++ b/src/ThreatModelForge.Cli/Properties/Resources.Designer.cs @@ -61,7 +61,7 @@ internal Resources() { } /// - /// Looks up a localized string similar to Threat Model Forge linter. + /// Looks up a localized string similar to Threat Model Forge analyzer. /// internal static string ToolFullName { get { @@ -97,9 +97,9 @@ internal static string ToolOrganization { } /// - /// Looks up a localized string similar to Threat Model Forge linter. + /// Looks up a localized string similar to Threat Model Forge analyzer. ///Usage: - ///tmforge lint [options] <path> + ///tmforge analyze [options] <path> ///Options: /// -?, -h, --help Show usage. /// --json Emit machine-readable JSON to standard output. diff --git a/src/ThreatModelForge.Cli/Properties/Resources.resx b/src/ThreatModelForge.Cli/Properties/Resources.resx index db8c096..3c90020 100644 --- a/src/ThreatModelForge.Cli/Properties/Resources.resx +++ b/src/ThreatModelForge.Cli/Properties/Resources.resx @@ -118,7 +118,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Threat Model Forge linter + Threat Model Forge analyzer https://example.com/tmforge @@ -130,9 +130,9 @@ Threat Model Forge - Threat Model Forge linter. + Threat Model Forge analyzer. Usage: -tmforge lint [options] <path> +tmforge analyze [options] <path> Options: -?, -h, --help Show usage. --json Emit machine-readable JSON to standard output. diff --git a/src/ThreatModelForge.Cli/PropertiesCommand.cs b/src/ThreatModelForge.Cli/PropertiesCommand.cs index 342ad82..0e02f86 100644 --- a/src/ThreatModelForge.Cli/PropertiesCommand.cs +++ b/src/ThreatModelForge.Cli/PropertiesCommand.cs @@ -10,7 +10,7 @@ namespace ThreatModelForge.Cli /// Implements tmforge properties: lists the built-in typed property schema (the same /// schema Studio renders and the API serves at GET /v1/property-schema) so an agent can /// discover every custom property, its value kind, allowed values, and default without running - /// lint first or reading the rule catalog. Optionally filtered to one DFD base by + /// analyze first or reading the rule catalog. Optionally filtered to one DFD base by /// --base. /// internal static class PropertiesCommand @@ -189,12 +189,12 @@ private static string FormatValues(PropertyDescriptor descriptor, PropertyPolicy private static void PrintUsage() { - Console.Error.WriteLine("List the built-in typed property schema (custom properties the linter reads and Studio edits)."); + Console.Error.WriteLine("List the built-in typed property schema (custom properties the analyzer reads and Studio edits)."); Console.Error.WriteLine("Usage:"); Console.Error.WriteLine(" tmforge properties [--base ] [--explain] [--json]"); Console.Error.WriteLine(); Console.Error.WriteLine("--explain maps each property VALUE to the rule ID and severity it triggers, so you can"); - Console.Error.WriteLine("predict lint behavior before running 'tmforge lint'."); + Console.Error.WriteLine("predict analyze behavior before running 'tmforge analyze'."); } } } diff --git a/src/ThreatModelForge.Cli/README.md b/src/ThreatModelForge.Cli/README.md index b404fb1..136b062 100644 --- a/src/ThreatModelForge.Cli/README.md +++ b/src/ThreatModelForge.Cli/README.md @@ -1,6 +1,6 @@ # ThreatModelForge.Cli (`tmforge`) -The `tmforge` command-line tool: inspect, author, validate, report on, and convert +The `tmforge` command-line tool: inspect, author, analyze, report on, and convert `.tm7`-compatible threat models from a shell or CI pipeline. It is the headless, scriptable face of the same engine the Studio UI uses, so agents and pipelines can drive threat models without a GUI. @@ -37,29 +37,29 @@ Every command accepts `--json` for machine-readable output, and options take eit | `tmforge remove --id [--json] ` | Remove an element (and its connected flows). | | `tmforge rename --id --name [--json] ` | Rename an element. | -### Validate, report & convert +### Analyze, report & convert | Command | Purpose | | --- | --- | -| `tmforge lint [--ruleset ] [--suppressionFile ] [--reportFolder ] [--define name=value ...] [--json] ` | Evaluate a rule set against the model. `--reportFolder` also emits SARIF + HTML findings reports. | +| `tmforge analyze [--ruleset ] [--suppressionFile ] [--reportFolder ] [--define name=value ...] [--json] ` | Evaluate the analysis rules against the model. `--reportFolder` also emits SARIF + HTML findings reports. | | `tmforge report [--out ] [--json] ` | Generate a self-contained HTML report of the model. | | `tmforge convert [--to ] [--out ] [--json] ` | Convert between formats (`tm7`, `tmforge-json`, `drawio`, `vsdx`). | -`lint` exit codes: `0` = clean, `1` = error (bad arguments or load failure), `2` = findings +`analyze` exit codes: `0` = clean, `1` = error (bad arguments or load failure), `2` = findings reported. This lets CI fail a build on findings while distinguishing them from tool errors. ## Run ```bash # From source -dotnet run --project src/ThreatModelForge.Cli -- lint model.tm7 --json +dotnet run --project src/ThreatModelForge.Cli -- analyze model.tm7 --json # From the published container image (pulls on first run) -docker run --rm -v "$PWD:/work" ghcr.io/hacks4snacks/tmforge-cli tmforge lint model.tm7 +docker run --rm -v "$PWD:/work" ghcr.io/hacks4snacks/tmforge-cli tmforge analyze model.tm7 # ...or build the image from source (see build/Dockerfile) docker build -f build/Dockerfile -t tmforge-cli . -docker run --rm -v "$PWD:/work" tmforge-cli tmforge lint model.tm7 +docker run --rm -v "$PWD:/work" tmforge-cli tmforge analyze model.tm7 ``` ## Examples diff --git a/src/ThreatModelForge.Cli/SetCommand.cs b/src/ThreatModelForge.Cli/SetCommand.cs index 0673fdc..2196f9d 100644 --- a/src/ThreatModelForge.Cli/SetCommand.cs +++ b/src/ThreatModelForge.Cli/SetCommand.cs @@ -10,7 +10,7 @@ namespace ThreatModelForge.Cli /// /// Implements tmforge set: sets the name and/or custom properties of an existing element - /// or flow (identified by GUID), so an agent can resolve linter findings (for example + /// or flow (identified by GUID), so an agent can resolve analysis findings (for example /// Protocol, Port, DataType, AuthenticationScheme) without /// hand-editing the .tm7. /// @@ -157,7 +157,7 @@ private static void PrintUsage() Console.Error.WriteLine(); Console.Error.WriteLine("--id accepts a GUID, an element --alias, or a unique element name."); Console.Error.WriteLine(); - Console.Error.WriteLine("Resolve linter findings, e.g. --property Protocol=HTTPS --property Port=443,"); + Console.Error.WriteLine("Resolve analysis findings, e.g. --property Protocol=HTTPS --property Port=443,"); Console.Error.WriteLine("--property DataType=\"Customer Content\", or --property AuthenticationScheme=OAuth."); Console.Error.WriteLine("Mark a non-network flow with --property Channel=In-Process|Local-file|Unix-socket|Loopback."); Console.Error.WriteLine("List every property and its allowed values with 'tmforge properties'."); diff --git a/src/ThreatModelForge.Cli/ShowCommand.cs b/src/ThreatModelForge.Cli/ShowCommand.cs index 5968cef..3af4345 100644 --- a/src/ThreatModelForge.Cli/ShowCommand.cs +++ b/src/ThreatModelForge.Cli/ShowCommand.cs @@ -10,7 +10,7 @@ namespace ThreatModelForge.Cli /// /// Implements tmforge show: a read-only view of an element or flow's name, type, stencil, /// and custom properties. This is the verify counterpart of tmforge set — an agent can - /// confirm what it wrote without re-running lint. + /// confirm what it wrote without re-running analyze. /// internal static class ShowCommand { diff --git a/src/ThreatModelForge.Cli/ThreatsCommand.cs b/src/ThreatModelForge.Cli/ThreatsCommand.cs new file mode 100644 index 0000000..4d4036e --- /dev/null +++ b/src/ThreatModelForge.Cli/ThreatsCommand.cs @@ -0,0 +1,191 @@ +namespace ThreatModelForge.Cli +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using ThreatModelForge.Analysis; + using ThreatModelForge.Formats; + using ThreatModelForge.Model; + + /// + /// Implements tmforge threats: the persistable, lifecycle-bearing view of the validation + /// findings. It runs the same rules as analyze, frames each threat-bearing finding as a STRIDE + /// threat against its element or flow, and — with --write — persists them into the model's + /// register, preserving any prior triage. Detection is entirely the rules; extend coverage by + /// adding a rule to a rule pack (there is no separate threat catalog). + /// + internal static class ThreatsCommand + { + /// + /// Runs the threats command. + /// + /// The command arguments (after the verb). + /// Zero on success; a non-zero value on error. + public static int Run(string[] args) + { + if (args == null || args.Length == 0) + { + PrintUsage(); + return 1; + } + + CliArgs parsed = CliArgs.Parse(args, Array.Empty(), new[] { "write" }); + if (parsed.Help) + { + PrintUsage(); + return 0; + } + + if (parsed.UnknownFlags.Count > 0) + { + Console.Error.WriteLine("Unknown option: " + parsed.UnknownFlags[0]); + PrintUsage(); + return 1; + } + + string? input = parsed.Positionals.Count > 0 ? parsed.Positionals[0] : null; + if (string.IsNullOrEmpty(input)) + { + PrintUsage(); + return 1; + } + + if (!File.Exists(input)) + { + Console.Error.WriteLine("File not found: " + input); + return 1; + } + + (ThreatModel model, IThreatModelFormat? format) = CliModelLoader.Load(input!); + GenerationResult result = ThreatGenerator.Generate(model); + + ApplyResult? written = null; + if (parsed.HasFlag("write")) + { + if (format == null || !format.Capabilities.CanWrite) + { + Console.Error.WriteLine("The model's format does not support writing; omit --write to preview."); + return 1; + } + + written = ThreatGenerator.Apply(model, result); + AuthoringSupport.Save(model, input!, format); + } + + if (parsed.Json) + { + WriteJson(result, written); + } + else + { + WriteHuman(result, written, input!); + } + + return 0; + } + + private static void WriteJson(GenerationResult result, ApplyResult? written) + { + object data = new + { + summary = new + { + count = result.Count, + written = written == null + ? null + : new { added = written.Added, updated = written.Updated, preserved = written.Preserved }, + }, + threats = result.Threats.Select(t => new + { + id = t.Id, + ruleId = t.RuleId, + category = t.Category.ToString(), + title = t.Title, + mitigation = t.Mitigation, + severity = t.Severity, + priority = t.Priority, + references = t.References.Select(r => new { catalog = r.Catalog, id = r.Id, url = r.Url }).ToList(), + scope = t.IsFlowScoped ? "flow" : "element", + source = t.SourceName, + target = t.TargetName, + flow = t.FlowName, + sourceId = t.SourceGuid, + targetId = t.TargetGuid, + flowId = t.FlowGuid, + diagramId = t.DiagramGuid, + interaction = t.InteractionString, + }).ToList(), + }; + CliJson.WriteEnvelope("threats", data); + } + + private static void WriteHuman(GenerationResult result, ApplyResult? written, string input) + { + Console.WriteLine("Threats: " + result.Count + " (from validation findings)"); + if (written != null) + { + Console.WriteLine("Wrote: " + written.Added + " added, " + written.Updated + " updated, " + written.Preserved + " preserved (triaged) in " + input); + } + + if (result.Count == 0) + { + Console.WriteLine(); + Console.WriteLine("No threats detected. Run 'tmforge analyze' for the same findings without persisting them."); + return; + } + + Console.WriteLine(); + foreach (GeneratedThreat threat in result.Threats) + { + PrintThreat(threat); + } + } + + private static void PrintThreat(GeneratedThreat threat) + { + string flow = threat.IsFlowScoped && !string.IsNullOrWhiteSpace(threat.FlowName) + ? " [" + threat.FlowName + "]" + : string.Empty; + Console.WriteLine( + " [" + CategoryLetter(threat.Category) + "] " + threat.RuleId + " " + + threat.InteractionString + flow + " — " + threat.Title); + if (!string.IsNullOrWhiteSpace(threat.Mitigation)) + { + Console.WriteLine(" fix: " + threat.Mitigation); + } + + if (threat.References.Count > 0) + { + Console.WriteLine(" refs: " + string.Join(", ", threat.References.Select(r => r.Id))); + } + } + + private static string CategoryLetter(StrideCategory category) + { + return category switch + { + StrideCategory.Spoofing => "S", + StrideCategory.Tampering => "T", + StrideCategory.Repudiation => "R", + StrideCategory.InformationDisclosure => "I", + StrideCategory.DenialOfService => "D", + StrideCategory.ElevationOfPrivilege => "E", + _ => "?", + }; + } + + private static void PrintUsage() + { + Console.Error.WriteLine("Report the model's threats — the persistable, triaged view of the validation findings."); + Console.Error.WriteLine("Usage:"); + Console.Error.WriteLine(" tmforge threats [--write] [--json] "); + Console.Error.WriteLine(); + Console.Error.WriteLine("--write persist the threats into the model's register (preserves prior triage)."); + Console.Error.WriteLine(); + Console.Error.WriteLine("Detection is the rule set (see 'tmforge analyze'); each threat carries a STRIDE category,"); + Console.Error.WriteLine("the rule's mitigation, and CWE/CAPEC references. After --write, triage with"); + Console.Error.WriteLine("'tmforge list threats' and 'tmforge accept'."); + } + } +} diff --git a/src/ThreatModelForge.Core/KnowledgeBase/GenerationFilters.cs b/src/ThreatModelForge.Core/KnowledgeBase/GenerationFilters.cs new file mode 100644 index 0000000..37600a2 --- /dev/null +++ b/src/ThreatModelForge.Core/KnowledgeBase/GenerationFilters.cs @@ -0,0 +1,24 @@ +namespace ThreatModelForge.KnowledgeBase +{ + using System.Runtime.Serialization; + + /// + /// The generation-filter expressions for a : the Include and + /// Exclude target expressions that decide which interactions the threat is generated for. + /// Persisted in both the .tb7 knowledge base and the copy embedded in a .tm7 model; + /// the Microsoft Threat Modeling Tool requires this element to be present on every threat type. + /// + [DataContract( + Name = "GenerationFilters", + Namespace = "http://schemas.datacontract.org/2004/07/ThreatModeling.KnowledgeBase")] + public class GenerationFilters + { + /// Gets or sets the exclude expression. + [DataMember(Name = "Exclude")] + public string Exclude { get; set; } = string.Empty; + + /// Gets or sets the include expression. + [DataMember(Name = "Include")] + public string Include { get; set; } = string.Empty; + } +} diff --git a/src/ThreatModelForge.Core/KnowledgeBase/TemplateSerializer.cs b/src/ThreatModelForge.Core/KnowledgeBase/TemplateSerializer.cs index 0b3df9a..74986ec 100644 --- a/src/ThreatModelForge.Core/KnowledgeBase/TemplateSerializer.cs +++ b/src/ThreatModelForge.Core/KnowledgeBase/TemplateSerializer.cs @@ -288,8 +288,8 @@ private static ThreatType ReadThreatType(XElement source) XElement? filters = source.Element(Names.GenerationFilters); if (filters is not null) { - threatType.IncludeGenerationFilter = Text(filters, Names.Include); - threatType.ExcludeGenerationFilter = Text(filters, Names.Exclude); + threatType.GenerationFilters.Include = Text(filters, Names.Include) ?? string.Empty; + threatType.GenerationFilters.Exclude = Text(filters, Names.Exclude) ?? string.Empty; } ReadThreatMetaData(threatType.PropertiesMetaData, source.Element(Names.PropertiesMetaData)); @@ -459,8 +459,8 @@ private static XElement WriteThreatTypes(IEnumerable threatTypes) Names.ThreatType, new XElement( Names.GenerationFilters, - new XElement(Names.Include, threatType.IncludeGenerationFilter ?? string.Empty), - new XElement(Names.Exclude, threatType.ExcludeGenerationFilter ?? string.Empty)), + new XElement(Names.Include, threatType.GenerationFilters.Include), + new XElement(Names.Exclude, threatType.GenerationFilters.Exclude)), new XElement(Names.Id, threatType.Id ?? string.Empty), new XElement(Names.ShortTitle, threatType.ShortTitle ?? string.Empty), new XElement(Names.Category, threatType.Category ?? string.Empty), diff --git a/src/ThreatModelForge.Core/KnowledgeBase/ThreatType.cs b/src/ThreatModelForge.Core/KnowledgeBase/ThreatType.cs index 9fa1d9c..c4c9c96 100644 --- a/src/ThreatModelForge.Core/KnowledgeBase/ThreatType.cs +++ b/src/ThreatModelForge.Core/KnowledgeBase/ThreatType.cs @@ -5,8 +5,9 @@ namespace ThreatModelForge.KnowledgeBase using ThreatModelForge.Model; /// - /// A threat type defined by a knowledge base. Its generation-filter expressions are read from the - /// .tb7 but are not persisted into the .tm7 snapshot. + /// A threat type defined by a knowledge base, including the generation-filter expressions that + /// decide which interactions it applies to. Persisted in both the .tb7 knowledge base and + /// the copy embedded in a .tm7 model. /// [DataContract( Name = "ThreatType", @@ -14,6 +15,7 @@ namespace ThreatModelForge.KnowledgeBase public class ThreatType : Extendable { private List? propertiesMetaData; + private GenerationFilters? generationFilters; /// Gets or sets the identifier. [DataMember(Name = "Id")] @@ -39,12 +41,16 @@ public class ThreatType : Extendable [DataMember(Name = "PropertiesMetaData")] public List PropertiesMetaData => this.propertiesMetaData ??= new List(); - /// Gets or sets the include generation-filter expression (TB7 only). - [IgnoreDataMember] - public string? IncludeGenerationFilter { get; set; } - - /// Gets or sets the exclude generation-filter expression (TB7 only). - [IgnoreDataMember] - public string? ExcludeGenerationFilter { get; set; } + /// + /// Gets or sets the generation-filter expressions (include/exclude) for this threat type. The + /// Microsoft Threat Modeling Tool requires this element on every threat type in an embedded + /// knowledge base, so the getter never returns . + /// + [DataMember(Name = "GenerationFilters")] + public GenerationFilters GenerationFilters + { + get => this.generationFilters ??= new GenerationFilters(); + set => this.generationFilters = value; + } } } diff --git a/src/ThreatModelForge.Core/Model/Abstracts/LineElement.cs b/src/ThreatModelForge.Core/Model/Abstracts/LineElement.cs index e936751..9abe25c 100644 --- a/src/ThreatModelForge.Core/Model/Abstracts/LineElement.cs +++ b/src/ThreatModelForge.Core/Model/Abstracts/LineElement.cs @@ -10,21 +10,56 @@ namespace ThreatModelForge.Model.Abstracts [DataContract(Namespace = "http://schemas.datacontract.org/2004/07/ThreatModeling.Model.Abstracts")] public abstract class LineElement : Entity { - /// Gets or sets the x coordinate of the curve handle. + private int handleX; + private int handleY; + private string? portSource; + private string? portTarget; + + /// + /// Gets or sets the x coordinate of the curve handle. When unset it serializes as the midpoint + /// of the source and target so the line satisfies the Microsoft Threat Modeling Tool's + /// coordinate validation (which rejects a handle below its minimum drawing coordinate). + /// [DataMember] - public int HandleX { get; set; } + public int HandleX + { + get => this.handleX != 0 ? this.handleX : (this.SourceX + this.TargetX) / 2; + set => this.handleX = value; + } - /// Gets or sets the y coordinate of the curve handle. + /// + /// Gets or sets the y coordinate of the curve handle. When unset it serializes as the midpoint + /// of the source and target so the line satisfies the tool's coordinate validation. + /// [DataMember] - public int HandleY { get; set; } + public int HandleY + { + get => this.handleY != 0 ? this.handleY : (this.SourceY + this.TargetY) / 2; + set => this.handleY = value; + } - /// Gets or sets the name of the source port. + /// + /// Gets or sets the source connection port. It serializes as a StencilConnectionPort + /// name (None when unset) because the Microsoft Threat Modeling Tool types this member + /// as a non-nullable enum and cannot deserialize a nil value. + /// [DataMember] - public string? PortSource { get; set; } + public string PortSource + { + get => string.IsNullOrEmpty(this.portSource) ? "None" : this.portSource!; + set => this.portSource = value; + } - /// Gets or sets the name of the target port. + /// + /// Gets or sets the target connection port. It serializes as a StencilConnectionPort + /// name (None when unset) for the same reason as . + /// [DataMember] - public string? PortTarget { get; set; } + public string PortTarget + { + get => string.IsNullOrEmpty(this.portTarget) ? "None" : this.portTarget!; + set => this.portTarget = value; + } /// Gets or sets the identifier of the element the source end attaches to. [DataMember] diff --git a/src/ThreatModelForge.Core/Model/ThreatModel.cs b/src/ThreatModelForge.Core/Model/ThreatModel.cs index d0098fe..89e2b49 100644 --- a/src/ThreatModelForge.Core/Model/ThreatModel.cs +++ b/src/ThreatModelForge.Core/Model/ThreatModel.cs @@ -13,6 +13,12 @@ namespace ThreatModelForge.Model [DataContract(Namespace = "http://schemas.datacontract.org/2004/07/ThreatModeling.Model")] public class ThreatModel { + // The Microsoft Threat Modeling Tool stamps every .tm7 with a document-format version (not an + // application version). Current MTMT releases write "4.3", and the tool refuses to open a file + // whose version it does not recognize, so freshly authored models are normalized to this value. + private const string Tm7DocumentVersion = "4.3"; + private const string DrawingSurfaceTypeId = "DRAWINGSURFACE"; + private static readonly DataContractSerializer Serializer = CreateSerializer(); private DrawingSurfaceModelList? drawingSurfaceList; @@ -94,6 +100,7 @@ public void Save(string path) /// The destination stream. public void Save(Stream stream) { + this.EnsureToolScaffolding(); Serializer.WriteObject(stream, this); } @@ -113,5 +120,60 @@ private static DataContractSerializer CreateSerializer() return new DataContractSerializer(typeof(ThreatModel), knownTypes); } + + private static void EnsureSurfaceName(DrawingSurfaceModel surface) + { + // The Microsoft Threat Modeling Tool binds a surface's displayed name to a "Name" display + // property, not to the serialized Header field. A freshly authored surface only has the + // Header, so mirror it into a Name property (matching how elements are named) — otherwise + // the tool shows the generic fallback "Diagram" for the page. + foreach (object property in surface.Properties) + { + if (property is StringDisplayAttribute named && + string.Equals(named.DisplayName, "Name", StringComparison.Ordinal)) + { + return; + } + } + + surface.Properties.Add(new StringDisplayAttribute + { + Name = "Name", + DisplayName = "Name", + Value = surface.Header ?? string.Empty, + }); + } + + /// + /// Populates the document-level members that the Microsoft Threat Modeling Tool reads without + /// a null guard when it opens a .tm7 file. Values already present (for example, from a + /// loaded file) are preserved; only members a freshly authored model leaves unset are filled, + /// so this is a no-op on the second and subsequent saves. + /// + private void EnsureToolScaffolding() + { + MetaInformation meta = this.MetaInformation ??= new MetaInformation(); + meta.ThreatModelName ??= string.Empty; + meta.Owner ??= string.Empty; + meta.Contributors ??= string.Empty; + meta.Reviewer ??= string.Empty; + meta.HighLevelSystemDescription ??= string.Empty; + meta.Assumptions ??= string.Empty; + meta.ExternalDependencies ??= string.Empty; + + this.Profile ??= new ProfileData { PromptedKb = new KbVersions() }; + + if (string.IsNullOrEmpty(this.Version) || this.Version == "1.0") + { + this.Version = Tm7DocumentVersion; + } + + foreach (DrawingSurfaceModel surface in this.DrawingSurfaceList) + { + surface.TypeId ??= DrawingSurfaceTypeId; + surface.GenericTypeId ??= DrawingSurfaceTypeId; + EnsureSurfaceName(surface); + } + } } } diff --git a/src/ThreatModelForge.Editing/DiagramElementHelper.cs b/src/ThreatModelForge.Editing/DiagramElementHelper.cs index 7ddd43b..4ea5e96 100644 --- a/src/ThreatModelForge.Editing/DiagramElementHelper.cs +++ b/src/ThreatModelForge.Editing/DiagramElementHelper.cs @@ -13,6 +13,10 @@ public static class DiagramElementHelper { private const string NamePropertyName = "Name"; + // The Microsoft Threat Modeling Tool uses a leading "Select" entry as the unset sentinel for + // its list (drop-down) attributes; a selection pointing at it means the property has no value. + private const string UnsetListOption = "Select"; + /// /// Gets the display name of an element from its Name property. /// @@ -96,7 +100,12 @@ public static void SetCustomProperty(Entity element, string key, string value) } /// - /// Reads the custom properties (see ) declared on an element. + /// Reads an element's user-defined properties. This surfaces both the custom key:value + /// attributes written by and the typed list (drop-down) + /// properties that the Microsoft Threat Modeling Tool stores for schema-backed attributes, so a + /// model imported from a typed .tm7 exposes the same property names (for example, + /// Protocol) that the analysis rules read. A selection pointing at the unset sentinel is + /// treated as absent, and a custom value takes precedence over a typed one for the same key. /// /// The element. /// A map of property key to value. @@ -118,6 +127,26 @@ public static IReadOnlyDictionary GetCustomProperties(Entity ele } } + foreach (ListDisplayAttribute list in element.Properties.OfType()) + { + string key = list.DisplayName ?? string.Empty; + if (key.Length == 0 || + string.Equals(key, NamePropertyName, StringComparison.OrdinalIgnoreCase) || + result.ContainsKey(key)) + { + continue; + } + + if (list.Value is string[] options && list.SelectedIndex >= 0 && list.SelectedIndex < options.Length) + { + string selected = options[list.SelectedIndex] ?? string.Empty; + if (selected.Length > 0 && !string.Equals(selected, UnsetListOption, StringComparison.OrdinalIgnoreCase)) + { + result[key] = selected; + } + } + } + return result; } diff --git a/src/ThreatModelForge.Editing/ModelSnapshot.cs b/src/ThreatModelForge.Editing/ModelSnapshot.cs index 9e1bc5c..d531fc2 100644 --- a/src/ThreatModelForge.Editing/ModelSnapshot.cs +++ b/src/ThreatModelForge.Editing/ModelSnapshot.cs @@ -91,7 +91,10 @@ private static void Collect(IDictionary elements, DrawingSurfaceMo private static string Classify(Entity entity) { - string? typeId = string.IsNullOrEmpty(entity.TypeId) ? entity.GenericTypeId : entity.TypeId; + // Classify by the generic (primitive) type: an element placed from a stencil carries its + // specific subtype in TypeId but its DFD primitive in GenericTypeId, so the generic type is + // the reliable basis for the kind. + string? typeId = string.IsNullOrEmpty(entity.GenericTypeId) ? entity.TypeId : entity.GenericTypeId; switch (typeId) { case "GE.P": diff --git a/src/ThreatModelForge.Editing/SchemaBackedProperties.cs b/src/ThreatModelForge.Editing/SchemaBackedProperties.cs new file mode 100644 index 0000000..8d341cb --- /dev/null +++ b/src/ThreatModelForge.Editing/SchemaBackedProperties.cs @@ -0,0 +1,161 @@ +namespace ThreatModelForge.Editing +{ + using System; + using System.Collections.Generic; + using System.Linq; + using ThreatModelForge.KnowledgeBase; + using ThreatModelForge.Model; + using ThreatModelForge.Model.Abstracts; + + /// + /// Projects Threat Model Forge's property schema onto an exported .tm7 so the Microsoft + /// Threat Modeling Tool renders known element properties as first-class, typed properties rather + /// than free-form custom attributes. It declares each enumerated/boolean schema property as a + /// list attribute on the matching generic element type in the knowledge base, and rewrites the + /// corresponding values on elements into typed + /// selections. Free-text properties (which the tool's knowledge + /// base cannot express as a list), and any value that is not one of a property's known options, are + /// left as custom attributes so their values are preserved. + /// + public static class SchemaBackedProperties + { + private const string UnsetOption = "Select"; + + private static readonly IReadOnlyDictionary KindByGenericType = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["GE.P"] = "process", + ["GE.DS"] = "datastore", + ["GE.EI"] = "external", + ["GE.DF"] = "flow", + }; + + /// + /// Declares the schema's enumerated and boolean properties as list attributes on the knowledge + /// base's generic element types, and rewrites those properties on the model's elements into + /// typed list selections. + /// + /// The model whose element properties are typed. + /// The knowledge base whose element types gain the attributes. + public static void Apply(ThreatModel model, KnowledgeBaseData knowledgeBase) + { + if (model == null) + { + throw new ArgumentNullException(nameof(model)); + } + + if (knowledgeBase == null) + { + throw new ArgumentNullException(nameof(knowledgeBase)); + } + + DeclareAttributes(knowledgeBase); + TypeElementProperties(model); + } + + private static void DeclareAttributes(KnowledgeBaseData knowledgeBase) + { + foreach (ElementType elementType in knowledgeBase.GenericElements) + { + if (elementType.Id == null || !KindByGenericType.TryGetValue(elementType.Id, out string? kind)) + { + continue; + } + + foreach (PropertyDescriptor descriptor in PropertySchemaCatalog.For(kind)) + { + if (!IsListProperty(descriptor)) + { + continue; + } + + KnowledgeBaseAttribute attribute = new KnowledgeBaseAttribute + { + Name = descriptor.Name, + DisplayName = descriptor.Name, + Mode = AttributeMode.Dynamic, + Type = AttributeType.List, + Inheritance = AttributeInheritance.Virtual, + }; + + attribute.AttributeValues.Add(UnsetOption); + foreach (string value in descriptor.Values) + { + attribute.AttributeValues.Add(value); + } + + elementType.Attributes.Add(attribute); + } + } + } + + private static void TypeElementProperties(ThreatModel model) + { + foreach (DrawingSurfaceModel surface in model.DrawingSurfaceList) + { + foreach (object node in surface.Borders.Values) + { + TypeElement(node as Entity); + } + + foreach (object line in surface.Lines.Values) + { + TypeElement(line as Entity); + } + } + } + + private static void TypeElement(Entity? element) + { + if (element == null || element.GenericTypeId == null || + !KindByGenericType.TryGetValue(element.GenericTypeId, out string? kind)) + { + return; + } + + IReadOnlyList schema = PropertySchemaCatalog.For(kind); + foreach (CustomStringDisplayAttribute custom in element.Properties.OfType().ToList()) + { + string encoded = custom.Value as string ?? string.Empty; + int separator = encoded.IndexOf(':'); + if (separator <= 0) + { + continue; + } + + string key = encoded.Substring(0, separator); + string value = encoded.Substring(separator + 1); + PropertyDescriptor? descriptor = schema.FirstOrDefault( + d => string.Equals(d.Name, key, StringComparison.OrdinalIgnoreCase)); + if (descriptor == null || !IsListProperty(descriptor)) + { + continue; + } + + List options = new List { UnsetOption }; + options.AddRange(descriptor.Values); + int index = options.FindIndex(o => string.Equals(o, value, StringComparison.OrdinalIgnoreCase)); + if (index <= 0) + { + // The value is not one of the schema's options (or is the unset sentinel); leave it + // as a custom attribute so it is preserved rather than silently dropped. + continue; + } + + element.Properties.Remove(custom); + element.Properties.Add(new ListDisplayAttribute + { + Name = descriptor.Name, + DisplayName = descriptor.Name, + Value = options.ToArray(), + SelectedIndex = index, + }); + } + } + + private static bool IsListProperty(PropertyDescriptor descriptor) + { + return string.Equals(descriptor.Kind, "enum", StringComparison.OrdinalIgnoreCase) + || string.Equals(descriptor.Kind, "bool", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/ThreatModelForge.Editing/StencilSubtypeProjection.cs b/src/ThreatModelForge.Editing/StencilSubtypeProjection.cs new file mode 100644 index 0000000..e8c7388 --- /dev/null +++ b/src/ThreatModelForge.Editing/StencilSubtypeProjection.cs @@ -0,0 +1,84 @@ +namespace ThreatModelForge.Editing +{ + using System; + using System.Collections.Generic; + using ThreatModelForge.KnowledgeBase; + using ThreatModelForge.Model; + using ThreatModelForge.Model.Abstracts; + + /// + /// Retypes each drawn element that was placed from a Threat Model Forge stencil to the matching + /// standard element type in the exported knowledge base, so the Microsoft Threat Modeling Tool + /// recognizes it as that stencil (for example, "Azure SQL Database") rather than a bare generic + /// type. An element's stencil is read from its StencilType custom property; it is retyped + /// only when the knowledge base actually declares a standard element with that identifier, so a + /// retyped element always resolves in the tool. The element's generic type is left unchanged, so + /// analysis continues to classify it by its primitive. + /// + public static class StencilSubtypeProjection + { + private const string StencilTypePropertyName = "StencilType"; + + /// + /// Retypes stencil-placed elements to their standard element subtype declared in + /// . + /// + /// The model whose elements are retyped; it is mutated in place. + /// The knowledge base whose standard elements are the valid targets. + public static void Apply(ThreatModel model, KnowledgeBaseData knowledgeBase) + { + if (model == null) + { + throw new ArgumentNullException(nameof(model)); + } + + if (knowledgeBase == null) + { + throw new ArgumentNullException(nameof(knowledgeBase)); + } + + Dictionary subtypeIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (ElementType standard in knowledgeBase.StandardElements) + { + if (!string.IsNullOrEmpty(standard.Id)) + { + subtypeIds[standard.Id!] = standard.Id!; + } + } + + if (subtypeIds.Count == 0) + { + return; + } + + foreach (DrawingSurfaceModel surface in model.DrawingSurfaceList) + { + foreach (object node in surface.Borders.Values) + { + Retype(node as Entity, subtypeIds); + } + + foreach (object line in surface.Lines.Values) + { + Retype(line as Entity, subtypeIds); + } + } + } + + private static void Retype(Entity? element, IReadOnlyDictionary subtypeIds) + { + if (element == null) + { + return; + } + + IReadOnlyDictionary properties = DiagramElementHelper.GetCustomProperties(element); + if (properties.TryGetValue(StencilTypePropertyName, out string? stencilType) && + stencilType != null && + subtypeIds.TryGetValue(stencilType, out string? canonical)) + { + element.TypeId = canonical; + } + } + } +} diff --git a/src/ThreatModelForge.Engine/EngineService.cs b/src/ThreatModelForge.Engine/EngineService.cs index bfe20f1..d4d48c9 100644 --- a/src/ThreatModelForge.Engine/EngineService.cs +++ b/src/ThreatModelForge.Engine/EngineService.cs @@ -3,6 +3,7 @@ namespace ThreatModelForge.Engine using System; using System.Collections.Generic; using System.IO; + using System.Linq; using System.Reflection; using System.Text; using System.Text.Json; @@ -128,7 +129,7 @@ public static IReadOnlyList GetRulePacks() /// /// The canonical model. /// The findings produced by the engine. - public static IReadOnlyList Validate(TmForgeModelDto dto) + public static IReadOnlyList Analyze(TmForgeModelDto dto) { List findings = new List(); try @@ -136,9 +137,9 @@ public static IReadOnlyList Validate(TmForgeModelDto dto) ThreatModel model = BuildModel(dto, out Dictionary> nameToIds); using (RuleSet ruleSet = LoadRuleSet()) { - if (dto.Validation != null) + if (dto.Analysis != null) { - ruleSet.Disable(dto.Validation.DisabledPacks, dto.Validation.DisabledRuleIds); + ruleSet.Disable(dto.Analysis.DisabledPacks, dto.Analysis.DisabledRuleIds); } CollectingMessageWriter writer = new CollectingMessageWriter(); @@ -178,6 +179,65 @@ public static IReadOnlyList Validate(TmForgeModelDto dto) return findings; } + /// + /// Projects the model's analysis findings into STRIDE threats. Detection is entirely the + /// rule set's — this runs the same rules runs and frames the findings + /// from threat-bearing rules as persistable threats. CLI, /v1, and WASM call the same + /// projector, so results are identical by construction. + /// + /// The canonical model. + /// The generated threats. + public static IReadOnlyList GenerateThreats(TmForgeModelDto dto) + { + List result = new List(); + try + { + ThreatModel model = BuildModel(dto, out _); + using (RuleSet ruleSet = LoadRuleSet()) + { + if (dto.Analysis != null) + { + ruleSet.Disable(dto.Analysis.DisabledPacks, dto.Analysis.DisabledRuleIds); + } + + GenerationResult generation = ThreatGenerator.Generate(model, ruleSet); + Dictionary triage = BuildTriage(dto.Threats); + foreach (GeneratedThreat threat in generation.Threats) + { + triage.TryGetValue(threat.Id, out ThreatStateDto? state); + result.Add(new ThreatDto + { + Id = threat.Id, + RuleId = threat.RuleId, + Category = threat.Category.ToString(), + Title = threat.Title, + Mitigation = threat.Mitigation, + Severity = threat.Severity, + Priority = threat.Priority, + References = threat.References.Select(r => r.Id).ToList(), + ElementIds = BuildElementIds(threat), + Interaction = threat.InteractionString, + State = NormalizeState(state?.State), + Justification = state?.Justification, + }); + } + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 // Do not catch general exception types + { + result.Add(new ThreatDto + { + Id = "engine-error", + Severity = "error", + Title = $"Threat generation failed: {ex.Message}", + }); + } + + return result; + } + /// /// Serializes the supplied model to lossless .tm7 bytes via the real engine. /// @@ -185,7 +245,9 @@ public static IReadOnlyList Validate(TmForgeModelDto dto) /// The .tm7 document bytes. public static byte[] ExportTm7(TmForgeModelDto dto) { - ThreatModel model = BuildModel(dto, out _); + ThreatModel model = BuildModelForExport(dto); + Tm7ExportPreparer.Prepare(model); + using (MemoryStream stream = new MemoryStream()) { model.Save(stream); @@ -230,7 +292,11 @@ public static byte[] Convert(TmForgeModelDto dto, string formatId) throw new ArgumentException("Value cannot be null or empty.", nameof(formatId)); } - ThreatModel model = BuildModel(dto, out _); + // .tm7 is the lossless, register-bearing format, so materialize the full threat register + // (with acceptance) for it; the other formats drop the register, so keep the cheaper build. + ThreatModel model = string.Equals(formatId, Tm7Format.FormatId, StringComparison.OrdinalIgnoreCase) + ? BuildModelForExport(dto) + : BuildModel(dto, out _); ThreatModelFormatRegistry registry = ThreatModelFormatRegistry.CreateDefault(); IThreatModelFormat format = registry.FindById(formatId) ?? throw new NotSupportedException($"No threat model format with id '{formatId}'."); @@ -325,6 +391,80 @@ public static MergeResultDto Merge(TmForgeModelDto? baseModel, TmForgeModelDto o return new MergeResultDto { Merged = ToDto(result.Merged), Conflicts = conflicts }; } + private static IReadOnlyList BuildElementIds(GeneratedThreat threat) + { + List ids = new List { threat.SourceGuid.ToString() }; + if (threat.IsFlowScoped) + { + ids.Add(threat.TargetGuid.ToString()); + ids.Add(threat.FlowGuid.ToString()); + } + + return ids; + } + + private static Dictionary BuildTriage(IReadOnlyList? states) + { + Dictionary map = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (states != null) + { + foreach (ThreatStateDto state in states) + { + if (!string.IsNullOrEmpty(state.Id)) + { + map[state.Id] = state; + } + } + } + + return map; + } + + private static string NormalizeState(string? state) + => string.Equals(state, "Accepted", StringComparison.OrdinalIgnoreCase) ? "Accepted" : "Open"; + + /// + /// Builds the model for a register-bearing export (for example, .tm7), materializing the + /// full, titled threat register the CLI's tmforge threats --write produces and overlaying + /// the model's acceptance triage. The canonical read seeds only a sparse (accepted-only) register + /// from the wire overlay because the register is otherwise regenerable; for a lossless export we + /// regenerate it in full so the file carries a complete, titled register — accepted risks keep + /// their state and justification — for tools that consume it, such as the Microsoft Threat + /// Modeling Tool. + /// + /// The canonical model. + /// The model with its threat register materialized and triaged. + private static ThreatModel BuildModelForExport(TmForgeModelDto dto) + { + ThreatModel model = BuildModel(dto, out _); + + // Rebuild the register from the rules (titled and categorized) rather than the sparse, + // accepted-only seed the wire read produced, then re-apply acceptance so accepted risks keep + // their state and justification on top of the freshly generated threats. + model.AllThreatsDictionary.Clear(); + using (RuleSet ruleSet = LoadRuleSet()) + { + if (dto.Analysis != null) + { + ruleSet.Disable(dto.Analysis.DisabledPacks, dto.Analysis.DisabledRuleIds); + } + + GenerationResult generation = ThreatGenerator.Generate(model, ruleSet); + ThreatGenerator.Apply(model, generation); + } + + foreach (ThreatStateDto state in dto.Threats ?? Array.Empty()) + { + if (!string.IsNullOrEmpty(state.Id) && + string.Equals(state.State, "Accepted", StringComparison.OrdinalIgnoreCase)) + { + ThreatGenerator.Accept(model, state.Id, state.Justification ?? string.Empty); + } + } + + return model; + } + private static ThreatModel BuildModel(TmForgeModelDto dto, out Dictionary> nameToIds) { nameToIds = new Dictionary>(StringComparer.Ordinal); diff --git a/src/ThreatModelForge.Engine/ThreatDto.cs b/src/ThreatModelForge.Engine/ThreatDto.cs new file mode 100644 index 0000000..b3cc0d8 --- /dev/null +++ b/src/ThreatModelForge.Engine/ThreatDto.cs @@ -0,0 +1,47 @@ +namespace ThreatModelForge.Engine +{ + using System; + using System.Collections.Generic; + + /// + /// A single generated STRIDE threat returned to the client, projected from a validation finding. + /// + public sealed class ThreatDto + { + /// Gets the deterministic threat identifier (register key). + public string Id { get; init; } = string.Empty; + + /// Gets the identifier of the rule that detected the threat (for example TM1023). + public string RuleId { get; init; } = string.Empty; + + /// Gets the STRIDE category. + public string Category { get; init; } = string.Empty; + + /// Gets the threat title (the finding text). + public string Title { get; init; } = string.Empty; + + /// Gets the suggested mitigation (the rule's help text). + public string? Mitigation { get; init; } + + /// Gets the rule severity (error, warning, or info). + public string Severity { get; init; } = "warning"; + + /// Gets the coarse priority hint (High / Medium / Low). + public string? Priority { get; init; } + + /// Gets the external catalog reference identifiers (CAPEC / CWE / ATT&CK). + public IReadOnlyList References { get; init; } = Array.Empty(); + + /// Gets the element identifiers (source, target, flow) this threat refers to, for highlighting. + public IReadOnlyList ElementIds { get; init; } = Array.Empty(); + + /// Gets the human-readable scope string (source -> target for a flow, else the element name). + public string Interaction { get; init; } = string.Empty; + + /// Gets the triage state (Open or Accepted). + public string State { get; init; } = "Open"; + + /// Gets the risk-acceptance justification, when the threat has been accepted. + public string? Justification { get; init; } + } +} diff --git a/src/ThreatModelForge.Engine/ThreatStateDto.cs b/src/ThreatModelForge.Engine/ThreatStateDto.cs new file mode 100644 index 0000000..4820e81 --- /dev/null +++ b/src/ThreatModelForge.Engine/ThreatStateDto.cs @@ -0,0 +1,20 @@ +namespace ThreatModelForge.Engine +{ + /// + /// A triage-overlay entry: the persisted lifecycle state of one generated threat, carried on the + /// model so risk acceptance round-trips with it. Keyed by the threat's register id + /// ({targetGuid:N}:{ruleId}). Only threats whose state differs from the default + /// (Open) need an entry. + /// + public sealed class ThreatStateDto + { + /// Gets the register id of the threat this state applies to. + public string Id { get; init; } = string.Empty; + + /// Gets the triage state (Open or Accepted). + public string State { get; init; } = "Open"; + + /// Gets the risk-acceptance justification, when the threat has been accepted. + public string? Justification { get; init; } + } +} diff --git a/src/ThreatModelForge.Engine/TmForgeValidationDto.cs b/src/ThreatModelForge.Engine/TmForgeAnalysisDto.cs similarity index 69% rename from src/ThreatModelForge.Engine/TmForgeValidationDto.cs rename to src/ThreatModelForge.Engine/TmForgeAnalysisDto.cs index 3a0e453..32470f9 100644 --- a/src/ThreatModelForge.Engine/TmForgeValidationDto.cs +++ b/src/ThreatModelForge.Engine/TmForgeAnalysisDto.cs @@ -3,11 +3,11 @@ namespace ThreatModelForge.Engine using System.Collections.Generic; /// - /// Per-model validation configuration that travels with the model, so the Studio and the CLI - /// validate against the same set of rules. It selects which analysis rule packs or individual + /// Per-model analysis configuration that travels with the model, so the Studio and the CLI + /// analyze against the same set of rules. It selects which analysis rule packs or individual /// rules to skip; an absent or empty selection runs every rule. /// - public sealed class TmForgeValidationDto + public sealed class TmForgeAnalysisDto { /// Gets the ids of rule packs to skip (for example, stride-completeness). public IReadOnlyList? DisabledPacks { get; init; } diff --git a/src/ThreatModelForge.Engine/TmForgeModelDto.cs b/src/ThreatModelForge.Engine/TmForgeModelDto.cs index 535cd6c..eebd72c 100644 --- a/src/ThreatModelForge.Engine/TmForgeModelDto.cs +++ b/src/ThreatModelForge.Engine/TmForgeModelDto.cs @@ -25,7 +25,14 @@ public sealed class TmForgeModelDto /// public IReadOnlyList? Diagrams { get; init; } - /// Gets the per-model validation configuration (which rule packs or rules to skip). - public TmForgeValidationDto? Validation { get; init; } + /// Gets the per-model analysis configuration (which rule packs or rules to skip). + public TmForgeAnalysisDto? Analysis { get; init; } + + /// + /// Gets the threat triage overlay: the persisted lifecycle state (accepted risks and their + /// justifications) of generated threats, keyed by threat id. Absent or empty means every + /// threat is open. + /// + public IReadOnlyList? Threats { get; init; } } } diff --git a/src/ThreatModelForge.Formats/TmForgeJsonValidation.cs b/src/ThreatModelForge.Formats/TmForgeJsonAnalysis.cs similarity index 74% rename from src/ThreatModelForge.Formats/TmForgeJsonValidation.cs rename to src/ThreatModelForge.Formats/TmForgeJsonAnalysis.cs index b4f7b30..7fa7d07 100644 --- a/src/ThreatModelForge.Formats/TmForgeJsonValidation.cs +++ b/src/ThreatModelForge.Formats/TmForgeJsonAnalysis.cs @@ -3,11 +3,11 @@ namespace ThreatModelForge.Formats using System.Collections.Generic; /// - /// The per-model validation selection persisted inside a tmforge-json document: which + /// The per-model analysis selection persisted inside a tmforge-json document: which /// analysis rule packs or individual rules to skip. It travels with the model so the editor and - /// the CLI validate against the same rules. + /// the CLI analyze against the same rules. /// - public sealed class TmForgeJsonValidation + public sealed class TmForgeJsonAnalysis { /// Gets the ids of rule packs to skip (for example, stride-completeness). public IReadOnlyList? DisabledPacks { get; init; } diff --git a/src/ThreatModelForge.Formats/TmForgeJsonFormat.cs b/src/ThreatModelForge.Formats/TmForgeJsonFormat.cs index 973882d..bc6b1af 100644 --- a/src/ThreatModelForge.Formats/TmForgeJsonFormat.cs +++ b/src/ThreatModelForge.Formats/TmForgeJsonFormat.cs @@ -9,6 +9,7 @@ namespace ThreatModelForge.Formats using System.Text.Json; using System.Text.Json.Serialization; using ThreatModelForge.Editing; + using ThreatModelForge.KnowledgeBase; using ThreatModelForge.Model; using ThreatModelForge.Model.Abstracts; @@ -31,7 +32,7 @@ public sealed class TmForgeJsonFormat : IThreatModelFormat canRead: true, canWrite: true, roundTrips: false, - fidelityNote: "The canvas wire model: elements, flows, trust boundaries, names, and geometry. Knowledge-base attributes and generated threats are not represented."); + fidelityNote: "The canvas wire model: elements, flows, trust boundaries, names, and geometry. Knowledge-base attributes and the generated-threat register are not represented, except risk-acceptance triage, which round-trips."); private static readonly JsonSerializerOptions SerializerOptions = new JsonSerializerOptions { @@ -61,14 +62,14 @@ public sealed class TmForgeJsonFormat : IThreatModelFormat public FormatCapabilities Capabilities => JsonCapabilities; /// - /// Reads only the per-model validation selection from a tmforge-json stream, without - /// building the full model. Used to honor a model's rule selection when linting. + /// Reads only the per-model analysis selection from a tmforge-json stream, without + /// building the full model. Used to honor a model's rule selection when analyzing. /// /// The source stream positioned at the start of the document. /// On return, the rule pack ids to skip (empty when none). /// On return, the rule ids to skip (empty when none). /// true if the document declared any pack or rule to skip; otherwise false. - public static bool TryReadValidation( + public static bool TryReadAnalysis( Stream stream, out IReadOnlyList disabledPacks, out IReadOnlyList disabledRuleIds) @@ -93,13 +94,13 @@ public static bool TryReadValidation( } TmForgeJsonModel? document = JsonSerializer.Deserialize(text, SerializerOptions); - if (document?.Validation == null) + if (document?.Analysis == null) { return false; } - disabledPacks = document.Validation.DisabledPacks ?? Array.Empty(); - disabledRuleIds = document.Validation.DisabledRuleIds ?? Array.Empty(); + disabledPacks = document.Analysis.DisabledPacks ?? Array.Empty(); + disabledRuleIds = document.Analysis.DisabledRuleIds ?? Array.Empty(); return disabledPacks.Count > 0 || disabledRuleIds.Count > 0; } @@ -184,6 +185,7 @@ public ThreatModel Read(Stream stream) PopulateSurface(editor, surface, document.Elements, document.Flows); } + SeedRegister(model, document.Threats); return model; } @@ -194,12 +196,12 @@ public void Write(ThreatModel model, Stream stream) } /// - /// Writes the model to tmforge-json, embedding the given per-model validation selection. + /// Writes the model to tmforge-json, embedding the given per-model analysis selection. /// /// The model to write. /// The destination stream. - /// The validation selection to embed, or to omit it. - public void Write(ThreatModel model, Stream stream, TmForgeJsonValidation? validation) + /// The analysis selection to embed, or to omit it. + public void Write(ThreatModel model, Stream stream, TmForgeJsonAnalysis? analysis) { if (model == null) { @@ -238,7 +240,8 @@ public void Write(ThreatModel model, Stream stream, TmForgeJsonValidation? valid Elements = diagrams.Count > 0 ? diagrams[0].Elements : Array.Empty(), Flows = diagrams.Count > 0 ? diagrams[0].Flows : Array.Empty(), Diagrams = diagrams.Count > 1 ? diagrams.ToArray() : null, - Validation = validation, + Analysis = analysis, + Threats = CollectTriage(model), }; string json = JsonSerializer.Serialize(document, SerializerOptions); @@ -252,6 +255,115 @@ public void Write(ThreatModel model, Stream stream, TmForgeJsonValidation? valid } } + /// + /// Seeds the model's threat register from the triage overlay carried on the document, so risk + /// acceptance recorded in Studio (or a prior CLI session) survives the round-trip. Only the + /// durable, human-set triage is stored on tmforge-json; the rest of the register is a + /// projection of the analysis findings and is regenerated on demand (tmforge threats), + /// which preserves these accepted entries by their register key. + /// + /// The model whose register is seeded. + /// The triage overlay from the document, if any. + private static void SeedRegister(ThreatModel model, IReadOnlyList? triage) + { + if (triage == null) + { + return; + } + + Guid surfaceGuid = FirstSurfaceGuid(model); + int nextId = 1; + foreach (TmForgeJsonThreatState entry in triage) + { + if (string.IsNullOrEmpty(entry.Id) || + !string.Equals(entry.State, "Accepted", StringComparison.OrdinalIgnoreCase) || + model.AllThreatsDictionary.ContainsKey(entry.Id)) + { + continue; + } + + (Guid targetGuid, string? ruleId) = ParseThreatId(entry.Id); + model.AllThreatsDictionary[entry.Id] = new Threat + { + Id = nextId, + TypeId = ruleId, + SourceGuid = targetGuid, + DrawingSurfaceGuid = surfaceGuid, + State = ThreatState.NotApplicable, + InteractionKey = entry.Id, + StateInformation = entry.Justification ?? string.Empty, + ModifiedAt = DateTime.UtcNow, + }; + nextId++; + } + } + + /// + /// Projects the model's accepted register threats back onto the document overlay so risk + /// acceptance round-trips. Only accepted risks () are + /// represented; the rest of the register is regenerable and is not stored. + /// + /// The model whose register is read. + /// The triage overlay, or when nothing is accepted. + private static IReadOnlyList? CollectTriage(ThreatModel model) + { + List triage = new List(); + foreach (KeyValuePair pair in model.AllThreatsDictionary) + { + Threat threat = pair.Value; + if (threat.State != ThreatState.NotApplicable) + { + continue; + } + + string id = string.IsNullOrEmpty(threat.InteractionKey) ? pair.Key : threat.InteractionKey!; + triage.Add(new TmForgeJsonThreatState + { + Id = id, + State = "Accepted", + Justification = string.IsNullOrEmpty(threat.StateInformation) ? null : threat.StateInformation, + }); + } + + if (triage.Count == 0) + { + return null; + } + + triage.Sort((left, right) => string.CompareOrdinal(left.Id, right.Id)); + return triage; + } + + /// + /// Splits a register id ({targetGuid:N}:{ruleId}) into its target element GUID and rule + /// id. Returns an empty GUID and rule id when the id is not in that form. + /// + /// The register id. + /// The parsed target GUID and rule id. + private static (Guid TargetGuid, string? RuleId) ParseThreatId(string id) + { + int colon = id.IndexOf(':'); + if (colon <= 0) + { + return (Guid.Empty, null); + } + + string guidPart = id.Substring(0, colon); + string ruleId = id.Substring(colon + 1); + Guid.TryParseExact(guidPart, "N", out Guid targetGuid); + return (targetGuid, string.IsNullOrEmpty(ruleId) ? null : ruleId); + } + + private static Guid FirstSurfaceGuid(ThreatModel model) + { + foreach (DrawingSurfaceModel surface in model.DrawingSurfaceList) + { + return surface.Guid; + } + + return Guid.Empty; + } + private static void PopulateSurface( DiagramEditor editor, DrawingSurfaceModel surface, diff --git a/src/ThreatModelForge.Formats/TmForgeJsonModel.cs b/src/ThreatModelForge.Formats/TmForgeJsonModel.cs index f8a42df..c21642d 100644 --- a/src/ThreatModelForge.Formats/TmForgeJsonModel.cs +++ b/src/ThreatModelForge.Formats/TmForgeJsonModel.cs @@ -5,8 +5,9 @@ namespace ThreatModelForge.Formats /// /// The tmforge-json document: the canvas's canonical in-memory model and the wire shape - /// exchanged with clients. It carries diagram structure only (elements, flows, trust - /// boundaries, names, and geometry), not knowledge-base attributes or generated threats. + /// exchanged with clients. It carries diagram structure (elements, flows, trust boundaries, + /// names, and geometry) plus the per-model analysis selection and the risk-acceptance triage + /// overlay; it does not carry knowledge-base attributes or the full (regenerable) threat register. /// public sealed class TmForgeJsonModel { @@ -29,7 +30,15 @@ public sealed class TmForgeJsonModel /// public IReadOnlyList? Diagrams { get; init; } - /// Gets the per-model validation selection (which rule packs or rules to skip). - public TmForgeJsonValidation? Validation { get; init; } + /// Gets the per-model analysis selection (which rule packs or rules to skip). + public TmForgeJsonAnalysis? Analysis { get; init; } + + /// + /// Gets the threat triage overlay: the persisted lifecycle state (accepted risks and their + /// justifications) of generated threats, keyed by threat id. Absent or empty means every + /// threat is open. This is the only part of the (otherwise regenerable) threat register that + /// tmforge-json carries, so risk acceptance survives a Studio export or CLI round-trip. + /// + public IReadOnlyList? Threats { get; init; } } } diff --git a/src/ThreatModelForge.Formats/TmForgeJsonThreatState.cs b/src/ThreatModelForge.Formats/TmForgeJsonThreatState.cs new file mode 100644 index 0000000..8769299 --- /dev/null +++ b/src/ThreatModelForge.Formats/TmForgeJsonThreatState.cs @@ -0,0 +1,21 @@ +namespace ThreatModelForge.Formats +{ + /// + /// A triage-overlay entry carried on tmforge-json: the persisted lifecycle state of one + /// generated threat, so risk acceptance round-trips with the structural model even though the + /// full (regenerable) threat register is not stored. Keyed by the threat's register id + /// ({targetGuid:N}:{ruleId}). Only threats whose state differs from the default + /// (Open) are recorded. + /// + public sealed class TmForgeJsonThreatState + { + /// Gets the register id of the threat this state applies to. + public string Id { get; init; } = string.Empty; + + /// Gets the triage state (Open or Accepted). + public string State { get; init; } = "Open"; + + /// Gets the risk-acceptance justification, when the threat has been accepted. + public string? Justification { get; init; } + } +} diff --git a/src/ThreatModelForge.Studio/src/App.css b/src/ThreatModelForge.Studio/src/App.css index 6a23310..a2bb394 100644 --- a/src/ThreatModelForge.Studio/src/App.css +++ b/src/ThreatModelForge.Studio/src/App.css @@ -954,6 +954,184 @@ margin-right: 2px; } +/* ---- threats panel (STRIDE register) ---- */ +.threats { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; + max-width: 340px; + max-height: 50vh; + overflow-y: auto; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.14); + font-size: 12px; + margin-top: 8px; +} + +.threats h3 { + margin: 0 0 6px; + font-size: 12px; +} + +.threat-group { + margin-bottom: 6px; +} + +.threat-group-head { + display: flex; + align-items: center; + gap: 6px; + font-weight: 700; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.02em; + color: var(--muted); + padding: 4px 0 2px; + border-bottom: 1px solid var(--border); + margin-bottom: 2px; +} + +.threat-count { + font-weight: 700; + color: var(--muted); + background: var(--surface-3); + border-radius: 6px; + padding: 0 6px; + font-size: 10px; +} + +.threat { + display: flex; + flex-direction: column; + padding: 4px 0; +} + +.threat-row { + display: flex; + gap: 8px; + align-items: flex-start; +} + +.threat-head { + display: flex; + gap: 8px; + flex: 1 1 auto; + min-width: 0; + line-height: 1.35; + cursor: pointer; +} + +.threat-head .sev { + font-weight: 700; + text-transform: uppercase; + font-size: 10px; + flex: 0 0 auto; + padding-top: 2px; +} + +.threat-actions { + flex: 0 0 auto; +} + +.threat-action { + font-size: 11px; + font-weight: 600; + background: none; + border: none; + color: #2563eb; + cursor: pointer; + padding: 1px 4px; + white-space: nowrap; +} + +.threat-action:hover { + text-decoration: underline; +} + +.threat-accepted { + opacity: 0.68; +} + +.threat-badge { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + color: #047857; + background: #ecfdf5; + border-radius: 4px; + padding: 0 5px; + margin-left: 4px; +} + +.threat-justification { + font-size: 11px; + font-style: italic; + color: var(--muted); + margin-top: 2px; +} + +.threat-accept { + display: flex; + flex-direction: column; + gap: 6px; + margin: 6px 0 4px 28px; +} + +.threat-accept-input { + width: 100%; + min-height: 44px; + font: inherit; + font-size: 12px; + padding: 6px; + border: 1px solid var(--border); + border-radius: 6px; + resize: vertical; + box-sizing: border-box; +} + +.threat-accept-buttons { + display: flex; + gap: 6px; +} + +.threats-accepted-count { + color: var(--muted); + font-weight: 400; +} + +.threat-body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.threat-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.threat-scope { + color: var(--muted); + font-size: 11px; +} + +.threat-ref { + font-size: 10px; + font-weight: 700; + color: #0e7490; + background: #ecfeff; + border-radius: 4px; + padding: 0 5px; + text-decoration: none; +} + +a.threat-ref:hover { + text-decoration: underline; +} + /* engine status pill */ .engine-pill { display: inline-flex; @@ -1659,7 +1837,7 @@ background: var(--process); } -.conflict-kind.kind-store { +.conflict-kind.kind-datastore { background: var(--datastore); } diff --git a/src/ThreatModelForge.Studio/src/dfd/ValidationSettings.tsx b/src/ThreatModelForge.Studio/src/dfd/AnalysisSettings.tsx similarity index 94% rename from src/ThreatModelForge.Studio/src/dfd/ValidationSettings.tsx rename to src/ThreatModelForge.Studio/src/dfd/AnalysisSettings.tsx index 4da3309..95ea61f 100644 --- a/src/ThreatModelForge.Studio/src/dfd/ValidationSettings.tsx +++ b/src/ThreatModelForge.Studio/src/dfd/AnalysisSettings.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import type { RuleInfo, RulePackInfo } from './engineClient'; -interface ValidationSettingsProps { +interface AnalysisSettingsProps { /** The full rule catalog from the engine. */ rules: RuleInfo[]; /** The rule packs from the engine, in presentation order. */ @@ -22,24 +22,24 @@ function firstSentence(text: string): string { } /** - * The per-model validation picker: choose which rule packs and individual rules the model is - * validated against. The selection travels with the model, so the Studio and the CLI agree. + * The per-model analysis-rule picker: choose which rule packs and individual rules the model is + * analyzed against. The selection travels with the model, so the Studio and the CLI agree. */ -export function ValidationSettings({ +export function AnalysisSettings({ rules, packs, disabledPacks, disabledRuleIds, onTogglePack, onToggleRule, -}: ValidationSettingsProps) { +}: AnalysisSettingsProps) { // Which rule's in-app help panel is expanded (only one at a time keeps the panel compact). const [openHelpId, setOpenHelpId] = useState(null); if (packs.length === 0) { return (

- Connect the engine to choose which rule packs and rules to validate against. + Connect the engine to choose which rule packs and rules to analyze against.

); } diff --git a/src/ThreatModelForge.Studio/src/dfd/Editor.tsx b/src/ThreatModelForge.Studio/src/dfd/Editor.tsx index 916cbe5..15d0c28 100644 --- a/src/ThreatModelForge.Studio/src/dfd/Editor.tsx +++ b/src/ThreatModelForge.Studio/src/dfd/Editor.tsx @@ -26,9 +26,10 @@ import { TrustBoundaryNode } from './nodes/TrustBoundaryNode'; import { Palette } from './Palette'; import { Toolbar } from './Toolbar'; import { Inspector } from './Inspector'; -import { ValidationSettings } from './ValidationSettings'; +import { AnalysisSettings } from './AnalysisSettings'; import { FALLBACK_PACKS, FALLBACK_STENCILS } from './stencils'; -import { createHttpEngine, loadWasmEngine, offlineEngine, probeEngine, type Finding, type FormatInfo, type IEngineClient, type PackInfo, type PropertyDescriptorInfo, type RuleInfo, type RulePackInfo, type StencilInfo } from './engineClient'; +import { createHttpEngine, loadWasmEngine, offlineEngine, probeEngine, type Finding, type FormatInfo, type IEngineClient, type PackInfo, type PropertyDescriptorInfo, type RuleInfo, type RulePackInfo, type StencilInfo, type Threat } from './engineClient'; +import { ThreatsPanel } from './ThreatsPanel'; import { DEFAULT_NODE_SIZE, modelFromPages, pagesFromModel, type PageGraph } from './mapping'; import { useUndoRedo } from './useUndoRedo'; import { FlowEdge } from './edges/FlowEdge'; @@ -36,7 +37,7 @@ import { PageTabs } from './PageTabs'; import { MergeResolveModal } from './MergeResolveModal'; import { DfdActionsContext, type DfdActions } from './editorContext'; import { Toaster, toast } from './toast'; -import type { DfdEdge, DfdKind, DfdNode, TmForgeModel, TmForgeValidation } from './types'; +import type { DfdEdge, DfdKind, DfdNode, ThreatTriage, TmForgeModel, TmForgeAnalysis } from './types'; const nodeTypes: NodeTypes = { process: ShapeNode, @@ -88,7 +89,8 @@ function initialTheme(): Theme { interface StoredWorkspace { pages: PageGraph[]; activePageId: string; - validation?: TmForgeValidation; + analysis?: TmForgeAnalysis; + threats?: ThreatTriage[]; } /** Reads the saved multi-page workspace (v2), migrating a legacy single-page model (v1) when present. */ @@ -100,7 +102,7 @@ function loadStoredWorkspace(): StoredWorkspace | null { if (parsed?.model?.schema === 'tmforge-json') { const pages = pagesFromModel(parsed.model); const activePageId = pages.some((p) => p.id === parsed.activePageId) ? parsed.activePageId! : pages[0].id; - return { pages, activePageId, validation: parsed.model.validation }; + return { pages, activePageId, analysis: parsed.model.analysis, threats: parsed.model.threats }; } } } catch { @@ -112,7 +114,7 @@ function loadStoredWorkspace(): StoredWorkspace | null { const model = JSON.parse(raw) as TmForgeModel; if (model?.schema === 'tmforge-json') { const pages = pagesFromModel(model); - return { pages, activePageId: pages[0].id, validation: model.validation }; + return { pages, activePageId: pages[0].id, analysis: model.analysis, threats: model.threats }; } } } catch { @@ -170,10 +172,10 @@ function persistStringList(key: string, value: string[]): void { const INITIAL_WORKSPACE = loadStoredWorkspace() ?? emptyWorkspace(); const INITIAL_ACTIVE = INITIAL_WORKSPACE.pages.find((p) => p.id === INITIAL_WORKSPACE.activePageId) ?? INITIAL_WORKSPACE.pages[0]; -const INITIAL_DISABLED_PACKS = INITIAL_WORKSPACE.validation?.disabledPacks ?? []; -const INITIAL_DISABLED_RULE_IDS = INITIAL_WORKSPACE.validation?.disabledRuleIds ?? []; +const INITIAL_DISABLED_PACKS = INITIAL_WORKSPACE.analysis?.disabledPacks ?? []; +const INITIAL_DISABLED_RULE_IDS = INITIAL_WORKSPACE.analysis?.disabledRuleIds ?? []; const INITIAL_SAVED_JSON = JSON.stringify( - modelFromPages(INITIAL_WORKSPACE.pages, buildValidation(INITIAL_DISABLED_PACKS, INITIAL_DISABLED_RULE_IDS)), + modelFromPages(INITIAL_WORKSPACE.pages, buildAnalysis(INITIAL_DISABLED_PACKS, INITIAL_DISABLED_RULE_IDS)), ); /** Minimal shape of the File System Access API used to open and overwrite files (Chromium). */ @@ -202,16 +204,16 @@ function downloadBlob(blob: Blob, filename: string): void { URL.revokeObjectURL(url); } -/** Builds the validation block from the current selection, or undefined when nothing is disabled. */ -function buildValidation(disabledPacks: string[], disabledRuleIds: string[]): TmForgeValidation | undefined { - const validation: TmForgeValidation = {}; +/** Builds the analysis-rule selection from the current toggles, or undefined when nothing is disabled. */ +function buildAnalysis(disabledPacks: string[], disabledRuleIds: string[]): TmForgeAnalysis | undefined { + const analysis: TmForgeAnalysis = {}; if (disabledPacks.length > 0) { - validation.disabledPacks = disabledPacks; + analysis.disabledPacks = disabledPacks; } if (disabledRuleIds.length > 0) { - validation.disabledRuleIds = disabledRuleIds; + analysis.disabledRuleIds = disabledRuleIds; } - return validation.disabledPacks || validation.disabledRuleIds ? validation : undefined; + return analysis.disabledPacks || analysis.disabledRuleIds ? analysis : undefined; } /** Returns copies of the graph with the `flagged` class applied to elements a finding referenced. */ @@ -232,6 +234,8 @@ export function Editor() { const [nodes, setNodes, onNodesChange] = useNodesState(INITIAL_ACTIVE.nodes); const [edges, setEdges, onEdgesChange] = useEdgesState(INITIAL_ACTIVE.edges); const [findings, setFindings] = useState([]); + const [threats, setThreats] = useState([]); + const [threatTriage, setThreatTriage] = useState(INITIAL_WORKSPACE.threats ?? []); const flaggedIdsRef = useRef>(new Set()); const [engine, setEngine] = useState(offlineEngine); const [engineOnline, setEngineOnline] = useState(false); @@ -248,7 +252,7 @@ export function Editor() { const [disabledRuleIds, setDisabledRuleIds] = useState(() => INITIAL_DISABLED_RULE_IDS); const [showRules, setShowRules] = useState(false); const [showMerge, setShowMerge] = useState(false); - const validationActiveRef = useRef(false); + const analysisActiveRef = useRef(false); const fileRef = useRef(null); const fileHandleRef = useRef(null); const fileFormatRef = useRef('tmforge-json'); @@ -369,7 +373,7 @@ export function Editor() { const stencilById = useMemo(() => new Map(stencils.map((s) => [s.id, s])), [stencils]); - // Load the analysis rule catalog + rule packs (for the validation settings panel) from the engine. + // Load the analysis rule catalog + rule packs (for the Analysis Rules settings panel) from the engine. useEffect(() => { let active = true; void engine @@ -419,10 +423,10 @@ export function Editor() { // the model, so selecting a node never marks it dirty); `dirty` compares it to the snapshot from // the last explicit Save. A debounced localStorage write of the whole workspace (pages + active // tab) runs on every change as a crash-recovery net, so a reload never loses work. - const currentModel = useMemo( - () => modelFromPages(allPages, buildValidation(disabledRulePacks, disabledRuleIds)), - [allPages, disabledRulePacks, disabledRuleIds], - ); + const currentModel = useMemo(() => { + const model = modelFromPages(allPages, buildAnalysis(disabledRulePacks, disabledRuleIds)); + return threatTriage.length > 0 ? { ...model, threats: threatTriage } : model; + }, [allPages, disabledRulePacks, disabledRuleIds, threatTriage]); const currentJson = useMemo(() => JSON.stringify(currentModel), [currentModel]); const [savedJson, setSavedJson] = useState(INITIAL_SAVED_JSON); const dirty = currentJson !== savedJson; @@ -455,7 +459,7 @@ export function Editor() { } setPages(committed); setActivePageId(targetId); - const applied = validationActiveRef.current + const applied = analysisActiveRef.current ? applyFlags(target.nodes, target.edges, flaggedIdsRef.current) : { nodes: target.nodes, edges: target.edges }; setNodes(applied.nodes); @@ -501,7 +505,7 @@ export function Editor() { if (id === activePageId) { const next = remaining[Math.min(index, remaining.length - 1)]; setActivePageId(next.id); - const applied = validationActiveRef.current + const applied = analysisActiveRef.current ? applyFlags(next.nodes, next.edges, flaggedIdsRef.current) : { nodes: next.nodes, edges: next.edges }; setNodes(applied.nodes); @@ -543,8 +547,9 @@ export function Editor() { const findingPageIds = useMemo(() => { const set = new Set(); - for (const finding of findings) { - for (const id of finding.elementIds) { + const elementIdLists = [...findings.map((f) => f.elementIds), ...threats.map((t) => t.elementIds)]; + for (const elementIds of elementIdLists) { + for (const id of elementIds) { const pageId = elementPageIndex.get(id); if (pageId) { set.add(pageId); @@ -552,20 +557,7 @@ export function Editor() { } } return set; - }, [findings, elementPageIndex]); - - const jumpToFinding = useCallback( - (finding: Finding) => { - for (const id of finding.elementIds) { - const pageId = elementPageIndex.get(id); - if (pageId && pageId !== activePageId) { - switchPage(pageId); - return; - } - } - }, - [elementPageIndex, activePageId, switchPage], - ); + }, [findings, threats, elementPageIndex]); const serializeModel = useCallback( async (formatId: string): Promise => { @@ -710,7 +702,7 @@ export function Editor() { }, []); // Enable/disable a whole rule pack for this model. The selection travels with the model (saved in - // the file and sent on validate), so it is model state, not a persisted UI preference. + // the file and sent on analyze), so it is model state, not a persisted UI preference. const toggleRulePack = useCallback((packId: string) => { setDisabledRulePacks((prev) => prev.includes(packId) ? prev.filter((x) => x !== packId) : [...prev, packId], @@ -878,37 +870,89 @@ export function Editor() { setNodes((nds) => nds.map((n) => (n.className ? { ...n, className: undefined } : n))); setEdges((eds) => eds.map((e) => (e.className ? { ...e, className: undefined } : e))); setFindings([]); + setThreats([]); flaggedIdsRef.current = new Set(); - validationActiveRef.current = false; + analysisActiveRef.current = false; }, [setNodes, setEdges]); - const runValidate = useCallback(async () => { - let result: Finding[]; + // Analyze the model: generate the STRIDE threat register and, in parallel, the model-hygiene + // findings. Threats (threat-bearing rules) and findings (the rest) are the same detection, so they + // share one panel: the register leads, non-threat findings trail. The register carries the model's + // acceptance triage, so accepted risks come back Accepted. + const runAnalyze = useCallback(async () => { + let generated: Threat[]; + let allFindings: Finding[]; try { - result = await engine.validate(currentModel); + [generated, allFindings] = await Promise.all([ + engine.generateThreats(currentModel), + engine.analyze(currentModel), + ]); } catch (err) { - toast(err instanceof Error ? err.message : 'Validation failed.', 'error'); + toast(err instanceof Error ? err.message : 'Analysis failed.', 'error'); return; } - setFindings(result); - validationActiveRef.current = true; - const flagged = new Set(result.flatMap((f) => f.elementIds)); + setThreats(generated); + // "Other findings" = findings from non-threat-bearing (hygiene) rules; the threat-bearing ones + // are already shown as threats. + const threatRuleIds = new Set(generated.map((t) => t.ruleId)); + setFindings(allFindings.filter((f) => !f.ruleId || !threatRuleIds.has(f.ruleId))); + analysisActiveRef.current = true; + const flagged = new Set([...generated.flatMap((t) => t.elementIds), ...allFindings.flatMap((f) => f.elementIds)]); flaggedIdsRef.current = flagged; const applied = applyFlags(nodes, edges, flagged); setNodes(applied.nodes); setEdges(applied.edges); }, [engine, currentModel, nodes, edges, setNodes, setEdges]); - // When a validation is already on screen, changing the rule selection re-runs it so the findings - // reflect the new choice immediately. A ref holds the latest runValidate to avoid a dependency loop. - const runValidateRef = useRef(runValidate); - runValidateRef.current = runValidate; + // When an analysis is already on screen, changing the rule selection re-runs it so the results + // reflect the new choice immediately. A ref holds the latest runAnalyze to avoid a dependency loop. + const runAnalyzeRef = useRef(runAnalyze); + runAnalyzeRef.current = runAnalyze; useEffect(() => { - if (validationActiveRef.current) { - void runValidateRef.current(); + if (analysisActiveRef.current) { + void runAnalyzeRef.current(); } }, [disabledRulePacks, disabledRuleIds]); + // Accept a threat's risk with a justification: record it in the model's triage overlay (so it + // persists and round-trips) and reflect it immediately in the panel, no re-analysis needed. + const acceptThreat = useCallback((threat: Threat, reason: string) => { + setThreatTriage((prev) => [...prev.filter((t) => t.id !== threat.id), { id: threat.id, state: 'Accepted', justification: reason }]); + setThreats((prev) => prev.map((t) => (t.id === threat.id ? { ...t, state: 'Accepted', justification: reason } : t))); + }, []); + + // Revert an accepted threat back to open. + const undoAccept = useCallback((threat: Threat) => { + setThreatTriage((prev) => prev.filter((t) => t.id !== threat.id)); + setThreats((prev) => prev.map((t) => (t.id === threat.id ? { ...t, state: 'Open', justification: undefined } : t))); + }, []); + + // Navigates to the page holding the first of a threat's / finding's referenced elements. + const jumpToElements = useCallback( + (elementIds: string[]) => { + for (const id of elementIds) { + const pageId = elementPageIndex.get(id); + if (pageId && pageId !== activePageId) { + switchPage(pageId); + return; + } + } + }, + [elementPageIndex, activePageId, switchPage], + ); + + // The name of the page a threat's elements live on, when that is not the page in view (for a badge). + const offPageLabel = useCallback( + (elementIds: string[]): string | undefined => { + const pageId = elementIds.map((id) => elementPageIndex.get(id)).find(Boolean); + if (!pageId || pageId === activePageId) { + return undefined; + } + return pages.find((p) => p.id === pageId)?.name; + }, + [elementPageIndex, activePageId, pages], + ); + const exportAs = useCallback( async (formatId: string) => { const format = formats.find((f) => f.id === formatId); @@ -925,8 +969,8 @@ export function Editor() { const loadModel = useCallback( (model: TmForgeModel) => { const nextPages = pagesFromModel(model); - const nextPacks = model.validation?.disabledPacks ?? []; - const nextRuleIds = model.validation?.disabledRuleIds ?? []; + const nextPacks = model.analysis?.disabledPacks ?? []; + const nextRuleIds = model.analysis?.disabledRuleIds ?? []; const first = nextPages[0]; setPages(nextPages); setActivePageId(first.id); @@ -935,12 +979,14 @@ export function Editor() { setDisabledRulePacks(nextPacks); setDisabledRuleIds(nextRuleIds); setFindings([]); + setThreats([]); + setThreatTriage(model.threats ?? []); flaggedIdsRef.current = new Set(); - validationActiveRef.current = false; + analysisActiveRef.current = false; setSelection({ node: null, edge: null }); reset(); // A freshly loaded model is the new saved baseline, so it does not read as dirty. - setSavedJson(JSON.stringify(modelFromPages(nextPages, buildValidation(nextPacks, nextRuleIds)))); + setSavedJson(JSON.stringify(modelFromPages(nextPages, buildAnalysis(nextPacks, nextRuleIds)))); window.setTimeout(() => fitView({ padding: 0.25, maxZoom: 1.15, duration: 300 }), 0); }, [setNodes, setEdges, fitView, reset], @@ -948,7 +994,7 @@ export function Editor() { const readModelFromBytes = useCallback( async (bytes: Uint8Array, formatId: string): Promise => { - // The native tmforge-json is parsed client-side so the per-model validation selection is + // The native tmforge-json is parsed client-side so the per-model analysis selection is // preserved; other formats round-trip through the engine (which projects onto tmforge-json). if (formatId === 'tmforge-json') { return engine.read(new TextDecoder().decode(bytes)); @@ -1008,8 +1054,10 @@ export function Editor() { setNodes([]); setEdges([]); setFindings([]); + setThreats([]); + setThreatTriage([]); flaggedIdsRef.current = new Set(); - validationActiveRef.current = false; + analysisActiveRef.current = false; setSelection({ node: null, edge: null }); reset(); }, [setNodes, setEdges, reset]); @@ -1036,7 +1084,7 @@ export function Editor() { onMerge={() => setShowMerge(true)} dirty={dirty} fileName={fileName} - onValidate={runValidate} + onAnalyze={runAnalyze} onClear={clearAll} onFit={() => fitView({ padding: 0.25, maxZoom: 1.15, duration: 300 })} onUndo={undo} @@ -1069,7 +1117,7 @@ export function Editor() { onNodesDelete={takeSnapshot} onEdgesDelete={takeSnapshot} onSelectionChange={onSelectionChange} - onPaneClick={findings.length ? clearFlags : undefined} + onPaneClick={findings.length || threats.length ? clearFlags : undefined} nodeTypes={nodeTypes} edgeTypes={edgeTypes} defaultEdgeOptions={defaultEdgeOptions} @@ -1105,13 +1153,13 @@ export function Editor() { - Validation + Analysis Rules {disabledRulePacks.length + disabledRuleIds.length > 0 ? ( {disabledRulePacks.length + disabledRuleIds.length} off ) : null} {showRules && ( - )} - {findings.length > 0 && ( -
-

- {findings.length} finding{findings.length === 1 ? '' : 's'} -

- {findings.map((f) => { - const pageId = f.elementIds.map((id) => elementPageIndex.get(id)).find(Boolean); - const pageName = - pageId && pageId !== activePageId ? pages.find((p) => p.id === pageId)?.name : undefined; - return ( -
jumpToFinding(f)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - jumpToFinding(f); - } - }} - > - {f.severity} - - {f.ruleId ? {f.ruleId} : null} {f.message} - {pageName ? {pageName} : null} - -
- ); - })} -
+ {(threats.length > 0 || findings.length > 0) && ( + )} diff --git a/src/ThreatModelForge.Studio/src/dfd/Inspector.tsx b/src/ThreatModelForge.Studio/src/dfd/Inspector.tsx index a1153ac..6e24bfd 100644 --- a/src/ThreatModelForge.Studio/src/dfd/Inspector.tsx +++ b/src/ThreatModelForge.Studio/src/dfd/Inspector.tsx @@ -163,7 +163,7 @@ export function Inspector(props: InspectorProps) { />

Set the properties a rule reads — for example Protocol and Port, a DataType, or an{' '} - Algorithm — and mention the protocol in the label, then re-validate to clear the flow findings + Algorithm — and mention the protocol in the label, then re-analyze to clear the flow findings (for example TM1008 / TM1009 / TM1010 / TM1013 / TM1016 / TM1025).

+ ) : !isAccepting ? ( + + ) : null} + + + {isAccepting ? ( +
{ + event.preventDefault(); + confirmAccept(threat); + }} + > +