Skip to content

feat: consumer-pluggable custom analysis rules via a declarative JSON DSL#23

Merged
Hacks4Snacks merged 1 commit into
mainfrom
hacks4snacks/pluggablerules
Jul 8, 2026
Merged

feat: consumer-pluggable custom analysis rules via a declarative JSON DSL#23
Hacks4Snacks merged 1 commit into
mainfrom
hacks4snacks/pluggablerules

Conversation

@Hacks4Snacks

@Hacks4Snacks Hacks4Snacks commented Jul 8, 2026

Copy link
Copy Markdown
Owner

feat: consumer-pluggable custom analysis rules via a declarative JSON DSL

What & why

This PR makes the analysis rule set consumer-pluggable: teams can ship their own rules as data a declarative *.tmrules.json spec and load them with --rules, without writing or compiling code. The durable move comes first: the reflection-based rule host already existed, but the rules assembly was hardcoded in five separate load sites, so the PR centralizes all five behind one seam before exposing anything. Custom rules are additive (evaluated alongside the full built-in set, never a replacement), and there are no wire-contract or engine-DTO changes the shared EngineService facade signature and the /v1 OpenAPI are unchanged. We went DSL-first rather than loading arbitrary rule assemblies because a spec is inspectable data: safe to share and review, and portable to hosts with no filesystem.

Highlights

  • Centralized rule seam (the durable move): one AnalysisRuleSources.Create() now sits behind all five previously-hardcoded Assembly.Load("ThreatModelForge.Analysis.Rules") sites — analyze, the shared engine facade (/v1 + WASM + Studio), threats, the knowledge-base catalog, and properties. Default behavior is byte-identical; this removes the duplication that would otherwise make custom rules land inconsistently across surfaces.
  • Declarative rule DSL: rules are authored as data in *.tmrules.json — a when guard plus an assert requirement over one DFD primitive's typed properties, extended with flow relations (crossesTrustBoundary, and source/target endpoint kind + property). DeclarativeRule reuses the exact model helpers the built-in rules use, so "crosses a boundary" has a single implementation.
  • --rules on the CLI: analyze, threats, and properties accept --rules <file-or-dir> (a spec file or a directory searched recursively). Custom rules are appended to the built-ins; a stride-bearing custom rule also becomes a threats threat.
  • Own id namespace: a new Rule(string id, ...) constructor lets third-party rules use their own prefix (e.g. ACME001) instead of the built-in TM####, so ids never collide in SARIF, suppressions, or JSON; a colliding id is dropped with a warning (the built-in wins).
  • Safe, resilient loading: RuleSet.LoadDefault is hardened to skip abstract / no-constructor rule types and survive bad assemblies; malformed specs and invalid rules are reported to stderr and skipped (never fatal); property names are validated against the typed schema, so a typo like Encryption vs Encrypted is flagged instead of silently never matching.
  • Consistent across surfaces: because the seam is centralized, a custom rule that reads a property also appears in properties --explain right next to the built-ins.
  • CLI-only, by design (and documented): /v1 and the in-browser WASM engine load the built-ins only — the shared engine facade is stateless with no per-request rule-source channel, and WASM has no filesystem for the spec loader; opening this over a shared service is a deferred, security-sensitive change.
  • Tests: 17 new DSL evaluation (incl. boundary-crossing and endpoint relations), provider validation, loader hardening, resolver merge/duplicate-drop, and an analyze --rules end-to-end.


private static (int Exit, string Stdout) Run(string[] args)
{
StringWriter outWriter = new StringWriter();
private static (int Exit, string Stdout) Run(string[] args)
{
StringWriter outWriter = new StringWriter();
StringWriter errorWriter = new StringWriter();
Comment on lines +100 to +107
foreach (DeclarativeRuleSpec spec in parsed.Rules)
{
Rule? rule = Compile(spec, file, diagnostics);
if (rule != null)
{
rules.Add(rule);
}
}

private static string WriteSpec(string json)
{
string path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".tmrules.json");

private static MockMessageWriter Evaluate(string specJson, ThreatModel model)
{
string path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".tmrules.json");
[TestInitialize]
public void Initialize()
{
this.WorkingDirectory = Path.Combine(Path.GetTempPath(), "tmforge-rules-" + Guid.NewGuid().ToString("N"));

private string WriteSpec(string json)
{
string path = Path.Combine(this.WorkingDirectory, Guid.NewGuid().ToString("N") + ".tmrules.json");
[TestInitialize]
public void Initialize()
{
this.WorkingDirectory = Path.Combine(Path.GetTempPath(), "tmforge-customrules-" + Guid.NewGuid().ToString("N"));

private string Write(string fileName, string content)
{
string path = Path.Combine(this.WorkingDirectory, fileName);
@Hacks4Snacks Hacks4Snacks merged commit 7e0b3b1 into main Jul 8, 2026
9 checks passed
This was referenced Jul 8, 2026
@Hacks4Snacks Hacks4Snacks deleted the hacks4snacks/pluggablerules branch July 9, 2026 00:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants