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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -712,7 +712,7 @@ lookup when needed. `--remove` deletes a manual or stale entry. Each operation t

```text
tmforge threats --add --title <t> --category <STRIDE> [--id <id>] [--scope <id>] [--state <s>] [--priority <p>] [--mitigation <m>] [--description <d>] <model>
tmforge threats --edit <id> [--state <s>] [--priority <p>] [--mitigation <m>] [--description <d>] [--note <n>] <model>
tmforge threats --edit <id> [--title <t>] [--category <c>] [--state <s>] [--priority <p>] [--mitigation <m>] [--description <d>] [--note <n>] <model>
tmforge threats --remove <id> <model>
tmforge threats --remove-stale [--force] [--rules <path>] <model>
```
Expand All @@ -721,18 +721,41 @@ tmforge threats --remove-stale [--force] [--rules <path>] <model>
| --- | --- |
| `--add` | Author a **manual threat** the rules do not detect. `--category` is a STRIDE category (`Spoofing` / `Tampering` / `Repudiation` / `InformationDisclosure` / `DenialOfService` / `ElevationOfPrivilege`); `--scope` is an element or flow id (omit for a model-wide threat). Manual threats are keyed in the reserved `manual:` namespace and do not implicitly persist generated threats. |
| `--id <id>` | Key the threat yourself instead of taking a generated id, so it can be referenced from a ticket or control catalogue and the same authoring command can be re-run. Letters, digits, `-`, `_`, and `.`, up to 128 characters; the `manual:` prefix is added if you omit it. Re-using an existing id is an error — use `--edit` to change that threat. |
| `--edit <id>` | Change a threat's `--state` (`Open` / `NeedsInvestigation` / `Mitigated` / `Accepted`), `--priority` (`Critical` / `High` / `Medium` / `Low`), `--mitigation`, `--description`, or `--note`. Works on rule-derived and manual threats. |
| `--edit <id>` | Change a threat's `--title`, `--state` (`Open` / `NeedsInvestigation` / `Mitigated` / `Accepted`), `--priority` (`Critical` / `High` / `Medium` / `Low`), `--mitigation`, `--description`, or `--note`. Works on rule-derived and manual threats. `--category` applies to **manual threats only**. |
| `--title <t>` | Retitle a threat. On a rule-derived threat this is an **override**: the rule keeps detecting the threat and keeps its identity, but the register shows your wording. Pass an empty title to drop the override and restore the rule's. |
| `--category <c>` | Set a **manual** threat's STRIDE category. Refused for a rule-derived threat, whose category is the rule's conclusion rather than an author's opinion. |
| `--remove <id>` | Delete a **manual** threat, or a **stale** one. A threat the rules still produce is refused: removing it would only bring it back on the next run — accept or edit it instead. |
| `--remove-stale` | Delete every stale entry at once. Entries carrying triage are **kept and listed**; `--force` discards them too. Refuses outright if any entry's rule was not part of the run, naming the rules so you can re-run with `--rules` rather than lose a real finding. |

```bash
tmforge threats app.tm7 --add --title "Admin actions are unlogged" --category Repudiation --scope <process-id> --priority High
tmforge threats app.tm7 --add --id unlogged-admin-actions --title "Admin actions are unlogged" --category Repudiation
tmforge threats app.tm7 --edit <id> --state Mitigated --description "Handled by the mesh"
tmforge threats app.tm7 --edit <id> --title "Unauthenticated ledger write" # override the rule's wording
tmforge threats app.tm7 --edit <id> --title "" # and hand it back to the rule
tmforge list threats app.tm7 # see the register and each entry's standing
tmforge threats app.tm7 --remove-stale # clear leftovers; triaged ones are kept and listed
```

#### Who owns which field

A threat has two kinds of text on it and they answer to different people. **Detection** — the rule
that fired, the category it concluded, and the threat's identity — belongs to the rule set, so a
re-run can be trusted to say the same thing. **Judgement** — title, state, priority, description,
mitigation, and note — belongs to the author.

Title sits deliberately on the author's side of that line. Rule wording is written to be precise
about a class of problem, not about your system, and a reviewer reading the register is better served
by "Unauthenticated ledger write" than by a sentence naming a generic data flow. Overriding it
changes nothing a later run depends on: the rule still fires, the threat keeps its id, and the
analysis document, SARIF fingerprints, and register key are all unmoved. Clearing the override
restores the rule's wording, so the edit is reversible.

Category does not, and that asymmetry is the point. A generated threat's category is what the
analysis concluded; letting an author change it would record a claim the rules do not support, and
the register would no longer mean one thing. The edit is refused rather than ignored, so nobody is
left believing it took effect. A manual threat has no rule behind it, so its author owns both.

#### Clearing stale entries

Applying a generation result never deletes, so triage survives every re-run. The cost is that an
Expand Down
10 changes: 10 additions & 0 deletions docs/studio-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,20 @@ configuration, so the loader reports that rather than pretending a local pack to
**Analyze** also returns the model's **STRIDE threat register** — the threat-bearing findings framed as
threats, grouped by category. Each threat is **editable inline**: click **Edit** to set its

- **title** — your wording in place of the rule's; clear it to restore the rule's;
- **state** — Open, Needs investigation, Mitigated, or Accepted (accepting reveals a justification field);
- **priority** — Critical / High / Medium / Low;
- **description** and **mitigation** notes.

The **category** control is disabled for a rule-derived threat, and says so. Its category is what the
analysis concluded, not an opinion held separately from the rules — editing it would record a claim
the rule set does not support. A manual threat has no rule behind it, so its author owns its category
as well as its title.

Retitling a rule threat changes nothing a later run depends on: the rule keeps detecting it, and the
threat keeps the identity that the analysis document, SARIF fingerprints, and the `.tm7` register key
are all built on.

Click **+ Add threat** to author a **manual threat** the rules do not detect: give it a title, a STRIDE
category, and a scope — a specific element or flow, or model-wide. Manual threats are badged **Manual**
and can be **deleted**; rule-derived threats cannot.
Expand Down
137 changes: 131 additions & 6 deletions src/ThreatModelForge.Analysis/ThreatGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,9 +256,9 @@ public static Threat AddManual(
}

