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
80 changes: 80 additions & 0 deletions docs/analysis-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,86 @@ formats (for example `.tm7`) use the full rule set unless you pass an explicit `
tmforge analyze model.tm7 --ruleset ./my-ruleset.xml
```

### Authoring custom rules (declarative)

Ship your own rules as **data**, not code. A declarative rule spec is a JSON file (`*.tmrules.json`)
loaded with `--rules` on [`analyze`](cli-reference.md#analyze), [`threats`](cli-reference.md#threats),
and [`properties`](cli-reference.md#properties):

```bash
tmforge analyze model.tm7 --rules ./rules.tmrules.json # one spec file
tmforge analyze model.tm7 --rules ./rules/ # a directory of specs (searched recursively)
```

Because a spec is inspectable data — not an assembly — it is safe to share and review, and it runs
everywhere the CLI does. Custom rules are **added to** the built-in rules, never a replacement for
them: `--rules` loads your rules *alongside* the full built-in set and both are evaluated together
(custom rules show up in findings, SARIF, `analyze --json`, and — when they read a property — in
`properties --explain`). To narrow the built-ins, use the existing controls independently of
`--rules`: a model's embedded `disabledPacks`, an `--ruleset` override, or `--max-severity`.

A spec is a `rules` array. A finding is raised for each element of `appliesTo` that matches `when`
(the guard) and fails `assert` (the requirement); at least one of `when`/`assert` is required:

```jsonc
{
"rules": [
{
"id": "ACME001", // your own id namespace (built-ins use TM####)
"pack": "acme-governance",
"severity": "error", // error | warning | info (default: warning)
"appliesTo": "datastore", // process | datastore | external | flow
"message": "Data store {name} does not declare encryption at rest.",
"fullDescription": "Persisted data must be encrypted at rest.",
"helpText": "Set Encrypted to At-rest, TDE, Client-side, or Platform.",
"assert": { "property": "Encrypted", "notAnyOf": ["No"] }
},
{
"id": "ACME002",
"pack": "acme-governance",
"severity": "warning",
"appliesTo": "flow",
"message": "Flow {name} carries sensitive data in the clear across a trust boundary.",
"stride": "InformationDisclosure", // optional; makes it a `threats` threat
"threatReferences": ["CWE:319"], // optional: CWE:<n> | CAPEC:<n> | ATTACK:<id>
"when": { "property": "DataType", "anyOf": ["EUII", "Customer Content"], "crossesTrustBoundary": true },
"assert": { "property": "Protocol", "anyOf": ["HTTPS", "TLS", "mTLS"] }
}
]
}
```

The `{name}` token in `message` is replaced with the element's display text. **Conditions** (`when`
and `assert`) are facets that must *all* hold; a bare `property` with no value matcher means "must be
present":

| Facet | Applies to | Meaning |
| --- | --- | --- |
| `property` + `anyOf` | any | The value is one of the listed values. |
| `property` + `notAnyOf` | any | The value is none of the listed values. |
| `property` + `equals` | any | The value equals a single value. |
| `property` + `present` | any | The property is present (`true`) or absent (`false`). |
| `crossesTrustBoundary` | `flow` | The flow crosses (`true`) or does not cross (`false`) a trust boundary. |
| `source` / `target` | `flow` | A condition on the flow's endpoint: its `kind` (`process`/`datastore`/`external`) and/or a property matcher. |

- **Ids are your namespace.** A custom rule whose id collides with an already-loaded rule is dropped
with a warning, so the built-in `TM####` namespace always wins (ids appear in SARIF and suppressions).
- **Property names are validated** against the typed [property schema](cli-reference.md#properties).
An unknown property is a warning, not an error — but it catches a typo (`Encryption` vs `Encrypted`)
that would otherwise make a rule silently never match.
- **Resilient loading.** A malformed spec file or an individual invalid rule is reported to standard
error and skipped; the rest still load.
- **Threats.** A custom rule that declares a `stride` category is projected into
[`threats`](cli-reference.md#threats) exactly like a built-in threat-bearing rule.
- **CLI only (and why).** `--rules` works on the CLI; the HTTP API (`/v1`) and the in-browser
(WebAssembly) engine load the built-in rules only. This is deliberate, not an oversight: (1) those
hosts share a **stateless** engine facade — a model in, findings out — with no per-request channel
for selecting rule sources; (2) the **WebAssembly host has no filesystem**, so the file/directory
loader behind `--rules` cannot read spec files there; and (3) loading rules over a shared service is
a security-sensitive contract change (in-memory rule injection, and treating rule-loading as a
privileged action) deferred to a later increment. The rule engine itself is portable the
limitation is the injection surface, not the DSL.

### Rule variables

Some rules read variables supplied on the command line (repeatable):
Expand Down
11 changes: 7 additions & 4 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ serves at `GET /v1/property-schema`; use it to discover closed enums (for exampl
`Encrypted`, `AccessControl`, or the approved cipher list) without running `analyze` first.

```text
tmforge properties [--base <process|datastore|external|flow>] [--explain] [--json]
tmforge properties [--base <process|datastore|external|flow>] [--explain] [--rules <path>] [--json]
```

```bash
Expand All @@ -136,7 +136,8 @@ 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 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.
rule fires when the property is absent or by a computed condition. Pass `--rules <path>` to fold your
[custom declarative rules](analysis-rules.md#authoring-custom-rules-declarative) into the explanation.

```bash
tmforge properties --base flow --explain
Expand Down Expand Up @@ -465,12 +466,13 @@ Evaluate the analysis rules against the model. See [Analysis rules & CI](analysi
full rule catalog, packs, and suppressions.

```text
tmforge analyze [--ruleset <path>] [--suppressionFile <path>] [--reportFolder <dir>] [--define name=value ...] [--max-severity <level>] [--json] <model>
tmforge analyze [--ruleset <path>] [--rules <path>] [--suppressionFile <path>] [--reportFolder <dir>] [--define name=value ...] [--max-severity <level>] [--json] <model>
```

| Option | Meaning |
| --- | --- |
| `--ruleset <path>` | Use a custom rule set instead of the built-in default. |
| `--rules <path>` | Load custom [declarative rules](analysis-rules.md#authoring-custom-rules-declarative) from a `*.tmrules.json` file (or a directory of them) in addition to the built-in rules. |
| `--suppressionFile <path>` | Apply a suppression document to filter findings. |
| `--reportFolder <dir>` | Also write SARIF + HTML findings reports (and a JSON listing) to `<dir>`. |
| `--define name=value` | Repeatable. Supplies a rule variable. |
Expand Down Expand Up @@ -507,12 +509,13 @@ persisted and triaged (`open -> mitigated -> accepted`). With `--write`, the thr
the model's register, keyed so a re-run updates in place and never overwrites prior triage.

```text
tmforge threats [--write] [--json] <model>
tmforge threats [--write] [--rules <path>] [--json] <model>
```

| Option | Meaning |
| --- | --- |
| `--write` | Persist the threats into the model's register (preserves prior triage). |
| `--rules <path>` | Also project threats from custom [declarative rules](analysis-rules.md#authoring-custom-rules-declarative); a custom rule that declares a `stride` category becomes a threat. |

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
Expand Down
67 changes: 67 additions & 0 deletions src/ThreatModelForge.Analysis/AnalysisRuleSources.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
namespace ThreatModelForge.Analysis
{
using System;
using System.Collections.Generic;
using System.Reflection;

/// <summary>
/// The single place that assembles a <see cref="RuleSet"/> from every rule source: the built-in
/// first-party rules (always) plus any opt-in declarative rules. Every load site — the CLI, the HTTP
/// API, the in-browser engine, threat generation, and the property policy — resolves rules through
/// here, so a custom rule is seen everywhere or nowhere, and there is exactly one place that names
/// the built-in rules assembly.
/// </summary>
public static class AnalysisRuleSources
{
private const string BuiltInRulesAssembly = "ThreatModelForge.Analysis.Rules";

/// <summary>
/// Gets the built-in first-party rule assemblies. This is the only place the built-in rules
/// assembly is named; every load site resolves it here.
/// </summary>
/// <returns>The built-in rule assemblies.</returns>
public static IReadOnlyList<Assembly> BuiltInAssemblies()
{
return new[] { Assembly.Load(BuiltInRulesAssembly) };
}

/// <summary>
/// Creates a rule set containing the built-in rules plus any declarative rules selected by
/// <paramref name="options"/>. A custom rule whose id collides with an already-loaded rule is
/// reported through the options' diagnostics sink and dropped, so the built-in <c>TM####</c>
/// namespace always wins and ids stay unique across SARIF, suppressions, and JSON output.
/// </summary>
/// <param name="options">The opt-in rule sources, or <see langword="null"/> for built-in rules only.</param>
/// <returns>A new rule set. The caller owns and disposes it.</returns>
public static RuleSet Create(RuleSourceOptions? options = null)
{
Action<string>? diagnostics = options?.Diagnostics;
RuleSet ruleSet = RuleSet.LoadDefault(BuiltInAssemblies(), diagnostics);

if (options == null || options.SpecPaths.Count == 0)
{
return ruleSet;
}

HashSet<string> ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Rule existing in ruleSet.Rules)
{
ids.Add(existing.ID);
}

foreach (Rule rule in DeclarativeRuleProvider.Load(options.SpecPaths, diagnostics))
{
if (!ids.Add(rule.ID))
{
diagnostics?.Invoke($"Skipped custom rule '{rule.ID}': a rule with that id is already loaded.");
rule.Dispose();
continue;
}

ruleSet.Rules.Add(rule);
}

return ruleSet;
}
}
}
39 changes: 39 additions & 0 deletions src/ThreatModelForge.Analysis/DeclarativeCondition.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace ThreatModelForge.Analysis
{
using System.Collections.Generic;
using System.Text.Json.Serialization;

/// <summary>
/// A condition over an element. Every specified facet must hold (logical AND). A bare
/// <see cref="Property"/> with no value matcher is treated as "must be present". The relational
/// facets — <see cref="CrossesTrustBoundary"/>, <see cref="Source"/>, and <see cref="Target"/> — are
/// only valid on a rule whose <c>appliesTo</c> is <c>flow</c>.
/// </summary>
internal sealed class DeclarativeCondition
{
/// <summary>Gets or sets the custom-property name the condition reads on the element.</summary>
public string? Property { get; set; }

/// <summary>Gets or sets the values that satisfy the condition; the property must equal one of them.</summary>
public List<string>? AnyOf { get; set; }

/// <summary>Gets or sets the values that violate the condition; the property must equal none of them.</summary>
public List<string>? NotAnyOf { get; set; }

/// <summary>Gets or sets the single value the property must equal.</summary>
[JsonPropertyName("equals")]
public string? EqualTo { get; set; }

/// <summary>Gets or sets whether the property must be present (<c>true</c>) or absent (<c>false</c>).</summary>
public bool? Present { get; set; }

/// <summary>Gets or sets whether the flow must cross a trust boundary (<c>true</c>) or not (<c>false</c>).</summary>
public bool? CrossesTrustBoundary { get; set; }

/// <summary>Gets or sets a condition on the element at the flow's source end.</summary>
public DeclarativeEndpoint? Source { get; set; }

/// <summary>Gets or sets a condition on the element at the flow's target end.</summary>
public DeclarativeEndpoint? Target { get; set; }
}
}
31 changes: 31 additions & 0 deletions src/ThreatModelForge.Analysis/DeclarativeEndpoint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace ThreatModelForge.Analysis
{
using System.Collections.Generic;
using System.Text.Json.Serialization;

/// <summary>
/// A condition on the element at one end of a flow, resolved through the flow's source or target
/// reference. Every specified facet must hold (logical AND).
/// </summary>
internal sealed class DeclarativeEndpoint
{
/// <summary>Gets or sets the required kind of the endpoint element (<c>process</c>, <c>datastore</c>, or <c>external</c>).</summary>
public string? Kind { get; set; }

/// <summary>Gets or sets the custom-property name to read on the endpoint element.</summary>
public string? Property { get; set; }

/// <summary>Gets or sets the values that satisfy the condition; the property must equal one of them.</summary>
public List<string>? AnyOf { get; set; }

/// <summary>Gets or sets the values that violate the condition; the property must equal none of them.</summary>
public List<string>? NotAnyOf { get; set; }

/// <summary>Gets or sets the single value the property must equal.</summary>
[JsonPropertyName("equals")]
public string? EqualTo { get; set; }

/// <summary>Gets or sets whether the property must be present (<c>true</c>) or absent (<c>false</c>).</summary>
public bool? Present { get; set; }
}
}
Loading
Loading