diff --git a/docs/analysis-rules.md b/docs/analysis-rules.md index 370d07b..aee6d11 100644 --- a/docs/analysis-rules.md +++ b/docs/analysis-rules.md @@ -454,8 +454,102 @@ present": | `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`). | +| `property` + numeric bounds | any | Invariant-decimal `greaterThan`, `greaterThanOrEqual`, `lessThan`, or `lessThanOrEqual`. All supplied bounds must hold. | +| `property` + `matches` | any | The non-blank value matches a bounded .NET regular expression. | | `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. | +| `reachableFrom` | component | A matching component has a directed path of one or more edges to this component. | +| `connectsTo` | component | This component has a direct outgoing connector to a matching component. | + +#### Numeric and regex predicates + +Property matchers also work inside `source`/`target` and connectivity filters. For example: + +```json +{ + "id": "RETENTION", + "appliesTo": "datastore", + "message": "{name} must declare retention between 1 and 30 days.", + "assert": { "property": "RetentionDays", "greaterThanOrEqual": 1, "lessThanOrEqual": 30 } +} +``` + +Numeric bounds are JSON numbers within `System.Decimal` range, not quoted strings. Model values +use invariant decimal parsing: `.` is the decimal separator; signs, surrounding whitespace, and +exponents are allowed; thousands separators are not. Decimal precision and rounding apply. Absent, +blank, `Unknown`, non-numeric, overflowing, or longer-than-256-character values do not match. +Consequently a numeric `when` skips them, while a numeric `assert` reports the missing requirement. +Contradictory bounds reject the pack. To require numeric equality, use equal inclusive lower and +upper bounds; `equals` continues to compare strings. + +`matches` searches the value rather than requiring a whole-string match. Use anchors when needed, +for example `"matches": "^svc-[a-z0-9-]+$"`. Matching is case-sensitive and culture-invariant; +inline `(?i)` enables case-insensitive matching. Missing and blank values never match, even `.*`. +`Unknown` is an ordinary recorded string and can match a permissive pattern; do not treat `.*` +as evidence of a control. + +Patterns are prepared once per distinct pattern per load, using the .NET interpreter (no dynamic +code generation). Limits are 1,024 pattern characters, 128 distinct patterns per load, 4,096 input +characters, and a 50 ms match timeout. Regex evaluation shares a 1,000 ms budget across the entire +analysis, in addition to the declarative operation budget. Pattern construction has a 250 ms +per-pattern and 1,000 ms shared load budget, checked after each constructor returns: the .NET +constructor is not cancellable, so these are not preemptive compilation timeouts. Pattern length +and count also bound that work. Invalid patterns reject the whole containing pack with a rule-local +diagnostic. Evaluation timeouts and oversized input abort analysis with an error, never `false`; +`not` cannot turn a timeout into evidence that a requirement holds. + +Use a version 2 pack for new matchers: an older engine rejects unknown v2 fields rather than silently +ignoring them as extensions in an unversioned pack. Existing legacy string predicates are unchanged. + +The interaction dialect accepts `{"subject":"flow","property":"Port","greaterThan":1024}` +or `{"subject":"source","property":"ServiceName","matches":"^svc-"}`. Choose exactly one +property matcher family per interaction leaf (`valueIn`, numeric bounds, or `matches`); combine +families using `allOf`. Interaction property predicates match any stored value, with all numeric +bounds applied to the same value. Flat rules and endpoint/connectivity filters retain their +first-value behavior. + +#### Directed connectivity + +Connectivity selectors require `kind`, `property`, or both, and reuse endpoint property matchers: + +```json +{ + "id": "AUDIT-PATH", + "appliesTo": "process", + "message": "{name} is externally reachable but lacks a direct audit-store connection.", + "when": { "reachableFrom": { "kind": "external" } }, + "assert": { "connectsTo": { "kind": "datastore", "property": "StoresLogData", "equals": "Yes" } } +} +``` + +`reachableFrom` walks incoming connectors to find an upstream match; `connectsTo` checks one outgoing +edge only. There is no implicit zero-hop match. An explicit self-loop or cycle can establish a +positive-length path back to the same component. Parallel connectors do not duplicate findings. +Traversal stays on the candidate's page. Rectangular and line trust boundaries do not block it: +a boundary documents a trust transition, not an enforced network policy. Boundaries and annotations +are not graph vertices, and connectors ending on them do not create component paths. Dangling or +cross-page connectors still present in the loaded model cause a diagnostic when the graph is built. +This is not a raw-file topology validator: the existing canonical JSON reader drops flows whose +endpoints cannot be resolved on their page before analysis, so those flows never reach this guard. +Connectivity describes the loaded model; it does not recover omitted or malformed links. + +For per-flow rules, use interaction leaves such as +`{"subject":"target","reachableFrom":{"kind":"external"}}` or +`{"subject":"source","connectsTo":{"kind":"datastore"}}`. Primitive-kind filters are also +available as `{"subject":"target","kind":"datastore"}` without a type catalog entry. +These predicates accept `source`/`target`, not the flow itself. Flat connectivity predicates require +a component `appliesTo` (`process`, `datastore`, or `external`). Filters cannot recursively contain +connectivity predicates. + +One lazy, page-partitioned adjacency index is shared across all rules in an evaluation. The model's +topology must remain fixed for that evaluation context. Index construction is linear; each +reachability query visits at most the page's vertices and edges, without recursion. Index building, +edge visits, and filter evaluation charge the shared operation budget; interaction predicates also +charge their per-rule budget. Graph construction allows at most 1,024 pages, 100,000 shapes, and +200,000 lines across the model. Repeated queries remain bounded by the invocation budget rather +than allocating an all-pairs reachability table. + +#### Compatibility and controls - **Assert what a control *is*, not what it is not.** `anyOf` and `equals` compare exact strings, so `Unknown` satisfies them only if you list it. `notAnyOf` is a denylist: `{"property": "Encrypted", @@ -476,14 +570,10 @@ present": an invalid envelope/catalog is skipped rather than partially interpreted. - **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. +- **Every engine surface.** These matchers use the same evaluator for CLI, API, WASM/Studio, and + MCP. Rule sources still follow each host's existing policy: CLI paths, MCP sandboxed paths, + trusted API startup configuration, or in-memory content on WASM. No per-request API rule injection + is added. See [Custom rules on every surface](#custom-rules-on-every-surface). ### Rule variables diff --git a/src/ThreatModelForge.Analysis/DeclarativeCondition.cs b/src/ThreatModelForge.Analysis/DeclarativeCondition.cs index 8c3c7b8..b65253b 100644 --- a/src/ThreatModelForge.Analysis/DeclarativeCondition.cs +++ b/src/ThreatModelForge.Analysis/DeclarativeCondition.cs @@ -2,6 +2,7 @@ namespace ThreatModelForge.Analysis { using System.Collections.Generic; using System.Text.Json.Serialization; + using System.Text.RegularExpressions; /// /// A condition over an element. Every specified facet must hold (logical AND). A bare @@ -27,6 +28,21 @@ internal sealed class DeclarativeCondition /// Gets or sets whether the property must be present (true) or absent (false). public bool? Present { get; set; } + /// Gets or sets the exclusive numeric lower bound. + public decimal? GreaterThan { get; set; } + + /// Gets or sets the inclusive numeric lower bound. + public decimal? GreaterThanOrEqual { get; set; } + + /// Gets or sets the exclusive numeric upper bound. + public decimal? LessThan { get; set; } + + /// Gets or sets the inclusive numeric upper bound. + public decimal? LessThanOrEqual { get; set; } + + /// Gets or sets the regular expression the property must match. + public string? Matches { get; set; } + /// Gets or sets whether the flow must cross a trust boundary (true) or not (false). public bool? CrossesTrustBoundary { get; set; } @@ -35,5 +51,14 @@ internal sealed class DeclarativeCondition /// Gets or sets a condition on the element at the flow's target end. public DeclarativeEndpoint? Target { get; set; } + + /// Gets or sets a filter for a component with a positive-length directed path to this component. + public DeclarativeEndpoint? ReachableFrom { get; set; } + + /// Gets or sets a filter for a component reached by one outgoing connector. + public DeclarativeEndpoint? ConnectsTo { get; set; } + + /// Gets or sets the pattern prepared during pack validation. + internal Regex? CompiledPattern { get; set; } } } diff --git a/src/ThreatModelForge.Analysis/DeclarativeEndpoint.cs b/src/ThreatModelForge.Analysis/DeclarativeEndpoint.cs index 9e8eea0..4e83cc4 100644 --- a/src/ThreatModelForge.Analysis/DeclarativeEndpoint.cs +++ b/src/ThreatModelForge.Analysis/DeclarativeEndpoint.cs @@ -2,6 +2,7 @@ namespace ThreatModelForge.Analysis { using System.Collections.Generic; using System.Text.Json.Serialization; + using System.Text.RegularExpressions; /// /// A condition on the element at one end of a flow, resolved through the flow's source or target @@ -27,5 +28,23 @@ internal sealed class DeclarativeEndpoint /// Gets or sets whether the property must be present (true) or absent (false). public bool? Present { get; set; } + + /// Gets or sets the exclusive numeric lower bound. + public decimal? GreaterThan { get; set; } + + /// Gets or sets the inclusive numeric lower bound. + public decimal? GreaterThanOrEqual { get; set; } + + /// Gets or sets the exclusive numeric upper bound. + public decimal? LessThan { get; set; } + + /// Gets or sets the inclusive numeric upper bound. + public decimal? LessThanOrEqual { get; set; } + + /// Gets or sets the regular expression the property must match. + public string? Matches { get; set; } + + /// Gets or sets the pattern prepared during pack validation. + internal Regex? CompiledPattern { get; set; } } } diff --git a/src/ThreatModelForge.Analysis/DeclarativeRule.cs b/src/ThreatModelForge.Analysis/DeclarativeRule.cs index 407b57f..1f6524e 100644 --- a/src/ThreatModelForge.Analysis/DeclarativeRule.cs +++ b/src/ThreatModelForge.Analysis/DeclarativeRule.cs @@ -3,6 +3,7 @@ namespace ThreatModelForge.Analysis using System; using System.Collections.Generic; using System.Linq; + using System.Text.RegularExpressions; using ThreatModelForge.Model; using ThreatModelForge.Model.Abstracts; @@ -138,6 +139,45 @@ public override void Evaluate(RuleEvaluationContext context) } } + /// Lowers a component filter into the common expression tree. + /// The kind and property filter. + /// The subject evaluated by the filter. + /// The immutable filter. + internal static InteractionExpression CompileEndpoint( + DeclarativeEndpoint endpoint, + string subject) + { + List expressions = new List + { + InteractionExpression.SubjectExists(subject), + }; + + if (endpoint.Kind != null) + { + expressions.Add(InteractionExpression.KindIs(subject, endpoint.Kind)); + } + + if (endpoint.Property != null) + { + AddPropertyExpressions( + expressions, + subject, + endpoint.Property, + endpoint.AnyOf, + endpoint.NotAnyOf, + endpoint.EqualTo, + endpoint.Present, + endpoint.GreaterThan, + endpoint.GreaterThanOrEqual, + endpoint.LessThan, + endpoint.LessThanOrEqual, + endpoint.CompiledPattern, + endpoint.Kind); + } + + return Conjunction(expressions, subject); + } + private static InteractionExpression.EvaluationContext CreateEvaluationContext( DrawingSurfaceModel diagram, Entity element) @@ -192,7 +232,12 @@ private static InteractionExpression CompileCondition( condition.AnyOf, condition.NotAnyOf, condition.EqualTo, - condition.Present); + condition.Present, + condition.GreaterThan, + condition.GreaterThanOrEqual, + condition.LessThan, + condition.LessThanOrEqual, + condition.CompiledPattern); } if (condition.CrossesTrustBoundary.HasValue) @@ -213,36 +258,19 @@ private static InteractionExpression CompileCondition( expressions.Add(CompileEndpoint(condition.Target, "target")); } - return Conjunction(expressions, candidateSubject); - } - - private static InteractionExpression CompileEndpoint( - DeclarativeEndpoint endpoint, - string subject) - { - List expressions = new List - { - InteractionExpression.SubjectExists(subject), - }; - - if (endpoint.Kind != null) + if (condition.ReachableFrom != null) { - expressions.Add(InteractionExpression.KindIs(subject, endpoint.Kind)); + expressions.Add(InteractionExpression.Connectivity( + candidateSubject, CompileEndpoint(condition.ReachableFrom, "source"), incoming: true)); } - if (endpoint.Property != null) + if (condition.ConnectsTo != null) { - AddPropertyExpressions( - expressions, - subject, - endpoint.Property, - endpoint.AnyOf, - endpoint.NotAnyOf, - endpoint.EqualTo, - endpoint.Present); + expressions.Add(InteractionExpression.Connectivity( + candidateSubject, CompileEndpoint(condition.ConnectsTo, "source"), incoming: false)); } - return Conjunction(expressions, subject); + return Conjunction(expressions, candidateSubject); } private static void AddPropertyExpressions( @@ -252,7 +280,13 @@ private static void AddPropertyExpressions( IReadOnlyList? anyOf, IReadOnlyList? notAnyOf, string? equalTo, - bool? present) + bool? present, + decimal? greaterThan, + decimal? greaterThanOrEqual, + decimal? lessThan, + decimal? lessThanOrEqual, + Regex? pattern, + string? kind = null) { expressions.Add(InteractionExpression.FlatPropertyCondition( subject, @@ -260,7 +294,13 @@ private static void AddPropertyExpressions( anyOf, notAnyOf, equalTo, - present)); + present, + greaterThan, + greaterThanOrEqual, + lessThan, + lessThanOrEqual, + pattern, + kind)); } private static InteractionExpression Conjunction( @@ -300,17 +340,22 @@ private static void AddBinding(List bindings, string appliesTo, AddEndpointBinding(bindings, condition.Source); AddEndpointBinding(bindings, condition.Target); + AddEndpointBinding(bindings, condition.ReachableFrom); + AddEndpointBinding(bindings, condition.ConnectsTo); } private static void AddEndpointBinding(List bindings, DeclarativeEndpoint? endpoint) { - if (endpoint?.Property == null || endpoint.Kind == null) + if (endpoint?.Property == null) { return; } string[] flagged = (endpoint.NotAnyOf ?? new List()).ToArray(); - bindings.Add(new PropertyBinding(endpoint.Kind, endpoint.Property, flagged)); + foreach (string kind in endpoint.Kind == null ? new[] { "process", "datastore", "external" } : new[] { endpoint.Kind }) + { + bindings.Add(new PropertyBinding(kind, endpoint.Property, flagged)); + } } private IEnumerable Candidates(DrawingSurfaceModel diagram, RuleEvaluationContext context) diff --git a/src/ThreatModelForge.Analysis/DeclarativeRuleProvider.cs b/src/ThreatModelForge.Analysis/DeclarativeRuleProvider.cs index 8d82721..0e5c122 100644 --- a/src/ThreatModelForge.Analysis/DeclarativeRuleProvider.cs +++ b/src/ThreatModelForge.Analysis/DeclarativeRuleProvider.cs @@ -2,6 +2,7 @@ namespace ThreatModelForge.Analysis { using System; using System.Collections.Generic; + using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; @@ -9,6 +10,7 @@ namespace ThreatModelForge.Analysis using System.Text; using System.Text.Json; using System.Text.Json.Serialization; + using System.Text.RegularExpressions; using System.Xml; using ThreatModelForge.Editing; @@ -165,10 +167,11 @@ public static RuleBundle LoadBundle( } List documents = new List(); + RegexCompiler regexCompiler = new RegexCompiler(); long totalBytes = 0; foreach (string file in files) { - ParsedDocument? document = LoadFile(file, diagnostics, out int bytesRead); + ParsedDocument? document = LoadFile(file, diagnostics, regexCompiler, out int bytesRead); totalBytes += bytesRead; if (totalBytes > MaxTotalRuleBytes) { @@ -184,7 +187,7 @@ public static RuleBundle LoadBundle( foreach (RuleContent content in inline) { - ParsedDocument? document = LoadContent(content, diagnostics, out int bytesRead); + ParsedDocument? document = LoadContent(content, diagnostics, regexCompiler, out int bytesRead); totalBytes += bytesRead; if (totalBytes > MaxTotalRuleBytes) { @@ -358,7 +361,7 @@ public static IReadOnlyList ReadContents(IEnumerable paths, } string? endpointError = ValidateEndpointKinds(spec.When); - return endpointError ?? ValidateEndpointKinds(spec.Assert); + return endpointError ?? ValidateEndpointKinds(spec.Assert) ?? ValidateConnectivityConditions(spec); } private static string? ValidateVersionTwoRuleMetadata(DeclarativeRuleSpec spec) @@ -465,10 +468,13 @@ public static IReadOnlyList ReadContents(IEnumerable paths, return "interaction expression depth exceeds the limit of 64."; } + bool hasNumeric = expression.GreaterThan.HasValue || expression.GreaterThanOrEqual.HasValue || + expression.LessThan.HasValue || expression.LessThanOrEqual.HasValue; bool hasPredicate = expression.Subject != null || expression.Type != null || expression.Property != null || - expression.ValueIn != null; + expression.Kind != null || expression.ReachableFrom != null || expression.ConnectsTo != null || + expression.ValueIn != null || hasNumeric || expression.Matches != null; int shapes = (expression.AllOf != null ? 1 : 0) + (expression.AnyOf != null ? 1 : 0) + (expression.Not != null ? 1 : 0) + @@ -518,16 +524,45 @@ public static IReadOnlyList ReadContents(IEnumerable paths, return $"unknown interaction subject '{expression.Subject}'."; } - bool hasType = !string.IsNullOrWhiteSpace(expression.Type); - bool hasProperty = !string.IsNullOrWhiteSpace(expression.Property); - if (hasType == hasProperty) + int predicates = (expression.Type != null ? 1 : 0) + (expression.Property != null ? 1 : 0) + + (expression.Kind != null ? 1 : 0) + (expression.ReachableFrom != null ? 1 : 0) + (expression.ConnectsTo != null ? 1 : 0); + if (predicates != 1) { - return "interaction predicates require exactly one of type or property."; + return "interaction predicates require exactly one of type, kind, property, reachableFrom, or connectsTo."; } - if (hasProperty && (expression.ValueIn == null || expression.ValueIn.Count == 0)) + if (expression.Kind != null || expression.ReachableFrom != null || expression.ConnectsTo != null) { - return "interaction property predicates require valueIn."; + if (expression.Subject == "flow" || expression.ValueIn != null || hasNumeric || expression.Matches != null) + { + return "kind and connectivity predicates require source or target and cannot declare property matchers."; + } + + return expression.Kind != null + ? ValidateEndpointKind(new DeclarativeEndpoint { Kind = expression.Kind }) + : ValidateConnectivityFilter(expression.ReachableFrom ?? expression.ConnectsTo); + } + + bool hasType = expression.Type != null; + bool hasProperty = expression.Property != null; + if ((hasType && string.IsNullOrWhiteSpace(expression.Type)) || (hasProperty && string.IsNullOrWhiteSpace(expression.Property))) + { + return "interaction type and property names cannot be empty."; + } + + if (hasProperty && !hasNumeric && expression.Matches == null && (expression.ValueIn == null || expression.ValueIn.Count == 0)) + { + return "interaction property predicates require valueIn, numeric bounds, or matches."; + } + + if (expression.Matches != null && (hasType || hasNumeric || expression.ValueIn != null)) + { + return "regex predicates require property and cannot be combined with type, valueIn, or numeric bounds."; + } + + if (hasNumeric && (hasType || expression.ValueIn != null)) + { + return "numeric predicates require property and cannot be combined with type or valueIn."; } if (hasType && expression.ValueIn != null) @@ -535,9 +570,11 @@ public static IReadOnlyList ReadContents(IEnumerable paths, return "interaction type predicates cannot declare valueIn."; } - return expression.ValueIn?.Any(value => value == null) == true + string? numericError = ValidateNumericBounds( + expression.Property, expression.GreaterThan, expression.GreaterThanOrEqual, expression.LessThan, expression.LessThanOrEqual); + return numericError ?? (expression.ValueIn?.Any(value => value == null) == true ? "interaction valueIn cannot contain null values." - : null; + : null); } private static string? ValidateConditionShape(DeclarativeCondition? condition) @@ -561,7 +598,9 @@ public static IReadOnlyList ReadContents(IEnumerable paths, bool hasValueMatcher = condition.EqualTo != null || condition.Present.HasValue || condition.AnyOf != null || - condition.NotAnyOf != null; + condition.NotAnyOf != null || + condition.GreaterThan.HasValue || condition.GreaterThanOrEqual.HasValue || + condition.LessThan.HasValue || condition.LessThanOrEqual.HasValue || condition.Matches != null; if (hasValueMatcher && string.IsNullOrWhiteSpace(condition.Property)) { return "condition value matchers require property."; @@ -572,8 +611,10 @@ public static IReadOnlyList ReadContents(IEnumerable paths, return "condition matcher arrays cannot be empty."; } - string? sourceError = ValidateEndpointShape(condition.Source); - return sourceError ?? ValidateEndpointShape(condition.Target); + string? numericError = ValidateNumericBounds( + condition.Property, condition.GreaterThan, condition.GreaterThanOrEqual, condition.LessThan, condition.LessThanOrEqual); + return numericError ?? ValidateEndpointShape(condition.Source) ?? ValidateEndpointShape(condition.Target) ?? + ValidateConnectivityFilter(condition.ReachableFrom) ?? ValidateConnectivityFilter(condition.ConnectsTo); } private static string? ValidateEndpointShape(DeclarativeEndpoint? endpoint) @@ -586,7 +627,8 @@ public static IReadOnlyList ReadContents(IEnumerable paths, bool hasValueMatcher = endpoint?.EqualTo != null || endpoint?.Present.HasValue == true || endpoint?.AnyOf != null || - endpoint?.NotAnyOf != null; + endpoint?.NotAnyOf != null || endpoint?.GreaterThan != null || endpoint?.GreaterThanOrEqual != null || + endpoint?.LessThan != null || endpoint?.LessThanOrEqual != null || endpoint?.Matches != null; if (hasValueMatcher && string.IsNullOrWhiteSpace(endpoint?.Property)) { return "endpoint value matchers require property."; @@ -597,10 +639,164 @@ public static IReadOnlyList ReadContents(IEnumerable paths, return "endpoint matcher arrays cannot be empty."; } - return endpoint?.AnyOf?.Any(value => value == null) == true || + string? numericError = ValidateNumericBounds( + endpoint?.Property, endpoint?.GreaterThan, endpoint?.GreaterThanOrEqual, endpoint?.LessThan, endpoint?.LessThanOrEqual); + return numericError ?? (endpoint?.AnyOf?.Any(value => value == null) == true || endpoint?.NotAnyOf?.Any(value => value == null) == true ? "endpoint matchers cannot contain null values." - : null; + : null); + } + + private static string? PrepareMatchers(IReadOnlyList rules, RegexCompiler compiler, bool interaction) + { + foreach (DeclarativeRuleSpec rule in rules) + { + try + { + string? connectivityError = ValidateConnectivityConditions(rule); + if (connectivityError != null) + { + throw new InvalidDataException(connectivityError); + } + + PrepareCondition(rule.When, compiler); + PrepareCondition(rule.Assert, compiler); + if (interaction && rule.Expression != null) + { + PrepareExpression(rule.Expression, compiler); + } + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidDataException) + { + return $"rule '{rule.Id}': {ex.Message}"; + } + } + + return null; + } + + private static void PrepareCondition(DeclarativeCondition? condition, RegexCompiler compiler) + { + if (condition == null) + { + return; + } + + condition.CompiledPattern = PreparePropertyMatcher( + condition.Property, condition.GreaterThan, condition.GreaterThanOrEqual, condition.LessThan, condition.LessThanOrEqual, condition.Matches, compiler); + PrepareEndpoint(condition.Source, compiler); + PrepareEndpoint(condition.Target, compiler); + PrepareEndpoint(condition.ReachableFrom, compiler); + PrepareEndpoint(condition.ConnectsTo, compiler); + } + + private static void PrepareEndpoint(DeclarativeEndpoint? endpoint, RegexCompiler compiler) + { + if (endpoint != null) + { + endpoint.CompiledPattern = PreparePropertyMatcher( + endpoint.Property, endpoint.GreaterThan, endpoint.GreaterThanOrEqual, endpoint.LessThan, endpoint.LessThanOrEqual, endpoint.Matches, compiler); + } + } + + private static void PrepareExpression(DeclarativeRuleSpec.InteractionExpressionSpec expression, RegexCompiler compiler) + { + expression.CompiledPattern = PreparePropertyMatcher( + expression.Property, expression.GreaterThan, expression.GreaterThanOrEqual, expression.LessThan, expression.LessThanOrEqual, expression.Matches, compiler); + PrepareEndpoint(expression.ReachableFrom, compiler); + PrepareEndpoint(expression.ConnectsTo, compiler); + if (expression.Not != null) + { + PrepareExpression(expression.Not, compiler); + } + + foreach (DeclarativeRuleSpec.InteractionExpressionSpec child in + (expression.AllOf ?? Enumerable.Empty()) + .Concat(expression.AnyOf ?? Enumerable.Empty())) + { + PrepareExpression(child, compiler); + } + } + + private static Regex? PreparePropertyMatcher( + string? property, + decimal? greaterThan, + decimal? greaterThanOrEqual, + decimal? lessThan, + decimal? lessThanOrEqual, + string? matches, + RegexCompiler compiler) + { + string? error = ValidateNumericBounds(property, greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual); + if (error != null) + { + throw new InvalidDataException(error); + } + + return compiler.Prepare(property, matches); + } + + private static string? ValidateConnectivityConditions(DeclarativeRuleSpec rule) + { + foreach (DeclarativeCondition? condition in new[] { rule.When, rule.Assert }) + { + if (condition?.ReachableFrom == null && condition?.ConnectsTo == null) + { + continue; + } + + if (string.Equals(rule.AppliesTo, "flow", StringComparison.OrdinalIgnoreCase)) + { + return "reachableFrom and connectsTo require a component appliesTo; use interaction source or target for flows."; + } + + string? error = ValidateConnectivityFilter(condition!.ReachableFrom) ?? ValidateConnectivityFilter(condition.ConnectsTo); + if (error != null) + { + return error; + } + } + + return null; + } + + private static string? ValidateConnectivityFilter(DeclarativeEndpoint? filter) + { + if (filter == null) + { + return null; + } + + if (filter.Kind == null && filter.Property == null) + { + return "connectivity filters require kind or property."; + } + + return ValidateEndpointKind(filter) ?? ValidateEndpointShape(filter); + } + + private static string? ValidateNumericBounds( + string? property, + decimal? greaterThan, + decimal? greaterThanOrEqual, + decimal? lessThan, + decimal? lessThanOrEqual) + { + if (!greaterThan.HasValue && !greaterThanOrEqual.HasValue && !lessThan.HasValue && !lessThanOrEqual.HasValue) + { + return null; + } + + if (string.IsNullOrWhiteSpace(property)) + { + return "numeric bounds require property."; + } + + decimal lower = Math.Max(greaterThan ?? decimal.MinValue, greaterThanOrEqual ?? decimal.MinValue); + decimal upper = Math.Min(lessThan ?? decimal.MaxValue, lessThanOrEqual ?? decimal.MaxValue); + return lower > upper || (lower == upper && (greaterThan == lower || lessThan == upper)) + ? "numeric bounds describe an empty range." + : null; } private static string? ValidateEndpointKinds(DeclarativeCondition? condition) @@ -656,7 +852,7 @@ private static IEnumerable ExpandFiles(string path, Action? diag return Array.Empty(); } - private static ParsedDocument? LoadFile(string file, Action? diagnostics, out int bytesRead) + private static ParsedDocument? LoadFile(string file, Action? diagnostics, RegexCompiler regexCompiler, out int bytesRead) { bytesRead = 0; byte[] content; @@ -670,10 +866,10 @@ private static IEnumerable ExpandFiles(string path, Action? diag return null; } - return ParseDocument(file, content, diagnostics); + return ParseDocument(file, content, diagnostics, regexCompiler); } - private static ParsedDocument? LoadContent(RuleContent source, Action? diagnostics, out int bytesRead) + private static ParsedDocument? LoadContent(RuleContent source, Action? diagnostics, RegexCompiler regexCompiler, out int bytesRead) { byte[] content = source.Bytes(); bytesRead = content.Length; @@ -684,10 +880,10 @@ private static IEnumerable ExpandFiles(string path, Action? diag return null; } - return ParseDocument(source.Name, content, diagnostics); + return ParseDocument(source.Name, content, diagnostics, regexCompiler); } - private static ParsedDocument? ParseDocument(string file, byte[] content, Action? diagnostics) + private static ParsedDocument? ParseDocument(string file, byte[] content, Action? diagnostics, RegexCompiler regexCompiler) { DeclarativeRuleFile? parsed; string json; @@ -756,9 +952,10 @@ private static IEnumerable ExpandFiles(string path, Action? diag Array.Empty(), Array.Empty(), legacyRules); - if (countError != null || textError != null) + string? matcherError = countError ?? textError ?? PrepareMatchers(legacyRules, regexCompiler, interaction: false); + if (matcherError != null) { - diagnostics?.Invoke($"Skipped rule file '{file}': {countError ?? textError}"); + diagnostics?.Invoke($"Skipped rule file '{file}': {matcherError}"); return null; } @@ -826,7 +1023,8 @@ private static IEnumerable ExpandFiles(string path, Action? diag return null; } - string? validationError = ValidateVersionTwoPack(parsed.Dialect!, header, categories, elementTypes, properties, rules); + string? validationError = ValidateVersionTwoPack(parsed.Dialect!, header, categories, elementTypes, properties, rules) ?? + PrepareMatchers(rules, regexCompiler, interaction: true); if (validationError != null) { diagnostics?.Invoke($"Skipped rule file '{file}': {validationError}"); @@ -1081,7 +1279,9 @@ private static string ReadJson(byte[] content) properties); return error ?? ValidateEndpointCatalogValues(condition.Source, properties) ?? - ValidateEndpointCatalogValues(condition.Target, properties); + ValidateEndpointCatalogValues(condition.Target, properties) ?? + ValidateEndpointCatalogValues(condition.ReachableFrom, properties) ?? + ValidateEndpointCatalogValues(condition.ConnectsTo, properties); } private static string? ValidateEndpointCatalogValues( @@ -1165,6 +1365,20 @@ private static string ReadJson(byte[] content) } } + foreach (DeclarativeEndpoint? filter in new[] { expression.ReachableFrom, expression.ConnectsTo }) + { + if (filter?.Property != null && !properties.ContainsKey(filter.Property)) + { + return $"connectivity filter references unknown property '{filter.Property}'."; + } + + string? error = ValidateEndpointCatalogValues(filter, properties); + if (error != null) + { + return error; + } + } + foreach (DeclarativeRuleSpec.InteractionExpressionSpec child in expression.AllOf ?? new List()) { string? error = ValidateInteractionReferences(child, elementIds, properties); @@ -1447,6 +1661,8 @@ private static long CountConditionValues(DeclarativeCondition? condition) long count = (condition.AnyOf?.Count ?? 0) + (condition.NotAnyOf?.Count ?? 0); count += CountEndpointValues(condition.Source); count += CountEndpointValues(condition.Target); + count += CountEndpointValues(condition.ReachableFrom); + count += CountEndpointValues(condition.ConnectsTo); return count; } @@ -1462,7 +1678,8 @@ private static long CountInteractionValues(DeclarativeRuleSpec.InteractionExpres return 0; } - long count = 1 + (expression.ValueIn?.Count ?? 0); + long count = 1 + (expression.ValueIn?.Count ?? 0) + + CountEndpointValues(expression.ReachableFrom) + CountEndpointValues(expression.ConnectsTo); foreach (DeclarativeRuleSpec.InteractionExpressionSpec child in expression.AllOf ?? new List()) { count += CountInteractionValues(child); @@ -1583,10 +1800,13 @@ private static void AddConditionText(List values, DeclarativeCondition? values.Add(condition.Property); values.Add(condition.EqualTo); + values.Add(condition.Matches); values.AddRange(condition.AnyOf ?? new List()); values.AddRange(condition.NotAnyOf ?? new List()); AddEndpointText(values, condition.Source); AddEndpointText(values, condition.Target); + AddEndpointText(values, condition.ReachableFrom); + AddEndpointText(values, condition.ConnectsTo); } private static void AddEndpointText(List values, DeclarativeEndpoint? endpoint) @@ -1599,6 +1819,7 @@ private static void AddEndpointText(List values, DeclarativeEndpoint? e values.Add(endpoint.Kind); values.Add(endpoint.Property); values.Add(endpoint.EqualTo); + values.Add(endpoint.Matches); values.AddRange(endpoint.AnyOf ?? new List()); values.AddRange(endpoint.NotAnyOf ?? new List()); } @@ -1614,8 +1835,12 @@ private static void AddInteractionText( values.Add(expression.Subject); values.Add(expression.Type); + values.Add(expression.Kind); values.Add(expression.Property); values.Add(expression.Crosses); + values.Add(expression.Matches); + AddEndpointText(values, expression.ReachableFrom); + AddEndpointText(values, expression.ConnectsTo); values.AddRange(expression.ValueIn ?? new List()); foreach (DeclarativeRuleSpec.InteractionExpressionSpec child in expression.AllOf ?? new List()) { @@ -1996,7 +2221,32 @@ private static InteractionExpression CompileInteractionExpression( CanonicalElementTypeId(expression.Type, pack)); } + if (expression.Kind != null) + { + return InteractionExpression.KindIs(expression.Subject!, expression.Kind); + } + + DeclarativeEndpoint? connected = expression.ReachableFrom ?? expression.ConnectsTo; + if (connected != null) + { + CanonicalizeEndpoint(connected, pack); + return InteractionExpression.Connectivity( + expression.Subject!, DeclarativeRule.CompileEndpoint(connected, "source"), expression.ReachableFrom != null); + } + string? property = CanonicalPropertyName(expression.Property!, pack); + if (expression.CompiledPattern != null) + { + return InteractionExpression.RegexProperty(expression.Subject!, property!, expression.CompiledPattern); + } + + if (expression.GreaterThan.HasValue || expression.GreaterThanOrEqual.HasValue || + expression.LessThan.HasValue || expression.LessThanOrEqual.HasValue) + { + return InteractionExpression.NumericProperty( + expression.Subject!, property!, expression.GreaterThan, expression.GreaterThanOrEqual, expression.LessThan, expression.LessThanOrEqual); + } + return InteractionExpression.PropertyIn( expression.Subject!, property!, @@ -2168,6 +2418,8 @@ private static void CanonicalizeCondition(DeclarativeCondition? condition, RuleP condition.Property = CanonicalPropertyName(condition.Property, pack); CanonicalizeEndpoint(condition.Source, pack); CanonicalizeEndpoint(condition.Target, pack); + CanonicalizeEndpoint(condition.ReachableFrom, pack); + CanonicalizeEndpoint(condition.ConnectsTo, pack); } private static void CanonicalizeEndpoint(DeclarativeEndpoint? endpoint, RulePackDefinition pack) @@ -2284,6 +2536,17 @@ private static void WarnEndpointProperties( { WarnProperty(condition.Target.Kind!, condition.Target.Property, pack, origin, diagnostics); } + + foreach (DeclarativeEndpoint? filter in new[] { condition?.ReachableFrom, condition?.ConnectsTo }) + { + if (filter?.Property != null) + { + foreach (string kind in filter.Kind == null ? new[] { "process", "datastore", "external" } : new[] { filter.Kind }) + { + WarnProperty(kind, filter.Property, pack, origin, diagnostics); + } + } + } } private static void WarnProperty( @@ -2346,5 +2609,64 @@ public RuleCandidate(ParsedDocument document, DeclarativeRuleSpec spec, string e public int Index { get; } } + + private sealed class RegexCompiler + { + private readonly Dictionary patterns = new Dictionary(StringComparer.Ordinal); + private TimeSpan elapsed; + + public Regex? Prepare(string? property, string? pattern) + { + if (pattern == null) + { + return null; + } + + if (string.IsNullOrWhiteSpace(property)) + { + throw new InvalidDataException("matches requires property."); + } + + if (pattern.Length == 0 || pattern.Length > 1024) + { + throw new InvalidDataException("Regex patterns must contain 1 to 1024 characters."); + } + + if (this.elapsed > TimeSpan.FromSeconds(1)) + { + throw new InvalidDataException("Regex construction exceeded the shared 1000 ms load limit."); + } + + if (this.patterns.TryGetValue(pattern, out Regex? existing)) + { + return existing; + } + + if (this.patterns.Count >= 128) + { + throw new InvalidDataException("Rule sources exceed the limit of 128 distinct regex patterns."); + } + + Regex compiled; + Stopwatch timer = Stopwatch.StartNew(); + try + { + compiled = new Regex(pattern, RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(50)); + } + finally + { + timer.Stop(); + this.elapsed += timer.Elapsed; + } + + if (timer.Elapsed > TimeSpan.FromMilliseconds(250) || this.elapsed > TimeSpan.FromSeconds(1)) + { + throw new InvalidDataException("Regex construction exceeded the 250 ms pattern or 1000 ms load limit."); + } + + this.patterns.Add(pattern, compiled); + return compiled; + } + } } } diff --git a/src/ThreatModelForge.Analysis/DeclarativeRuleSpec.cs b/src/ThreatModelForge.Analysis/DeclarativeRuleSpec.cs index d59509f..03e02eb 100644 --- a/src/ThreatModelForge.Analysis/DeclarativeRuleSpec.cs +++ b/src/ThreatModelForge.Analysis/DeclarativeRuleSpec.cs @@ -1,6 +1,7 @@ namespace ThreatModelForge.Analysis { using System.Collections.Generic; + using System.Text.RegularExpressions; /// /// A single declarative rule. A finding is raised for each element of that @@ -105,14 +106,41 @@ internal sealed class InteractionExpressionSpec /// Gets or sets the expected element type. public string? Type { get; set; } + /// Gets or sets the expected primitive component kind. + public string? Kind { get; set; } + /// Gets or sets the property to read. public string? Property { get; set; } /// Gets or sets accepted property values. public List? ValueIn { get; set; } + /// Gets or sets the exclusive numeric lower bound. + public decimal? GreaterThan { get; set; } + + /// Gets or sets the inclusive numeric lower bound. + public decimal? GreaterThanOrEqual { get; set; } + + /// Gets or sets the exclusive numeric upper bound. + public decimal? LessThan { get; set; } + + /// Gets or sets the inclusive numeric upper bound. + public decimal? LessThanOrEqual { get; set; } + + /// Gets or sets the regular expression the property must match. + public string? Matches { get; set; } + /// Gets or sets the specific crossed-boundary type. public string? Crosses { get; set; } + + /// Gets or sets an upstream component filter for positive-length directed reachability. + public DeclarativeEndpoint? ReachableFrom { get; set; } + + /// Gets or sets a component filter for one outgoing connector. + public DeclarativeEndpoint? ConnectsTo { get; set; } + + /// Gets or sets the pattern prepared during pack validation. + internal Regex? CompiledPattern { get; set; } } } } diff --git a/src/ThreatModelForge.Analysis/InteractionExpression.cs b/src/ThreatModelForge.Analysis/InteractionExpression.cs index 7a648ed..d0f0626 100644 --- a/src/ThreatModelForge.Analysis/InteractionExpression.cs +++ b/src/ThreatModelForge.Analysis/InteractionExpression.cs @@ -2,8 +2,11 @@ namespace ThreatModelForge.Analysis { using System; using System.Collections.Generic; + using System.Diagnostics; + using System.Globalization; using System.IO; using System.Linq; + using System.Text.RegularExpressions; using ThreatModelForge.Model; using ThreatModelForge.Model.Abstracts; @@ -25,7 +28,12 @@ private InteractionExpression( string? equalTo = null, bool? present = null, string? boundaryType = null, - bool firstValueOnly = false) + bool firstValueOnly = false, + decimal? greaterThan = null, + decimal? greaterThanOrEqual = null, + decimal? lessThan = null, + decimal? lessThanOrEqual = null, + Regex? pattern = null) { this.Operation = operation; this.Children = children ?? Array.Empty(); @@ -40,6 +48,11 @@ private InteractionExpression( this.Present = present; this.BoundaryType = boundaryType; this.FirstValueOnly = firstValueOnly; + this.GreaterThan = greaterThan; + this.GreaterThanOrEqual = greaterThanOrEqual; + this.LessThan = lessThan; + this.LessThanOrEqual = lessThanOrEqual; + this.Pattern = pattern; } /// The operation represented by this node. @@ -74,6 +87,12 @@ internal enum OperationKind /// Specific crossed-boundary type. Crosses, + + /// Positive-length reachability from a filtered upstream component. + ReachableFrom, + + /// A direct outgoing connector to a filtered component. + ConnectsTo, } /// Gets the node operation. @@ -109,6 +128,25 @@ internal enum OperationKind /// Gets the required presence state for a composite flat condition. public bool? Present { get; } + /// Gets the exclusive numeric lower bound. + public decimal? GreaterThan { get; } + + /// Gets the inclusive numeric lower bound. + public decimal? GreaterThanOrEqual { get; } + + /// Gets the exclusive numeric upper bound. + public decimal? LessThan { get; } + + /// Gets the inclusive numeric upper bound. + public decimal? LessThanOrEqual { get; } + + /// Gets a value indicating whether a numeric constraint is present. + public bool HasNumericMatcher => this.GreaterThan.HasValue || this.GreaterThanOrEqual.HasValue || + this.LessThan.HasValue || this.LessThanOrEqual.HasValue; + + /// Gets the validated, reusable regular expression. + public Regex? Pattern { get; } + /// Gets the expected crossed-boundary type. public string? BoundaryType { get; } @@ -161,6 +199,38 @@ public static InteractionExpression SubjectExists(string subject) => public static InteractionExpression PropertyIn(string subject, string property, IReadOnlyList values) => new InteractionExpression(OperationKind.Property, subject: subject, property: property, values: values); + /// Creates a regular-expression property predicate. + /// The subject to read. + /// The property to read. + /// The validated expression with a match timeout. + /// The regular-expression predicate. + public static InteractionExpression RegexProperty(string subject, string property, Regex pattern) => + new InteractionExpression(OperationKind.Property, subject: subject, property: property, pattern: pattern); + + /// Creates a numeric property predicate. + /// The subject to read. + /// The property to read. + /// The exclusive lower bound. + /// The inclusive lower bound. + /// The exclusive upper bound. + /// The inclusive upper bound. + /// The numeric predicate. + public static InteractionExpression NumericProperty( + string subject, + string property, + decimal? greaterThan, + decimal? greaterThanOrEqual, + decimal? lessThan, + decimal? lessThanOrEqual) => + new InteractionExpression( + OperationKind.Property, + subject: subject, + property: property, + greaterThan: greaterThan, + greaterThanOrEqual: greaterThanOrEqual, + lessThan: lessThan, + lessThanOrEqual: lessThanOrEqual); + /// Creates a first-value property predicate for legacy flat-rule compatibility. /// The interaction subject. /// The runtime property name. @@ -195,6 +265,12 @@ public static InteractionExpression FirstPropertyPresent(string subject, string /// The rejected values. /// The required equality value. /// The required presence state. + /// The exclusive numeric lower bound. + /// The inclusive numeric lower bound. + /// The exclusive numeric upper bound. + /// The inclusive numeric upper bound. + /// The optional validated regular expression. + /// The optional enclosing component-kind filter for property policy. /// The composite property condition. public static InteractionExpression FlatPropertyCondition( string subject, @@ -202,7 +278,13 @@ public static InteractionExpression FlatPropertyCondition( IReadOnlyList? anyOf, IReadOnlyList? notAnyOf, string? equalTo, - bool? present) => + bool? present, + decimal? greaterThan = null, + decimal? greaterThanOrEqual = null, + decimal? lessThan = null, + decimal? lessThanOrEqual = null, + Regex? pattern = null, + string? kind = null) => new InteractionExpression( OperationKind.FlatProperty, subject: subject, @@ -211,7 +293,13 @@ public static InteractionExpression FlatPropertyCondition( rejectedValues: notAnyOf?.ToArray(), equalTo: equalTo, present: present, - firstValueOnly: true); + firstValueOnly: true, + greaterThan: greaterThan, + greaterThanOrEqual: greaterThanOrEqual, + lessThan: lessThan, + lessThanOrEqual: lessThanOrEqual, + pattern: pattern, + kind: kind); /// Creates a crossed-boundary predicate. /// The expected boundary type. @@ -224,6 +312,14 @@ public static InteractionExpression Crosses(string boundaryType) => public static InteractionExpression CrossesAnyBoundary() => new InteractionExpression(OperationKind.Crosses); + /// Creates a directed component connectivity predicate. + /// The component subject. + /// The filter evaluated against each connected component as source. + /// Whether to follow incoming paths rather than one outgoing edge. + /// The connectivity predicate. + public static InteractionExpression Connectivity(string subject, InteractionExpression filter, bool incoming) => + new InteractionExpression(incoming ? OperationKind.ReachableFrom : OperationKind.ConnectsTo, child: filter, subject: subject); + /// Checks whether an entity belongs to one of the flat dialect's primitive kinds. /// The entity to classify. /// The expected primitive kind. @@ -411,6 +507,9 @@ internal bool Evaluate( return this.EvaluatePresence(expression, interaction, context, ref operations); case OperationKind.FlatProperty: return this.EvaluateFlatProperty(expression, interaction, context, ref operations); + case OperationKind.ReachableFrom: + case OperationKind.ConnectsTo: + return this.EvaluateConnectivity(expression, interaction, context, ref operations); default: return false; } @@ -421,6 +520,51 @@ boundary is BorderBoundary border ? flow.Crosses(border) : boundary is LineBoundary line && flow.Crosses(line); + private bool EvaluateConnectivity( + InteractionExpression expression, + EvaluationContext interaction, + RuleEvaluationContext context, + ref int operations) + { + Entity? subject = interaction.Subject(expression.Subject!); + if (subject == null || subject is Connector || !subject.IsComponent()) + { + return false; + } + + RuleEvaluationContext.ConnectivityGraph graph = context.GetConnectivityGraph(); + bool incoming = expression.Operation == OperationKind.ReachableFrom; + Queue pending = new Queue(); + HashSet visited = new HashSet(); + pending.Enqueue(subject.Guid); + while (pending.Count > 0) + { + this.AccountOperation(context, ref operations); + Guid current = pending.Dequeue(); + foreach (Entity neighbor in graph.Neighbors(interaction.Diagram, current, incoming)) + { + this.AccountOperation(context, ref operations); + if (!visited.Add(neighbor.Guid)) + { + continue; + } + + EvaluationContext candidate = new EvaluationContext(interaction.Diagram, neighbor, null, null, isRoot: false); + if (this.Evaluate(expression.Child!, candidate, context, ref operations)) + { + return true; + } + + if (incoming) + { + pending.Enqueue(neighbor.Guid); + } + } + } + + return false; + } + private bool EvaluateCrosses( InteractionExpression expression, EvaluationContext interaction, @@ -463,6 +607,26 @@ private bool EvaluateProperty( IEnumerable candidates = expression.FirstValueOnly ? values.Take(1) : values; foreach (string value in candidates) { + if (expression.Pattern != null) + { + if (this.MatchesRegexValue(expression, value, context, ref operations)) + { + return true; + } + + continue; + } + + if (expression.HasNumericMatcher) + { + if (this.MatchesNumericValue(expression, value, context, ref operations)) + { + return true; + } + + continue; + } + if (expression.FirstValueOnly && string.IsNullOrWhiteSpace(value)) { return false; @@ -518,7 +682,8 @@ private bool EvaluateFlatProperty( bool hasMatcher = expression.Present.HasValue || expression.EqualTo != null || expression.Values.Count > 0 || - expression.RejectedValues.Count > 0; + expression.RejectedValues.Count > 0 || + expression.HasNumericMatcher || expression.Pattern != null; if (!hasMatcher) { return isPresent; @@ -551,7 +716,60 @@ private bool EvaluateFlatProperty( return false; } - return true; + return (!expression.HasNumericMatcher || + (isPresent && this.MatchesNumericValue(expression, value, context, ref operations))) && + (expression.Pattern == null || + (isPresent && this.MatchesRegexValue(expression, value, context, ref operations))); + } + + private bool MatchesRegexValue( + InteractionExpression expression, + string value, + RuleEvaluationContext context, + ref int operations) + { + const int maximumLength = 4096; + if (value.Length > maximumLength) + { + throw new InvalidDataException($"Rule '{this.ruleId}' regex input for '{expression.Property}' exceeds {maximumLength} characters."); + } + + this.AccountOperations(context, ref operations, value.Length + 1); + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + context.AccountRegexTime(TimeSpan.Zero); + Stopwatch elapsed = Stopwatch.StartNew(); + try + { + return expression.Pattern!.IsMatch(value); + } + catch (RegexMatchTimeoutException ex) + { + throw new InvalidDataException($"Rule '{this.ruleId}' regex match for '{expression.Property}' exceeded the 50 ms timeout.", ex); + } + finally + { + context.AccountRegexTime(elapsed.Elapsed); + } + } + + private bool MatchesNumericValue( + InteractionExpression expression, + string value, + RuleEvaluationContext context, + ref int operations) + { + const int maximumLength = 256; + this.AccountOperations(context, ref operations, Math.Min(value.Length, maximumLength) + 1); + return value.Length <= maximumLength && + decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal number) && + (!expression.GreaterThan.HasValue || number > expression.GreaterThan.Value) && + (!expression.GreaterThanOrEqual.HasValue || number >= expression.GreaterThanOrEqual.Value) && + (!expression.LessThan.HasValue || number < expression.LessThan.Value) && + (!expression.LessThanOrEqual.HasValue || number <= expression.LessThanOrEqual.Value); } private bool ContainsFlatValue( diff --git a/src/ThreatModelForge.Analysis/InteractionRule.cs b/src/ThreatModelForge.Analysis/InteractionRule.cs index dd4ca64..0a2dc6e 100644 --- a/src/ThreatModelForge.Analysis/InteractionRule.cs +++ b/src/ThreatModelForge.Analysis/InteractionRule.cs @@ -219,7 +219,8 @@ private static void AddPropertyBindings( List bindings, ISet seen) { - if (expression.Operation == InteractionExpression.OperationKind.Property) + if (expression.Operation == InteractionExpression.OperationKind.Property || + expression.Operation == InteractionExpression.OperationKind.FlatProperty) { foreach (string appliesTo in PropertyKinds(expression, pack, parents)) { @@ -247,6 +248,11 @@ private static IEnumerable PropertyKinds( RulePackDefinition pack, IReadOnlyDictionary parents) { + if (expression.Kind != null) + { + return new[] { expression.Kind }; + } + if (string.Equals(expression.Subject, "flow", StringComparison.Ordinal)) { return new[] { "flow" }; diff --git a/src/ThreatModelForge.Analysis/RuleEvaluationContext.cs b/src/ThreatModelForge.Analysis/RuleEvaluationContext.cs index 7be699c..75761ee 100644 --- a/src/ThreatModelForge.Analysis/RuleEvaluationContext.cs +++ b/src/ThreatModelForge.Analysis/RuleEvaluationContext.cs @@ -5,6 +5,7 @@ namespace ThreatModelForge.Analysis using System.Collections.ObjectModel; using System.Linq; using ThreatModelForge.Model; + using ThreatModelForge.Model.Abstracts; /// /// Configuration, services, and properties passed along to all rules during evaluation. @@ -20,6 +21,8 @@ public class RuleEvaluationContext private long declarativeOperationCount; private long declarativeOperationLimit = DefaultDeclarativeOperationLimit; + private TimeSpan regexTime; + private ConnectivityGraph? connectivityGraph; /// /// Initializes a new instance of the class. @@ -235,6 +238,24 @@ internal long GetDeclarativeOperationCount() return this.declarativeOperationCount; } + /// Charges regex work to the shared one-second invocation budget. + /// The elapsed regular-expression evaluation time. + internal void AccountRegexTime(TimeSpan elapsed) + { + this.regexTime += elapsed; + if (this.regexTime > TimeSpan.FromSeconds(1)) + { + throw new InvalidDataException("Analysis exceeded the shared regex evaluation time limit of 1000 ms."); + } + } + + /// Gets the page-partitioned graph shared by all predicates in this evaluation. + /// The lazily constructed graph index. + internal ConnectivityGraph GetConnectivityGraph() + { + return this.connectivityGraph ??= new ConnectivityGraph(this); + } + /// Sets the declarative operation limit for this analysis invocation. /// The new non-negative limit. internal void SetDeclarativeOperationLimit(long value) @@ -288,5 +309,98 @@ private string GetThreatModelName() System.IO.Path.GetFileNameWithoutExtension(this.ModelSourcePath) : string.Empty; } + + /// An invocation-local adjacency index; model topology must remain fixed during evaluation. + internal sealed class ConnectivityGraph + { + private readonly Dictionary pages = new Dictionary(); + + /// Initializes a new instance of the class. + /// The evaluation owning this index and its operation budget. + internal ConnectivityGraph(RuleEvaluationContext context) + { + if (context.Model.DrawingSurfaceList.Count > 1024) + { + throw new InvalidDataException("Connectivity exceeds the limit of 1024 pages."); + } + + long borders = 0; + long lines = 0; + foreach (DrawingSurfaceModel diagram in context.Model.DrawingSurfaceList) + { + context.AccountDeclarativeOperations(); + borders += diagram.Borders.Count; + lines += diagram.Lines.Count; + } + + if (borders > 100000 || lines > 200000) + { + throw new InvalidDataException("Connectivity exceeds the limit of 100000 shapes or 200000 lines."); + } + + foreach (DrawingSurfaceModel diagram in context.Model.DrawingSurfaceList) + { + context.AccountDeclarativeOperations(diagram.Borders.Count); + context.AccountDeclarativeOperations(diagram.Lines.Count); + GraphPage page = new GraphPage(); + foreach (KeyValuePair entry in diagram.Borders) + { + if (entry.Value is not Entity component || !component.IsComponent()) + { + continue; + } + + if (entry.Key == Guid.Empty || component.Guid != entry.Key) + { + throw new InvalidDataException("Connectivity requires non-empty component ids matching their page keys."); + } + + page.Outgoing.Add(component.Guid, new List()); + page.Incoming.Add(component.Guid, new List()); + } + + foreach (Connector flow in diagram.Lines.Values.OfType()) + { + if (!diagram.Borders.TryGetValue(flow.SourceGuid, out object? sourceValue) || sourceValue is not Entity source || + !diagram.Borders.TryGetValue(flow.TargetGuid, out object? targetValue) || targetValue is not Entity target) + { + throw new InvalidDataException($"Connectivity requires flow '{flow.Guid}' endpoints to resolve on the same page."); + } + + if (page.Outgoing.TryGetValue(source.Guid, out List? outgoing) && + page.Incoming.TryGetValue(target.Guid, out List? incoming)) + { + outgoing.Add(target); + incoming.Add(source); + } + } + + this.pages.Add(diagram, page); + } + } + + /// Returns the neighbors of a component without scanning model lines again. + /// The containing page. + /// The component id. + /// Whether to follow incoming rather than outgoing edges. + /// The page-local neighbors, including explicit self-loops and parallel edges. + internal IReadOnlyList Neighbors(DrawingSurfaceModel diagram, Guid component, bool incoming) + { + if (this.pages.TryGetValue(diagram, out GraphPage? page) && + (incoming ? page.Incoming : page.Outgoing).TryGetValue(component, out List? neighbors)) + { + return neighbors; + } + + return Array.Empty(); + } + + private sealed class GraphPage + { + public Dictionary> Outgoing { get; } = new Dictionary>(); + + public Dictionary> Incoming { get; } = new Dictionary>(); + } + } } } diff --git a/src/ThreatModelForge.Analysis/Schemas/tmforge-rules-v2.schema.json b/src/ThreatModelForge.Analysis/Schemas/tmforge-rules-v2.schema.json index 59e39d7..1da80ce 100644 --- a/src/ThreatModelForge.Analysis/Schemas/tmforge-rules-v2.schema.json +++ b/src/ThreatModelForge.Analysis/Schemas/tmforge-rules-v2.schema.json @@ -273,6 +273,18 @@ } } }, + { + "if": { + "properties": { "appliesTo": { "const": "flow" } }, + "required": ["appliesTo"] + }, + "then": { + "properties": { + "when": { "properties": { "reachableFrom": false, "connectsTo": false } }, + "assert": { "properties": { "reachableFrom": false, "connectsTo": false } } + } + } + }, { "if": { "required": ["defaultPriority"] }, "then": { @@ -336,7 +348,11 @@ { "$ref": "#/$defs/anyExpression" }, { "$ref": "#/$defs/notExpression" }, { "$ref": "#/$defs/typeExpression" }, + { "$ref": "#/$defs/kindExpression" }, { "$ref": "#/$defs/propertyExpression" }, + { "$ref": "#/$defs/numericExpression" }, + { "$ref": "#/$defs/regexExpression" }, + { "$ref": "#/$defs/connectivityExpression" }, { "$ref": "#/$defs/crossesExpression" } ] }, @@ -399,6 +415,30 @@ "type": { "$ref": "#/$defs/nonEmptyString" } } }, + "kindExpression": { + "type": "object", + "additionalProperties": false, + "required": ["subject", "kind"], + "properties": { + "subject": { "enum": ["source", "target"] }, + "kind": { "enum": ["process", "datastore", "external"] } + } + }, + "connectivityFilter": { + "allOf": [ { "$ref": "#/$defs/endpoint" } ], + "anyOf": [ { "required": ["kind"] }, { "required": ["property"] } ] + }, + "connectivityExpression": { + "type": "object", + "additionalProperties": false, + "required": ["subject"], + "oneOf": [ { "required": ["reachableFrom"] }, { "required": ["connectsTo"] } ], + "properties": { + "subject": { "enum": ["source", "target"] }, + "reachableFrom": { "$ref": "#/$defs/connectivityFilter" }, + "connectsTo": { "$ref": "#/$defs/connectivityFilter" } + } + }, "propertyExpression": { "type": "object", "additionalProperties": false, @@ -414,6 +454,45 @@ } } }, + "decimal": { + "type": "number", + "minimum": -79228162514264337593543950335, + "maximum": 79228162514264337593543950335 + }, + "numericExpression": { + "type": "object", + "additionalProperties": false, + "required": ["subject", "property"], + "anyOf": [ + { "required": ["greaterThan"] }, + { "required": ["greaterThanOrEqual"] }, + { "required": ["lessThan"] }, + { "required": ["lessThanOrEqual"] } + ], + "properties": { + "subject": { "enum": ["source", "target", "flow"] }, + "property": { "$ref": "#/$defs/nonEmptyString" }, + "greaterThan": { "$ref": "#/$defs/decimal" }, + "greaterThanOrEqual": { "$ref": "#/$defs/decimal" }, + "lessThan": { "$ref": "#/$defs/decimal" }, + "lessThanOrEqual": { "$ref": "#/$defs/decimal" } + } + }, + "regexPattern": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "regexExpression": { + "type": "object", + "additionalProperties": false, + "required": ["subject", "property", "matches"], + "properties": { + "subject": { "enum": ["source", "target", "flow"] }, + "property": { "$ref": "#/$defs/nonEmptyString" }, + "matches": { "$ref": "#/$defs/regexPattern" } + } + }, "crossesExpression": { "type": "object", "additionalProperties": false, @@ -429,7 +508,12 @@ "anyOf": ["property"], "notAnyOf": ["property"], "equals": ["property"], - "present": ["property"] + "present": ["property"], + "greaterThan": ["property"], + "greaterThanOrEqual": ["property"], + "lessThan": ["property"], + "lessThanOrEqual": ["property"], + "matches": ["property"] }, "properties": { "property": { "$ref": "#/$defs/nonEmptyString" }, @@ -447,9 +531,16 @@ }, "equals": { "$ref": "#/$defs/boundedString" }, "present": { "type": "boolean" }, + "greaterThan": { "$ref": "#/$defs/decimal" }, + "greaterThanOrEqual": { "$ref": "#/$defs/decimal" }, + "lessThan": { "$ref": "#/$defs/decimal" }, + "lessThanOrEqual": { "$ref": "#/$defs/decimal" }, + "matches": { "$ref": "#/$defs/regexPattern" }, "crossesTrustBoundary": { "type": "boolean" }, "source": { "$ref": "#/$defs/endpoint" }, - "target": { "$ref": "#/$defs/endpoint" } + "target": { "$ref": "#/$defs/endpoint" }, + "reachableFrom": { "$ref": "#/$defs/connectivityFilter" }, + "connectsTo": { "$ref": "#/$defs/connectivityFilter" } } }, "nonRelationalCondition": { @@ -459,7 +550,12 @@ "anyOf": ["property"], "notAnyOf": ["property"], "equals": ["property"], - "present": ["property"] + "present": ["property"], + "greaterThan": ["property"], + "greaterThanOrEqual": ["property"], + "lessThan": ["property"], + "lessThanOrEqual": ["property"], + "matches": ["property"] }, "properties": { "property": { "$ref": "#/$defs/nonEmptyString" }, @@ -476,7 +572,14 @@ "items": { "$ref": "#/$defs/boundedString" } }, "equals": { "$ref": "#/$defs/boundedString" }, - "present": { "type": "boolean" } + "present": { "type": "boolean" }, + "greaterThan": { "$ref": "#/$defs/decimal" }, + "greaterThanOrEqual": { "$ref": "#/$defs/decimal" }, + "lessThan": { "$ref": "#/$defs/decimal" }, + "lessThanOrEqual": { "$ref": "#/$defs/decimal" }, + "matches": { "$ref": "#/$defs/regexPattern" }, + "reachableFrom": { "$ref": "#/$defs/connectivityFilter" }, + "connectsTo": { "$ref": "#/$defs/connectivityFilter" } } }, "endpoint": { @@ -486,7 +589,12 @@ "anyOf": ["property"], "notAnyOf": ["property"], "equals": ["property"], - "present": ["property"] + "present": ["property"], + "greaterThan": ["property"], + "greaterThanOrEqual": ["property"], + "lessThan": ["property"], + "lessThanOrEqual": ["property"], + "matches": ["property"] }, "properties": { "kind": { "enum": ["process", "datastore", "external"] }, @@ -504,7 +612,12 @@ "items": { "$ref": "#/$defs/boundedString" } }, "equals": { "$ref": "#/$defs/boundedString" }, - "present": { "type": "boolean" } + "present": { "type": "boolean" }, + "greaterThan": { "$ref": "#/$defs/decimal" }, + "greaterThanOrEqual": { "$ref": "#/$defs/decimal" }, + "lessThan": { "$ref": "#/$defs/decimal" }, + "lessThanOrEqual": { "$ref": "#/$defs/decimal" }, + "matches": { "$ref": "#/$defs/regexPattern" } } } } diff --git a/test/Fixtures/additional-matchers.json b/test/Fixtures/additional-matchers.json new file mode 100644 index 0000000..76772a7 --- /dev/null +++ b/test/Fixtures/additional-matchers.json @@ -0,0 +1,59 @@ +{ + "pack": { + "schema": "tmforge-rules", + "version": 2, + "dialect": "urn:tmforge:rules:flat-v1", + "pack": { "id": "rule005", "name": "Additional matcher contract", "version": "1.0" }, + "categories": [ { "id": "policy", "name": "Policy" } ], + "properties": [ + { "name": "RetentionDays" }, + { "name": "ServiceName" }, + { "name": "StoresLogData", "allowedValues": ["Yes", "No", "Unknown"] } + ], + "rules": [ + { + "id": "RETENTION", + "severity": "error", + "categoryId": "policy", + "defaultPriority": "High", + "appliesTo": "datastore", + "message": "{name} must declare retention between 1 and 30 days.", + "assert": { "property": "RetentionDays", "greaterThanOrEqual": 1, "lessThanOrEqual": 30 } + }, + { + "id": "SERVICE-NAME", + "severity": "error", + "categoryId": "policy", + "defaultPriority": "High", + "appliesTo": "process", + "message": "{name} must use a service name beginning with svc-.", + "assert": { "property": "ServiceName", "matches": "^svc-[a-z0-9-]+$" } + }, + { + "id": "AUDIT-PATH", + "severity": "error", + "categoryId": "policy", + "defaultPriority": "High", + "appliesTo": "process", + "message": "{name} is externally reachable but lacks a direct audit-store connection.", + "when": { "reachableFrom": { "kind": "external" } }, + "assert": { "connectsTo": { "kind": "datastore", "property": "StoresLogData", "equals": "Yes" } } + } + ] + }, + "model": { + "schema": "tmforge-json", + "version": "0.1", + "elements": [ + { "id": "11111111-1111-4111-8111-111111111111", "kind": "external", "name": "Entry", "x": 10, "y": 50, "width": 120, "height": 60 }, + { "id": "22222222-2222-4222-8222-222222222222", "kind": "process", "name": "Gateway", "x": 200, "y": 50, "width": 120, "height": 60, "properties": { "ServiceName": "frontend" } }, + { "id": "33333333-3333-4333-8333-333333333333", "kind": "process", "name": "Worker", "x": 400, "y": 50, "width": 120, "height": 60, "properties": { "ServiceName": "svc-worker" } }, + { "id": "44444444-4444-4444-8444-444444444444", "kind": "datastore", "name": "Audit", "x": 600, "y": 50, "width": 120, "height": 60, "properties": { "StoresLogData": "Yes", "RetentionDays": "90" } } + ], + "flows": [ + { "id": "55555555-5555-4555-8555-555555555555", "name": "Request", "source": "11111111-1111-4111-8111-111111111111", "target": "22222222-2222-4222-8222-222222222222" }, + { "id": "66666666-6666-4666-8666-666666666666", "name": "Work", "source": "22222222-2222-4222-8222-222222222222", "target": "33333333-3333-4333-8333-333333333333" }, + { "id": "77777777-7777-4777-8777-777777777777", "name": "Audit", "source": "33333333-3333-4333-8333-333333333333", "target": "44444444-4444-4444-8444-444444444444" } + ] + } +} diff --git a/test/ThreatModelForge.Analysis.Tests/DeclarativeRuleProviderTests.cs b/test/ThreatModelForge.Analysis.Tests/DeclarativeRuleProviderTests.cs index f6390e6..029a102 100644 --- a/test/ThreatModelForge.Analysis.Tests/DeclarativeRuleProviderTests.cs +++ b/test/ThreatModelForge.Analysis.Tests/DeclarativeRuleProviderTests.cs @@ -2,8 +2,11 @@ namespace ThreatModelForge.Analysis.Tests { using System; using System.Collections.Generic; + using System.Globalization; using System.IO; using System.Linq; + using System.Text.Json; + using System.Text.RegularExpressions; using Microsoft.VisualStudio.TestTools.UnitTesting; using ThreatModelForge.KnowledgeBase; using ThreatModelForge.Model; @@ -146,6 +149,416 @@ public void SharesEvaluationBudgetAcrossRules() Assert.Throws(() => rules[1].Evaluate(context)); } + /// Numeric guards use invariant decimals and never match missing or invalid values. + /// The stored property value, or null for an absent property. + /// Whether the numeric guard should match. + [TestMethod] + [DataRow(null, false)] + [DataRow("", false)] + [DataRow("Unknown", false)] + [DataRow("NaN", false)] + [DataRow("Infinity", false)] + [DataRow("10,5", false)] + [DataRow("10", false)] + [DataRow("9", false)] + [DataRow("10.5", true)] + [DataRow(" +11 ", true)] + [DataRow("1e2", true)] + public void NumericGuardsUseInvariantValues(string? value, bool expected) + { + string rule = + "{\"id\":\"LIMIT\",\"appliesTo\":\"process\",\"message\":\"limit exceeded\"," + + "\"when\":{\"property\":\"Cache Type\",\"greaterThan\":10}}"; + string spec = VersionTwoSpec("numeric", rule); + List diagnostics = new List(); + RuleBundle bundle = DeclarativeRuleProvider.LoadBundle(new[] { this.WriteSpec(spec) }, diagnostics.Add); + Assert.AreEqual(1, bundle.Rules.Count, string.Join("; ", diagnostics)); + StencilEllipse process = CreateEntity("GE.P", "GE.P", "Worker"); + if (value != null) + { + process.Properties.Add(new CustomStringDisplayAttribute { Value = "Cache Type:" + value }); + } + + DrawingSurfaceModel diagram = new DrawingSurfaceModel { Header = "Numeric" }; + diagram.Borders.Add(process.Guid, process); + MockMessageWriter writer = new MockMessageWriter(); + RuleEvaluationContext context = new RuleEvaluationContext( + new ThreatModel { DrawingSurfaceList = { diagram } }, + writer); + CultureInfo originalCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + bundle.Rules[0].Evaluate(context); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + + Assert.AreEqual(expected ? 1 : 0, writer.Messages.Count); + } + + /// Numeric comparisons agree on each subject in both rule dialects. + /// The numeric comparison. + /// The property value. + /// Whether the comparison matches. + [TestMethod] + [DataRow("\"greaterThan\":10", "9", false)] + [DataRow("\"greaterThan\":10", "10", false)] + [DataRow("\"greaterThan\":10", "11", true)] + [DataRow("\"greaterThanOrEqual\":10", "9", false)] + [DataRow("\"greaterThanOrEqual\":10", "10", true)] + [DataRow("\"greaterThanOrEqual\":10", "11", true)] + [DataRow("\"lessThan\":10", "9", true)] + [DataRow("\"lessThan\":10", "10", false)] + [DataRow("\"lessThan\":10", "11", false)] + [DataRow("\"lessThanOrEqual\":10", "9", true)] + [DataRow("\"lessThanOrEqual\":10", "10", true)] + [DataRow("\"lessThanOrEqual\":10", "11", false)] + [DataRow("\"greaterThanOrEqual\":10,\"lessThan\":20", "15", true)] + [DataRow("\"greaterThanOrEqual\":10,\"lessThan\":20", "20", false)] + [DataRow("\"greaterThan\":9,\"greaterThanOrEqual\":10,\"lessThan\":11,\"lessThanOrEqual\":10", "10", true)] + [DataRow("\"greaterThanOrEqual\":0", "1e99", false)] + [DataRow("\"greaterThanOrEqual\":0", "1,000", false)] + public void NumericPredicatesApplyAcrossSubjects(string matcher, string value, bool expected) + { + foreach (string form in new[] { "element", "flow", "source", "target", "interaction-source", "interaction-target", "interaction-flow" }) + { + MockMessageWriter writer = this.RunPropertyRule(matcher, value, form); + Assert.AreEqual(expected ? 1 : 0, writer.Messages.Count, form); + } + } + + /// Missing or invalid numeric evidence fails a requirement instead of proving it. + /// The absent or invalid evidence. + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow("Unknown")] + [DataRow("not a number")] + public void NumericRequirementsFailWithoutEvidence(string? value) + { + MockMessageWriter writer = this.RunPropertyRule("\"lessThanOrEqual\":30", value, "element", requirement: true); + Assert.AreEqual(1, writer.Messages.Count); + } + + /// Numeric parsing and comparison consume the invocation budget. + [TestMethod] + public void NumericEvaluationIsBounded() + { + Assert.Throws(() => this.RunPropertyRule("\"greaterThan\":0", "1", "element", operationLimit: 1)); + Assert.AreEqual(0, this.RunPropertyRule("\"greaterThanOrEqual\":0", new string('0', 257), "element").Messages.Count); + } + + /// Invalid numeric constraints are diagnosed before a pack can be evaluated. + /// The invalid condition. + [TestMethod] + [DataRow("{\"greaterThan\":10}")] + [DataRow("{\"property\":\"Cache Type\",\"greaterThan\":\"10\"}")] + [DataRow("{\"property\":\"Cache Type\",\"greaterThan\":1e99}")] + [DataRow("{\"property\":\"Cache Type\",\"greaterThan\":null}")] + [DataRow("{\"property\":\"Cache Type\",\"greaterThan\":10,\"lessThanOrEqual\":10}")] + [DataRow("{\"property\":\"Cache Type\",\"greaterThanOrEqual\":11,\"lessThan\":10}")] + public void RejectsInvalidNumericConstraints(string condition) + { + string rule = "{\"id\":\"BAD\",\"appliesTo\":\"process\",\"message\":\"bad\",\"when\":" + condition + "}"; + List diagnostics = new List(); + RuleBundle bundle = DeclarativeRuleProvider.LoadBundle( + new[] { this.WriteSpec(VersionTwoSpec("numeric-invalid", rule)) }, diagnostics.Add); + Assert.AreEqual(0, bundle.Rules.Count); + Assert.AreEqual(0, bundle.Packs.Count); + Assert.IsTrue(diagnostics.Count > 0); + } + + /// Regex predicates have identical subject and missing-value semantics in both dialects. + /// The regular expression. + /// The property value. + /// Whether it should match. + [TestMethod] + [DataRow("^svc-[0-9]+$", "svc-42", true)] + [DataRow("^svc-[0-9]+$", "SVC-42", false)] + [DataRow("(?i)^svc-[0-9]+$", "SVC-42", true)] + [DataRow("svc", "prefix-svc-suffix", true)] + [DataRow("^svc$", "prefix-svc-suffix", false)] + [DataRow("^svc$", null, false)] + [DataRow(".*", "", false)] + [DataRow(".*", " ", false)] + [DataRow("^svc$", "Unknown", false)] + [DataRow("^Unknown$", "Unknown", true)] + public void RegexPredicatesApplyAcrossSubjects(string pattern, string? value, bool expected) + { + string matcher = "\"matches\":" + JsonSerializer.Serialize(pattern); + foreach (string form in new[] { "element", "flow", "source", "target", "interaction-source", "interaction-target", "interaction-flow" }) + { + Assert.AreEqual(expected ? 1 : 0, this.RunPropertyRule(matcher, value, form).Messages.Count, form); + } + } + + /// Missing evidence fails a regex requirement, and excessive input aborts analysis. + [TestMethod] + public void RegexRequirementsAndInputLimitsAreExplicit() + { + Assert.AreEqual(1, this.RunPropertyRule("\"matches\":\"^svc-\"", null, "element", requirement: true).Messages.Count); + Assert.AreEqual(1, this.RunPropertyRule("\"matches\":\"^a+$\"", new string('a', 4096), "element").Messages.Count); + InvalidDataException error = Assert.Throws( + () => this.RunPropertyRule("\"matches\":\".*\"", new string('a', 4097), "element")); + StringAssert.Contains(error.Message, "regex input"); + StringAssert.Contains(error.Message, "4096"); + } + + /// A timeout aborts analysis even when the failed predicate is under negation. + [TestMethod] + public void RegexTimeoutIsNeverAFalsePredicate() + { + string matcher = "\"matches\":\"^(a+)+$\""; + foreach (string form in new[] { "element", "interaction-source" }) + { + InvalidDataException error = Assert.Throws( + () => this.RunPropertyRule(matcher, new string('a', 4095) + "!", form, requirement: true)); + StringAssert.Contains(error.Message, "property-matchers/VALUE"); + StringAssert.Contains(error.Message, "timeout"); + Assert.IsInstanceOfType(error.InnerException); + } + } + + /// The shared regex budget is cumulative and remains exhausted for subsequent rules. + [TestMethod] + public void RegexTimeBudgetIsShared() + { + RuleEvaluationContext context = new RuleEvaluationContext(new ThreatModel(), new MockMessageWriter()); + context.AccountRegexTime(TimeSpan.FromMilliseconds(600)); + Assert.Throws(() => context.AccountRegexTime(TimeSpan.FromMilliseconds(401))); + Assert.Throws(() => context.AccountRegexTime(TimeSpan.Zero)); + } + + /// Invalid patterns reject their whole pack with rule-local diagnostics. + /// An invalid or empty pattern. + [TestMethod] + [DataRow("[")] + [DataRow("")] + [DataRow("(?invalid)")] + public void RejectsInvalidRegexPatterns(string pattern) + { + string predicate = "{\"property\":\"Cache Type\",\"matches\":" + JsonSerializer.Serialize(pattern) + "}"; + string rule = "{\"id\":\"REGEX\",\"appliesTo\":\"process\",\"message\":\"regex\",\"when\":" + predicate + "}"; + List diagnostics = new List(); + RuleBundle bundle = DeclarativeRuleProvider.LoadBundle( + new[] { this.WriteSpec(VersionTwoSpec("regex-invalid", V2Rule + "," + rule)) }, diagnostics.Add); + Assert.AreEqual(0, bundle.Rules.Count); + Assert.AreEqual(0, bundle.Packs.Count); + Assert.IsTrue(diagnostics.Any(message => message.Contains("REGEX"))); + } + + /// Regex text and distinct-pattern counts are bounded across all loaded sources. + [TestMethod] + public void RegexPatternLimitsAndReuse() + { + string RuleText(string id, string pattern) => + "{\"id\":\"" + id + "\",\"appliesTo\":\"process\",\"message\":\"regex\",\"when\":{\"property\":\"Cache Type\",\"matches\":" + JsonSerializer.Serialize(pattern) + "}}"; + List diagnostics = new List(); + string overlong = RuleText("LONG", new string('a', 1025)); + RuleBundle rejected = DeclarativeRuleProvider.LoadBundle( + new[] { this.WriteSpec(VersionTwoSpec("regex-long", overlong)) }, diagnostics.Add); + Assert.AreEqual(0, rejected.Rules.Count); + Assert.IsTrue(diagnostics.Any(message => message.Contains("1024"))); + + string repeated = string.Join(",", Enumerable.Range(0, 130).Select(index => RuleText("R" + index, "^svc$"))); + RuleBundle reused = DeclarativeRuleProvider.LoadBundle(new[] { this.WriteSpec(VersionTwoSpec("regex-reuse", repeated)) }); + Assert.AreEqual(130, reused.Rules.Count); + DeclarativeRule first = (DeclarativeRule)reused.Rules[0]; + DeclarativeRule second = (DeclarativeRule)reused.Rules[1]; + Assert.AreSame(first.CompiledExpression.Pattern, second.CompiledExpression.Pattern); + + string distinct = string.Join(",", Enumerable.Range(0, 128).Select(index => RuleText("D" + index, "^svc" + index + "$"))); + string extra = RuleText("EXTRA", "^other$"); + RuleBundle limited = DeclarativeRuleProvider.LoadBundle( + new[] { this.WriteSpec(VersionTwoSpec("regex-first", distinct)), this.WriteSpec(VersionTwoSpec("regex-extra", extra)) }, diagnostics.Add); + Assert.AreEqual(128, limited.Rules.Count); + Assert.AreEqual(1, limited.Packs.Count); + Assert.IsTrue(diagnostics.Any(message => message.Contains("128 distinct regex"))); + } + + /// Connectivity follows directed edges and never invents a zero-hop path. + /// The connectivity condition. + /// The kind of component being checked. + /// The expected finding targets. + [TestMethod] + [DataRow("\"reachableFrom\":{\"kind\":\"external\"}", "process", "Gateway,Worker")] + [DataRow("\"reachableFrom\":{\"kind\":\"process\"}", "process", "Worker")] + [DataRow("\"reachableFrom\":{\"kind\":\"external\"}", "external", "")] + [DataRow("\"reachableFrom\":{\"kind\":\"datastore\"}", "process", "")] + [DataRow("\"connectsTo\":{\"kind\":\"datastore\"}", "process", "Worker")] + [DataRow("\"connectsTo\":{\"kind\":\"process\"}", "process", "Gateway")] + [DataRow("\"connectsTo\":{\"kind\":\"external\"}", "process", "")] + [DataRow("\"reachableFrom\":{\"kind\":\"external\"},\"connectsTo\":{\"kind\":\"datastore\"}", "process", "Worker")] + public void ConnectivityIsDirectedAndNonReflexive(string predicate, string appliesTo, string names) + { + ThreatModel model = CreateConnectivityModel(); + Rule rule = this.LoadGraphRule(predicate, appliesTo: appliesTo); + MockMessageWriter writer = new MockMessageWriter(); + + rule.Evaluate(new RuleEvaluationContext(model, writer)); + + string actual = string.Join(",", writer.Messages.Select(message => message.Target!.Name()).OrderBy(name => name, StringComparer.Ordinal)); + Assert.AreEqual(names, actual); + } + + /// Interaction expressions can inspect connectivity and primitive kinds at either endpoint. + /// The endpoint predicate. + /// The endpoint being inspected. + /// The expected matching flows. + [TestMethod] + [DataRow("\"reachableFrom\":{\"kind\":\"external\"}", "target", 3)] + [DataRow("\"reachableFrom\":{\"kind\":\"external\"}", "source", 2)] + [DataRow("\"connectsTo\":{\"kind\":\"datastore\"}", "source", 1)] + [DataRow("\"connectsTo\":{\"kind\":\"datastore\"}", "target", 1)] + [DataRow("\"kind\":\"external\"", "source", 1)] + [DataRow("\"kind\":\"external\"", "target", 0)] + [DataRow("\"kind\":\"datastore\"", "target", 1)] + public void InteractionConnectivityUsesTheChosenEndpoint(string predicate, string subject, int count) + { + Rule rule = this.LoadGraphRule(predicate, interaction: true, subject: subject); + MockMessageWriter writer = new MockMessageWriter(); + rule.Evaluate(new RuleEvaluationContext(CreateConnectivityModel(), writer)); + Assert.AreEqual(count, writer.Messages.Count); + } + + /// Explicit cycles and self-loops supply positive-length paths without infinite traversal. + [TestMethod] + public void ConnectivityHandlesCyclesSelfLoopsAndParallelEdges() + { + ThreatModel model = CreateConnectivityModel(); + DrawingSurfaceModel page = model.DrawingSurfaceList[0]; + Entity gateway = page.Components().Single(element => element.Name() == "Gateway"); + Entity worker = page.Components().Single(element => element.Name() == "Worker"); + Entity isolated = page.Components().Single(element => element.Name() == "Isolated"); + AddGraphFlow(page, worker, gateway); + AddGraphFlow(page, worker, gateway); + AddGraphFlow(page, isolated, isolated); + Rule rule = this.LoadGraphRule("\"reachableFrom\":{\"kind\":\"process\"}"); + MockMessageWriter writer = new MockMessageWriter(); + rule.Evaluate(new RuleEvaluationContext(model, writer)); + Assert.AreEqual(3, writer.Messages.Count); + Assert.AreEqual(3, writer.Messages.Select(message => message.Target!.Guid).Distinct().Count()); + } + + /// Connectivity is page-local and does not infer access control from boundary geometry. + [TestMethod] + public void ConnectivityIgnoresBoundaryGeometryButNeverCrossesPages() + { + ThreatModel model = CreateConnectivityModel(); + DrawingSurfaceModel first = model.DrawingSurfaceList[0]; + DrawingSurfaceModel second = new DrawingSurfaceModel { Header = "Other page" }; + foreach (KeyValuePair component in first.Borders) + { + second.Borders.Add(component.Key, component.Value); + } + + model.DrawingSurfaceList.Add(second); + BorderBoundary boundary = new BorderBoundary { Guid = Guid.NewGuid(), Left = -1000, Top = -1000, Width = 2000, Height = 2000 }; + first.Borders.Add(boundary.Guid, boundary); + Rule rule = this.LoadGraphRule("\"reachableFrom\":{\"kind\":\"external\"}"); + MockMessageWriter before = new MockMessageWriter(); + rule.Evaluate(new RuleEvaluationContext(model, before)); + boundary.Left = 5000; + MockMessageWriter after = new MockMessageWriter(); + rule.Evaluate(new RuleEvaluationContext(model, after)); + Assert.AreEqual(2, after.Messages.Count); + Assert.IsTrue(after.Messages.All(message => ReferenceEquals(first, message.Model))); + CollectionAssert.AreEquivalent(before.Messages.Select(message => message.Target!.Guid).ToArray(), after.Messages.Select(message => message.Target!.Guid).ToArray()); + } + + /// Connectivity filters reuse property aliases, numeric/regex matching, and policy bindings. + /// Whether the rule uses the interaction dialect. + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void ConnectivityFiltersReusePropertyMatchers(bool interaction) + { + ThreatModel model = CreateConnectivityModel(); + Entity entry = model.DrawingSurfaceList[0].Components().Single(element => element.Name() == "Entry"); + entry.Properties.Add(new CustomStringDisplayAttribute { Value = "Cache Type:42" }); + string predicate = "\"reachableFrom\":{\"kind\":\"external\",\"property\":\"cacheType\",\"greaterThan\":40,\"matches\":\"^[0-9]+$\"}"; + Rule rule = this.LoadGraphRule(predicate, interaction: interaction); + MockMessageWriter writer = new MockMessageWriter(); + rule.Evaluate(new RuleEvaluationContext(model, writer)); + Assert.AreEqual(interaction ? 3 : 2, writer.Messages.Count); + Assert.IsTrue(rule.PropertyBindings.Any(binding => binding.AppliesTo == "external" && binding.PropertyName == "Cache Type")); + entry.Properties.RemoveAt(entry.Properties.Count - 1); + MockMessageWriter absent = new MockMessageWriter(); + rule.Evaluate(new RuleEvaluationContext(model, absent)); + Assert.AreEqual(0, absent.Messages.Count); + } + + /// Long cyclic paths are iterative and consume work proportional to vertices and edges. + /// The number of intermediate components. + [TestMethod] + [DataRow(256)] + [DataRow(4096)] + public void ConnectivityWorkIsLinearForLargeCycles(int count) + { + DrawingSurfaceModel page = new DrawingSurfaceModel(); + List nodes = Enumerable.Range(0, count) + .Select(index => CreateEntity("GE.P", "GE.P", "Node " + index)).ToList(); + StencilParallelLines store = CreateEntity("GE.DS", "GE.DS", "Store"); + foreach (StencilEllipse node in nodes) + { + page.Borders.Add(node.Guid, node); + } + + page.Borders.Add(store.Guid, store); + for (int index = 0; index < count - 1; index++) + { + AddGraphFlow(page, nodes[index], nodes[index + 1]); + } + + AddGraphFlow(page, nodes[count - 1], nodes[0]); + AddGraphFlow(page, nodes[count - 1], store); + ThreatModel model = new ThreatModel { DrawingSurfaceList = { page } }; + Rule rule = this.LoadGraphRule("\"reachableFrom\":{\"kind\":\"external\"}", appliesTo: "datastore"); + MockMessageWriter writer = new MockMessageWriter(); + RuleEvaluationContext context = new RuleEvaluationContext(model, writer); + context.SetDeclarativeOperationLimit((10 * count) + 100); + rule.Evaluate(context); + Assert.AreEqual(0, writer.Messages.Count); + Assert.IsTrue(context.GetDeclarativeOperationCount() < (10 * count) + 100); + context.SetDeclarativeOperationLimit(context.GetDeclarativeOperationCount() + count + 10); + Assert.Throws(() => rule.Evaluate(context)); + } + + /// Connectivity rejects ambiguous filters and invalid subjects before evaluation. + /// The invalid predicate. + /// Whether to use the interaction dialect. + /// The flat appliesTo or interaction subject. + [TestMethod] + [DataRow("\"reachableFrom\":{}", false, "process")] + [DataRow("\"reachableFrom\":{\"kind\":\"flow\"}", false, "process")] + [DataRow("\"connectsTo\":{\"kind\":\"process\"}", false, "flow")] + [DataRow("\"connectsTo\":{\"kind\":\"process\"}", true, "flow")] + [DataRow("\"connectsTo\":{},\"reachableFrom\":{}", true, "source")] + [DataRow("\"connectsTo\":{\"greaterThan\":10}", true, "source")] + [DataRow("\"connectsTo\":{\"kind\":\"unknown\"}", true, "source")] + [DataRow("\"connectsTo\":{\"property\":\"Missing\"}", true, "source")] + [DataRow("\"connectsTo\":{\"property\":\"cacheType\",\"equals\":\"INVALID\"}", true, "source")] + [DataRow("\"kind\":\"process\",\"type\":\"GE.P\"", true, "source")] + [DataRow("\"kind\":\"process\"", true, "flow")] + public void RejectsInvalidConnectivityPredicates(string predicate, bool interaction, string subject) + { + string rule = GraphRuleText(predicate, interaction, subject, subject); + string spec = VersionTwoSpec("invalid-connectivity", rule); + if (interaction) + { + spec = spec.Replace(RulePackDialects.FlatV1, RulePackDialects.InteractionV1); + } + + List diagnostics = new List(); + RuleBundle bundle = DeclarativeRuleProvider.LoadBundle(new[] { this.WriteSpec(spec) }, diagnostics.Add); + Assert.AreEqual(0, bundle.Rules.Count); + Assert.AreEqual(0, bundle.Packs.Count); + Assert.IsTrue(diagnostics.Count > 0); + } + /// /// A version 2 envelope owns the pack identity and namespaces each source rule id with it. /// @@ -1408,6 +1821,38 @@ private static T CreateEntity(string typeId, string genericTypeId, string nam return entity; } + private static ThreatModel CreateConnectivityModel() + { + DrawingSurfaceModel page = new DrawingSurfaceModel { Header = "Connectivity" }; + StencilRectangle entry = CreateEntity("GE.EI", "GE.EI", "Entry"); + StencilEllipse gateway = CreateEntity("GE.P", "GE.P", "Gateway"); + StencilEllipse worker = CreateEntity("GE.P", "GE.P", "Worker"); + StencilParallelLines store = CreateEntity("GE.DS", "GE.DS", "Store"); + StencilEllipse isolated = CreateEntity("GE.P", "GE.P", "Isolated"); + foreach (Entity component in new Entity[] { entry, gateway, worker, store, isolated }) + { + page.Borders.Add(component.Guid, component); + } + + AddGraphFlow(page, entry, gateway); + AddGraphFlow(page, gateway, worker); + AddGraphFlow(page, worker, store); + return new ThreatModel { DrawingSurfaceList = { page } }; + } + + private static void AddGraphFlow(DrawingSurfaceModel page, Entity source, Entity target) + { + Connector flow = CreateEntity("GE.DF", "GE.DF", "Flow"); + flow.SourceGuid = source.Guid; + flow.TargetGuid = target.Guid; + page.Lines.Add(flow.Guid, flow); + } + + private static string GraphRuleText(string predicate, bool interaction, string subject, string appliesTo) => + interaction + ? "{\"id\":\"GRAPH\",\"message\":\"graph\",\"expression\":{\"subject\":\"" + subject + "\"," + predicate + "}}" + : "{\"id\":\"GRAPH\",\"appliesTo\":\"" + appliesTo + "\",\"message\":\"graph\",\"when\":{" + predicate + "}}"; + private string WriteSpec(string json, string? fileName = null) { string path = Path.Join( @@ -1416,5 +1861,78 @@ private string WriteSpec(string json, string? fileName = null) File.WriteAllText(path, json); return path; } + + private Rule LoadGraphRule(string predicate, bool interaction = false, string subject = "target", string appliesTo = "process") + { + string spec = VersionTwoSpec("connectivity", GraphRuleText(predicate, interaction, subject, appliesTo)); + if (interaction) + { + spec = spec.Replace(RulePackDialects.FlatV1, RulePackDialects.InteractionV1); + } + + List diagnostics = new List(); + RuleBundle bundle = DeclarativeRuleProvider.LoadBundle(new[] { this.WriteSpec(spec) }, diagnostics.Add); + Assert.AreEqual(1, bundle.Rules.Count, string.Join("; ", diagnostics)); + return bundle.Rules[0]; + } + + private MockMessageWriter RunPropertyRule( + string matcher, + string? value, + string form, + bool requirement = false, + long? operationLimit = null) + { + bool interaction = form.StartsWith("interaction-", StringComparison.Ordinal); + string subject = interaction ? form.Substring("interaction-".Length) : form; + string predicate = "{\"property\":\"cacheType\"," + matcher + "}"; + string condition = subject == "source" || subject == "target" + ? "{\"" + subject + "\":" + predicate + "}" + : predicate; + string rule = interaction + ? "{\"id\":\"VALUE\",\"message\":\"value\",\"expression\":{\"subject\":\"" + subject + "\",\"property\":\"cacheType\"," + matcher + "}}" + : "{\"id\":\"VALUE\",\"message\":\"value\",\"appliesTo\":\"" + (subject == "element" ? "process" : "flow") + + "\",\"" + (requirement ? "assert" : "when") + "\":" + condition + "}"; + if (interaction && requirement) + { + using JsonDocument document = JsonDocument.Parse(rule); + string expression = document.RootElement.GetProperty("expression").GetRawText(); + rule = "{\"id\":\"VALUE\",\"message\":\"value\",\"expression\":{\"not\":" + expression + "}}"; + } + + string spec = VersionTwoSpec("property-matchers", rule); + if (interaction) + { + spec = spec.Replace(RulePackDialects.FlatV1, RulePackDialects.InteractionV1); + } + + List diagnostics = new List(); + RuleBundle bundle = DeclarativeRuleProvider.LoadBundle(new[] { this.WriteSpec(spec) }, diagnostics.Add); + Assert.AreEqual(1, bundle.Rules.Count, string.Join("; ", diagnostics)); + StencilEllipse source = CreateEntity("GE.P", "GE.P", "Source"); + StencilRectangle target = CreateEntity("GE.EI", "GE.EI", "Target"); + Connector flow = CreateEntity("GE.DF", "GE.DF", "Flow"); + flow.SourceGuid = source.Guid; + flow.TargetGuid = target.Guid; + Entity candidate = subject == "flow" ? flow : subject == "target" ? target : source; + if (value != null) + { + candidate.Properties.Add(new CustomStringDisplayAttribute { Value = "Cache Type:" + value }); + } + + DrawingSurfaceModel diagram = new DrawingSurfaceModel { Header = "Matchers" }; + diagram.Borders.Add(source.Guid, source); + diagram.Borders.Add(target.Guid, target); + diagram.Lines.Add(flow.Guid, flow); + MockMessageWriter writer = new MockMessageWriter(); + RuleEvaluationContext context = new RuleEvaluationContext(new ThreatModel { DrawingSurfaceList = { diagram } }, writer); + if (operationLimit.HasValue) + { + context.SetDeclarativeOperationLimit(operationLimit.Value); + } + + bundle.Rules[0].Evaluate(context); + return writer; + } } } diff --git a/test/ThreatModelForge.Analysis.Tests/RuleEvaluationContextTests.cs b/test/ThreatModelForge.Analysis.Tests/RuleEvaluationContextTests.cs index f10cb5c..4fdcf63 100644 --- a/test/ThreatModelForge.Analysis.Tests/RuleEvaluationContextTests.cs +++ b/test/ThreatModelForge.Analysis.Tests/RuleEvaluationContextTests.cs @@ -52,6 +52,50 @@ public void ConstructorTest() Assert.AreEqual(variables["Bar"], target.Variables["BAR"]); } + /// The graph is constructed once, directed, and partitioned by page. + [TestMethod] + public void ConnectivityGraphIsSharedAndPageScoped() + { + StencilEllipse source = new StencilEllipse { Guid = Guid.NewGuid(), GenericTypeId = "GE.P" }; + StencilEllipse target = new StencilEllipse { Guid = Guid.NewGuid(), GenericTypeId = "GE.P" }; + Connector flow = new Connector { Guid = Guid.NewGuid(), SourceGuid = source.Guid, TargetGuid = target.Guid }; + DrawingSurfaceModel first = new DrawingSurfaceModel(); + first.Borders.Add(source.Guid, source); + first.Borders.Add(target.Guid, target); + first.Lines.Add(flow.Guid, flow); + DrawingSurfaceModel second = new DrawingSurfaceModel(); + second.Borders.Add(source.Guid, source); + RuleEvaluationContext context = new RuleEvaluationContext( + new ThreatModel { DrawingSurfaceList = { first, second } }, new MockMessageWriter()); + + RuleEvaluationContext.ConnectivityGraph graph = context.GetConnectivityGraph(); + long buildCost = context.GetDeclarativeOperationCount(); + Assert.IsTrue(buildCost > 0); + Assert.AreSame(graph, context.GetConnectivityGraph()); + Assert.AreEqual(buildCost, context.GetDeclarativeOperationCount()); + Assert.AreSame(target, graph.Neighbors(first, source.Guid, incoming: false)[0]); + Assert.AreSame(source, graph.Neighbors(first, target.Guid, incoming: true)[0]); + Assert.AreEqual(0, graph.Neighbors(first, target.Guid, incoming: false).Count); + Assert.AreEqual(0, graph.Neighbors(second, source.Guid, incoming: false).Count); + Assert.AreEqual(0, graph.Neighbors(first, Guid.NewGuid(), incoming: true).Count); + } + + /// Graph construction is charged before scanning lines and rejects malformed topology. + [TestMethod] + public void ConnectivityGraphRejectsInvalidTopologyAndBudgetExhaustion() + { + DrawingSurfaceModel diagram = new DrawingSurfaceModel(); + Connector dangling = new Connector { Guid = Guid.NewGuid(), SourceGuid = Guid.NewGuid(), TargetGuid = Guid.NewGuid() }; + diagram.Lines.Add(dangling.Guid, dangling); + ThreatModel model = new ThreatModel { DrawingSurfaceList = { diagram } }; + RuleEvaluationContext invalid = new RuleEvaluationContext(model, new MockMessageWriter()); + InvalidDataException error = Assert.Throws(() => invalid.GetConnectivityGraph()); + StringAssert.Contains(error.Message, "same page"); + RuleEvaluationContext limited = new RuleEvaluationContext(model, new MockMessageWriter()); + limited.SetDeclarativeOperationLimit(0); + Assert.Throws(() => limited.GetConnectivityGraph()); + } + /// /// Unit test for the constructor. /// diff --git a/test/ThreatModelForge.Analysis.Tests/RulePackModelTests.cs b/test/ThreatModelForge.Analysis.Tests/RulePackModelTests.cs index 1c68cf1..8b22ffd 100644 --- a/test/ThreatModelForge.Analysis.Tests/RulePackModelTests.cs +++ b/test/ThreatModelForge.Analysis.Tests/RulePackModelTests.cs @@ -84,5 +84,39 @@ public void VersionTwoSchemaIsEmbeddedAndParseable() definitions.GetProperty("flatRule").GetProperty("properties") .GetProperty("defaultPriority").GetProperty("$ref").GetString()); } + + /// The published schema exposes the bounded property and connectivity matcher contracts. + [TestMethod] + public void VersionTwoSchemaDeclaresAdditionalMatchers() + { + using JsonDocument schema = JsonDocument.Parse(RulePackSchema.VersionTwo); + JsonElement definitions = schema.RootElement.GetProperty("$defs"); + foreach (string shape in new[] { "condition", "nonRelationalCondition", "endpoint" }) + { + JsonElement definition = definitions.GetProperty(shape); + foreach (string matcher in new[] { "greaterThan", "greaterThanOrEqual", "lessThan", "lessThanOrEqual", "matches" }) + { + Assert.IsTrue(definition.GetProperty("properties").TryGetProperty(matcher, out _), shape + ":" + matcher); + Assert.AreEqual("property", definition.GetProperty("dependentRequired").GetProperty(matcher)[0].GetString()); + } + } + + JsonElement pattern = definitions.GetProperty("regexPattern"); + Assert.AreEqual(1, pattern.GetProperty("minLength").GetInt32()); + Assert.AreEqual(1024, pattern.GetProperty("maxLength").GetInt32()); + Assert.AreEqual(decimal.MinValue, definitions.GetProperty("decimal").GetProperty("minimum").GetDecimal()); + Assert.AreEqual(decimal.MaxValue, definitions.GetProperty("decimal").GetProperty("maximum").GetDecimal()); + string[] expressions = definitions.GetProperty("interactionExpression").GetProperty("oneOf").EnumerateArray() + .Select(expression => expression.GetProperty("$ref").GetString() ?? string.Empty).ToArray(); + foreach (string expression in new[] { "numericExpression", "regexExpression", "kindExpression", "connectivityExpression" }) + { + CollectionAssert.Contains(expressions, "#/$defs/" + expression); + } + + CollectionAssert.AreEquivalent( + new[] { "source", "target" }, + definitions.GetProperty("connectivityExpression").GetProperty("properties").GetProperty("subject") + .GetProperty("enum").EnumerateArray().Select(value => value.GetString()).ToArray()); + } } } diff --git a/test/ThreatModelForge.Api.Tests/ApiCustomRulesTest.cs b/test/ThreatModelForge.Api.Tests/ApiCustomRulesTest.cs index 3e959b9..e172350 100644 --- a/test/ThreatModelForge.Api.Tests/ApiCustomRulesTest.cs +++ b/test/ThreatModelForge.Api.Tests/ApiCustomRulesTest.cs @@ -4,12 +4,14 @@ namespace ThreatModelForge.Api.Tests using System.IO; using System.Net; using System.Net.Http; + using System.Net.Http.Json; using System.Text; using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.VisualStudio.TestTools.UnitTesting; + using ThreatModelForge.Engine; /// /// Tests the host's startup rule loading. Custom packs are deployment configuration read once at @@ -148,6 +150,24 @@ public async Task PacksCanBeNamedAsOneSemicolonSeparatedSetting() Assert.AreEqual("pack-two", packs[1].GetProperty("id").GetString()); } + /// The HTTP host evaluates added matchers identically to the shared engine. + /// A task. + [TestMethod] + public async Task AdditionalMatchersMatchTheSharedEngine() + { + (TmForgeModelDto model, EngineRuleOptions rules) = EngineCustomRulesTest.AdditionalMatchers(); + string path = Path.Join(this.WorkingDirectory, "additional-matchers.tmrules.json"); + File.WriteAllText(path, rules.Sources![0].Json); + using WebApplicationFactory factory = HostWithRules(path); + using HttpClient client = factory.CreateClient(); + using HttpResponseMessage response = await client.PostAsJsonAsync("/v1/model/analysis", model); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + AnalysisResultDto? actual = await response.Content.ReadFromJsonAsync(); + Assert.IsNotNull(actual); + AnalysisResultDto expected = EngineService.RunAnalysis(model, rules); + Assert.AreEqual(JsonSerializer.Serialize(expected), JsonSerializer.Serialize(actual)); + } + /// Builds a host that loads rule packs from the given paths. /// The configured rule pack paths. /// The factory. diff --git a/test/ThreatModelForge.Api.Tests/EngineCustomRulesTest.cs b/test/ThreatModelForge.Api.Tests/EngineCustomRulesTest.cs index 43df30b..372ef11 100644 --- a/test/ThreatModelForge.Api.Tests/EngineCustomRulesTest.cs +++ b/test/ThreatModelForge.Api.Tests/EngineCustomRulesTest.cs @@ -2,8 +2,10 @@ namespace ThreatModelForge.Api.Tests { using System; using System.Collections.Generic; + using System.IO; using System.Linq; using System.Text; + using System.Text.Json; using Microsoft.VisualStudio.TestTools.UnitTesting; using ThreatModelForge.Engine; @@ -227,6 +229,111 @@ public void MalformedPackIsReportedThroughDiagnostics() Assert.IsTrue(bundle.Diagnostics.Any(message => message.Contains("broken.tmrules.json", StringComparison.Ordinal))); } + /// New predicates preserve finding/threat identities, reports, and exported model semantics. + /// The round-trip format. + [TestMethod] + [DataRow("tmforge-json")] + [DataRow("tm7")] + public void AdditionalMatchersPreserveResultsAcrossFormats(string format) + { + (TmForgeModelDto model, EngineRuleOptions rules) = AdditionalMatchers(); + AnalysisResultDto original = EngineService.RunAnalysis(model, rules); + Assert.AreEqual(0, original.Diagnostics.Count); + FindingDto[] findings = original.Findings.Where(finding => finding.RuleId?.StartsWith("rule005/", StringComparison.Ordinal) == true).ToArray(); + ThreatDto[] threats = original.Threats.Where(threat => threat.RuleId?.StartsWith("rule005/", StringComparison.Ordinal) == true).ToArray(); + CollectionAssert.AreEquivalent( + new[] { "rule005/RETENTION", "rule005/SERVICE-NAME", "rule005/AUDIT-PATH" }, + findings.Select(finding => finding.RuleId).ToArray()); + Assert.AreEqual(3, threats.Length); + Assert.IsTrue(threats.All(threat => threat.CategoryId == "rule005/policy" && threat.Priority == "High")); + + byte[] bytes = EngineService.Convert(model, format, rules); + TmForgeModelDto restored = EngineService.ReadModel(bytes, format); + AnalysisResultDto roundTrip = EngineService.RunAnalysis(restored, rules); + CollectionAssert.AreEquivalent(findings.Select(finding => finding.Id).ToArray(), roundTrip.Findings + .Where(finding => finding.RuleId?.StartsWith("rule005/", StringComparison.Ordinal) == true).Select(finding => finding.Id).ToArray()); + CollectionAssert.AreEquivalent(threats.Select(threat => threat.Id).ToArray(), roundTrip.Threats + .Where(threat => threat.RuleId?.StartsWith("rule005/", StringComparison.Ordinal) == true).Select(threat => threat.Id).ToArray()); + Assert.IsFalse(roundTrip.Findings.Any(finding => finding.Id == "engine-error")); + Assert.AreEqual(original.RulePacks.Single().Fingerprint, roundTrip.RulePacks.Single().Fingerprint); + string html = Encoding.UTF8.GetString(EngineService.Report(model, "html", rules)); + StringAssert.Contains(html, "retention between 1 and 30 days"); + StringAssert.Contains(html, "service name beginning with svc-"); + StringAssert.Contains(html, "lacks a direct audit-store connection"); + } + + /// Existing per-rule and per-pack toggles apply to every new matcher. + /// Whether to disable the whole pack. + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void AdditionalMatchersHonorDisabledSelections(bool disablePack) + { + (TmForgeModelDto model, EngineRuleOptions rules) = AdditionalMatchers(); + TmForgeModelDto selected = new TmForgeModelDto + { + Elements = model.Elements, + Flows = model.Flows, + Analysis = new TmForgeAnalysisDto + { + DisabledPacks = disablePack ? new[] { "rule005" } : Array.Empty(), + DisabledRuleIds = disablePack ? Array.Empty() : new[] { "rule005/RETENTION" }, + }, + }; + AnalysisResultDto result = EngineService.RunAnalysis(selected, rules); + Assert.AreEqual(disablePack ? 0 : 2, result.Findings.Count(finding => finding.RuleId?.StartsWith("rule005/", StringComparison.Ordinal) == true)); + Assert.IsFalse(result.Findings.Any(finding => finding.RuleId == "rule005/RETENTION" || finding.Id == "engine-error")); + } + + /// A regex timeout is a visible failure in both projections, not an empty successful analysis. + [TestMethod] + public void RegexTimeoutIsVisibleOnTheEngineFacade() + { + EngineRuleOptions rules = new EngineRuleOptions + { + Sources = new[] + { + new RuleSourceDto + { + Name = "timeout.tmrules.json", + Json = "{\"rules\":[{\"id\":\"TIMEOUT\",\"appliesTo\":\"process\",\"message\":\"timeout\",\"assert\":{\"property\":\"Value\",\"matches\":\"^(a+)+$\"}}]}", + }, + }, + }; + TmForgeModelDto model = new TmForgeModelDto + { + Elements = new[] + { + new TmForgeElementDto + { + Id = "timeout", + Kind = "process", + Properties = new Dictionary { ["Value"] = new string('a', 4095) + "!" }, + }, + }, + }; + + AnalysisResultDto result = EngineService.RunAnalysis(model, rules); + + StringAssert.Contains(result.Findings.Single(finding => finding.Id == "engine-error").Message, "TIMEOUT"); + StringAssert.Contains(result.Threats.Single(threat => threat.Id == "engine-error").Title, "timeout"); + Assert.IsFalse(result.Findings.Any(finding => finding.RuleId == "TIMEOUT")); + } + + /// Reads identical rule content and model input for direct-engine and HTTP parity checks. + /// The fixture model and rule sources. + internal static (TmForgeModelDto Model, EngineRuleOptions Rules) AdditionalMatchers() + { + using JsonDocument fixture = JsonDocument.Parse(File.ReadAllText(Path.Join(AppContext.BaseDirectory, "Fixtures", "additional-matchers.json"))); + TmForgeModelDto model = fixture.RootElement.GetProperty("model").Deserialize(new JsonSerializerOptions(JsonSerializerDefaults.Web)) + ?? throw new InvalidDataException("The matcher fixture requires a model."); + EngineRuleOptions rules = new EngineRuleOptions + { + Sources = new[] { new RuleSourceDto { Name = "additional-matchers.tmrules.json", Json = fixture.RootElement.GetProperty("pack").GetRawText() } }, + }; + return (model, rules); + } + private static EngineRuleOptions Rules() { return new EngineRuleOptions diff --git a/test/ThreatModelForge.Api.Tests/ThreatModelForge.Api.Tests.csproj b/test/ThreatModelForge.Api.Tests/ThreatModelForge.Api.Tests.csproj index 5a46c3d..5bc0dba 100644 --- a/test/ThreatModelForge.Api.Tests/ThreatModelForge.Api.Tests.csproj +++ b/test/ThreatModelForge.Api.Tests/ThreatModelForge.Api.Tests.csproj @@ -21,4 +21,8 @@ + + + + diff --git a/test/ThreatModelForge.Cli.Tests/AnalyzeAnalysisDocumentTest.cs b/test/ThreatModelForge.Cli.Tests/AnalyzeAnalysisDocumentTest.cs index d3706ec..0783841 100644 --- a/test/ThreatModelForge.Cli.Tests/AnalyzeAnalysisDocumentTest.cs +++ b/test/ThreatModelForge.Cli.Tests/AnalyzeAnalysisDocumentTest.cs @@ -6,6 +6,7 @@ namespace ThreatModelForge.Cli.Tests using System.Linq; using System.Text.Json; using Microsoft.VisualStudio.TestTools.UnitTesting; + using ThreatModelForge.Engine; /// /// Tests that analyze --reportFolder writes the versioned analysis document. @@ -141,7 +142,47 @@ public void SuppressedFindingIsRecordedWithoutAThreatLink() "A suppressed finding must not also claim a place in the threat register."); } - private static void Run(string[] args) + /// CLI reports evaluate the same numeric, regex, and graph policy as the engine. + [TestMethod] + public void AdditionalMatchersAgreeWithTheEngine() + { + using JsonDocument fixture = JsonDocument.Parse(File.ReadAllText(Path.Join(AppContext.BaseDirectory, "Fixtures", "additional-matchers.json"))); + string modelJson = fixture.RootElement.GetProperty("model").GetRawText(); + string packJson = fixture.RootElement.GetProperty("pack").GetRawText(); + string modelPath = Path.Join(this.WorkingDirectory, "model.json"); + string rulesPath = Path.Join(this.WorkingDirectory, "matchers.tmrules.json"); + File.WriteAllText(modelPath, modelJson); + File.WriteAllText(rulesPath, packJson); + string reports = Path.Join(this.WorkingDirectory, "reports"); + + int exit = Run(new[] { modelPath, "--rules", rulesPath, "--reportFolder", reports }); + + Assert.AreEqual(2, exit); + JsonElement[] actual = ReadDocument(reports).GetProperty("findings").EnumerateArray() + .Where(finding => finding.GetProperty("ruleId").GetString()?.StartsWith("rule005/", StringComparison.Ordinal) == true).ToArray(); + TmForgeModelDto model = JsonSerializer.Deserialize(modelJson, new JsonSerializerOptions(JsonSerializerDefaults.Web)) + ?? throw new InvalidDataException("The matcher fixture requires a model."); + EngineRuleOptions rules = new EngineRuleOptions + { + Sources = new[] { new RuleSourceDto { Name = "matchers.tmrules.json", Json = packJson } }, + }; + AnalysisResultDto expected = EngineService.Analyze(model, rules); + Assert.AreEqual(3, actual.Length); + foreach (JsonElement finding in actual) + { + FindingDto engine = expected.Findings.Single(entry => entry.RuleId == finding.GetProperty("ruleId").GetString()); + Assert.AreEqual(engine.Message, finding.GetProperty("message").GetString()); + Assert.AreEqual(engine.Severity, finding.GetProperty("severity").GetString()); + Assert.AreEqual("generated-threat", finding.GetProperty("disposition").GetString()); + } + + string repeated = Path.Join(this.WorkingDirectory, "repeated"); + Assert.AreEqual(2, Run(new[] { modelPath, "--rules", rulesPath, "--reportFolder", repeated })); + Assert.AreEqual(ReadDocument(reports).GetRawText(), ReadDocument(repeated).GetRawText()); + Assert.AreEqual(modelJson, File.ReadAllText(modelPath)); + } + + private static int Run(string[] args) { TextWriter originalOut = Console.Out; TextWriter originalError = Console.Error; @@ -151,7 +192,7 @@ private static void Run(string[] args) Console.SetError(errorWriter); try { - AnalyzeCommand.Run(args); + return AnalyzeCommand.Run(args); } finally { diff --git a/test/ThreatModelForge.Cli.Tests/McpToolsTest.cs b/test/ThreatModelForge.Cli.Tests/McpToolsTest.cs index d5db3eb..12555d1 100644 --- a/test/ThreatModelForge.Cli.Tests/McpToolsTest.cs +++ b/test/ThreatModelForge.Cli.Tests/McpToolsTest.cs @@ -6,6 +6,7 @@ namespace ThreatModelForge.Cli.Tests using System.IO; using System.IO.Compression; using System.Linq; + using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.VisualStudio.TestTools.UnitTesting; using ThreatModelForge.Engine; @@ -92,6 +93,28 @@ public void Apply_ThenAnalyze_ThreadsModelThroughTools() Assert.IsFalse(findings.Any(finding => finding.Id == "engine-error")); } + /// The sandboxed MCP rule path preserves results from all three added matcher families. + [TestMethod] + public void AdditionalMatchersAgreeWithTheEngine() + { + using JsonDocument fixture = JsonDocument.Parse(File.ReadAllText(Path.Join(AppContext.BaseDirectory, "Fixtures", "additional-matchers.json"))); + TmForgeModelDto model = fixture.RootElement.GetProperty("model").Deserialize(new JsonSerializerOptions(JsonSerializerDefaults.Web)) + ?? throw new InvalidDataException("The matcher fixture requires a model."); + string packJson = fixture.RootElement.GetProperty("pack").GetRawText(); + File.WriteAllText(Path.Join(this.WorkingDirectory, "matchers.tmrules.json"), packJson); + using ServiceProvider services = CreateServices(this.WorkingDirectory); + EngineRuleOptions rules = new EngineRuleOptions + { + Sources = new[] { new RuleSourceDto { Name = "matchers.tmrules.json", Json = packJson } }, + }; + + IReadOnlyList actual = McpModelTools.Analyze(model, services, rulesPath: "matchers.tmrules.json"); + IReadOnlyList expected = EngineService.Analyze(model, rules).Findings; + + Assert.AreEqual(3, actual.Count(finding => finding.RuleId?.StartsWith("rule005/", StringComparison.Ordinal) == true)); + Assert.AreEqual(JsonSerializer.Serialize(expected), JsonSerializer.Serialize(actual)); + } + /// /// Verifies that save writes a model to disk and read loads it back. /// diff --git a/test/ThreatModelForge.Cli.Tests/ThreatModelForge.Cli.Tests.csproj b/test/ThreatModelForge.Cli.Tests/ThreatModelForge.Cli.Tests.csproj index df18b8e..5b2ea12 100644 --- a/test/ThreatModelForge.Cli.Tests/ThreatModelForge.Cli.Tests.csproj +++ b/test/ThreatModelForge.Cli.Tests/ThreatModelForge.Cli.Tests.csproj @@ -19,4 +19,8 @@ + + + +