/// <summary>
/// Edits a threat's author-owned fields in place: its lifecycle state, priority, description,
/// mitigation, and state note. Only the non-null arguments are applied. Works on both
/// rule-derived and manually-authored threats found in the register.
/// Edits a threat's author-owned fields in place: its lifecycle state, priority, title,
/// description, mitigation, category, and state note. Only the non-null arguments are applied.
/// Works on both rule-derived and manually-authored threats found in the register.
/// </summary>
/// <param name="model">The model containing the threat.</param>
/// <param name="threatId">The threat's register key, interaction key, or numeric id.</param>
Expand All @@ -267,15 +267,30 @@ public static Threat AddManual(
/// <param name="description">The new description, or <see langword="null"/> to leave it unchanged (empty clears it).</param>
/// <param name="mitigation">The new mitigation, or <see langword="null"/> to leave it unchanged (empty clears it).</param>
/// <param name="note">The new state note / justification, or <see langword="null"/> to leave it unchanged.</param>
/// <param name="title">
/// The new title, or <see langword="null"/> to leave it unchanged. Empty clears an override on a
/// generated threat, restoring the title its rule produces.
/// </param>
/// <param name="category">
/// The new category for a manually authored threat, or <see langword="null"/> to leave it
/// unchanged.
/// </param>
/// <returns><see langword="true"/> if a matching threat was found and edited; otherwise <see langword="false"/>.</returns>
/// <exception cref="ArgumentException">
/// <paramref name="category"/> was supplied for a rule-derived threat, whose category belongs to
/// the rule that detected it. Callers that surface errors to a person should check first rather
/// than rely on the exception text.
/// </exception>
public static bool Edit(
ThreatModel model,
string threatId,
ThreatState? state,
string? priority,
string? description,
string? mitigation,
string? note)
string? note,
string? title = null,
string? category = null)
{
if (model == null)
{
Expand All @@ -293,11 +308,29 @@ public static bool Edit(
return false;
}

bool manual = ManualThreatId.IsManual(threat.InteractionKey);
if (category != null && !manual)
{
throw new ArgumentException(
"The category of a generated threat belongs to the rule that detected it.",
nameof(category));
}

if (state.HasValue)
{
threat.State = state.Value;
}

if (title != null)
{
ApplyTitle(threat, title, manual);
}

if (category != null)
{
threat.UserThreatCategory = category;
}

if (!string.IsNullOrWhiteSpace(priority))
{
threat.Priority = priority;
Expand Down Expand Up @@ -622,14 +655,16 @@ private static void UpdateThreat(Threat existing, GeneratedThreat threat)
{
bool priorityOverridden = HasPriorityOverride(existing, threat);
string? overriddenPriority = priorityOverridden ? existing.Priority : null;
bool titleOverridden = HasTitleOverride(existing);
string? overriddenTitle = titleOverridden ? existing.Title : null;
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.Title = overriddenTitle ?? threat.Title;
existing.Priority = overriddenPriority ?? threat.Priority;
existing.UserThreatCategory = threat.ThreatCategory.Name;
existing.Properties = BuildProperties(threat);
Expand All @@ -638,20 +673,26 @@ private static void UpdateThreat(Threat existing, GeneratedThreat threat)
existing.Properties["PriorityOverride"] = "true";
}

if (titleOverridden)
{
existing.Properties["TitleOverride"] = "true";
}

existing.ModifiedAt = DateTime.UtcNow;
}

private static void HydrateThreat(Threat existing, GeneratedThreat threat)
{
bool priorityOverridden = HasPriorityOverride(existing, threat);
bool titleOverridden = HasTitleOverride(existing);
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 = FirstNonEmpty(existing.Title, threat.Title);
existing.Title = titleOverridden ? existing.Title : FirstNonEmpty(existing.Title, threat.Title);
existing.Priority = priorityOverridden ? existing.Priority : threat.Priority;
existing.UserThreatCategory = threat.ThreatCategory.Name;

Expand All @@ -669,6 +710,11 @@ private static void HydrateThreat(Threat existing, GeneratedThreat threat)
generatedProperties["PriorityOverride"] = "true";
}

if (titleOverridden)
{
generatedProperties["TitleOverride"] = "true";
}

existing.Properties = generatedProperties;
}

Expand All @@ -682,6 +728,11 @@ private static Dictionary<string, string> BuildProperties(GeneratedThreat threat
["Rule"] = threat.RuleId,
["CategoryId"] = threat.ThreatCategory.Id,
["GeneratedDefaultPriority"] = threat.Priority,

// Stashed for the same reason as the default priority: it is the only way to tell an
// author's title from the rule's on a later run, including one where the title was
// changed in another tool that cannot write tmforge's override marker.
["GeneratedDefaultTitle"] = threat.Title,
};
if (threat.Stride.HasValue)
{
Expand Down Expand Up @@ -730,11 +781,85 @@ private static bool HasPriorityOverride(Threat existing, GeneratedThreat generat
StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Applies a title edit, recording that the title is now the author's.
/// </summary>
/// <remarks>
/// Clearing a generated threat's title restores the rule's own wording from the stashed
/// default, which is what makes the edit reversible: an author who changes their mind gets the
/// rule's title back rather than an empty one. A manual threat has no rule to fall back to, so
/// its caller refuses a blank title before reaching here.
/// </remarks>
/// <param name="threat">The threat being edited.</param>
/// <param name="title">The requested title; empty clears an override.</param>
/// <param name="manual">Whether the threat is manually authored.</param>
private static void ApplyTitle(Threat threat, string title, bool manual)
{
if (title.Trim().Length > 0)
{
threat.Title = title;
if (!manual)
{
threat.Properties ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
threat.Properties["TitleOverride"] = "true";
}

return;
}

if (manual)
{
return;
}

threat.Properties?.Remove("TitleOverride");
if (threat.Properties?.TryGetValue("GeneratedDefaultTitle", out string? generated) == true &&
!string.IsNullOrEmpty(generated))
{
threat.Title = generated;
}
}

/// <summary>
/// Reports whether an existing threat's title is the author's rather than the rule's.
/// </summary>
/// <remarks>
/// Mirrors <see cref="HasPriorityOverride"/>. The marker is the fast path for a title tmforge
/// applied itself; comparing against the stashed generated default is what catches a title
/// changed in another tool, which cannot write the marker. Without this, regeneration would
/// quietly restore the rule's wording over the author's.
/// </remarks>
/// <param name="existing">The threat already in the register.</param>
/// <returns><see langword="true"/> when the title is author-owned.</returns>
private static bool HasTitleOverride(Threat existing)
{
if (existing.Properties?.TryGetValue("TitleOverride", out string? marker) == true &&
string.Equals(marker, "true", StringComparison.OrdinalIgnoreCase))
{
return true;
}

if (string.IsNullOrWhiteSpace(existing.Title))
{
return false;
}

if (existing.Properties?.TryGetValue("GeneratedDefaultTitle", out string? previousDefault) == true)
{
return !string.Equals(existing.Title, previousDefault, StringComparison.Ordinal);
}

// No stashed default means this entry predates the override contract. Claiming an override
// would freeze the rule's own wording as though an author had chosen it.
return false;
}

private static bool IsRuleOwnedProperty(string name)
{
return string.Equals(name, "Rule", StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, "CategoryId", StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, "GeneratedDefaultPriority", StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, "GeneratedDefaultTitle", StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, "STRIDE", StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, "References", StringComparison.OrdinalIgnoreCase);
}
Expand Down
Loading
Loading