From 5abdcb6eeec249a63b64c2e79d72c16422056eb7 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 28 Apr 2026 23:18:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(rules):=20preconditions=20gate=20=E2=80=94?= =?UTF-8?q?=20skip=20rules=20silently=20on=20non-matching=20device=20facts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional `preconditions` array to analyze rules. Preconditions evaluate BEFORE conditions and are AND-combined; any failing precondition silently skips the rule (no RuleResult, no UI card, no tenant-visible "rule fired"). Distinct from `conditions`, which decide whether the rule fires given that it applies. Use case: rules that should only run on certain hardware/OS profiles (e.g. "skip ANALYZE-SEC-001 on virtual machines because UEFI CA 2023 deployment is host-controlled"). Backend - New `RulePrecondition` model + `Preconditions` list on `AnalyzeRule`. - JSON schema gains optional top-level `preconditions` referencing a new `rulePrecondition` definition (no signal/required/correlation noise — pure boolean gate). - `RuleEngine.EvaluateRule` runs preconditions first; on failure returns null. `EvaluatePrecondition` reuses MatchesEventType / GetDataFieldValue / MatchesOperator. Operator semantics: * not_exists → passes when event type absent OR field empty (handy for "old agent without the new field" path). * exists / others → fail closed when event type or field missing. - `TableStorageService` rule round-trip extended with `PreconditionsJson` on Store + Map (column-mapped, not blob — same pattern as Conditions). Web (analyze-rules editor) - `AnalyzeRule` / `RuleForm` types gain `preconditions` array; form payload round-trips on POST + PUT; ruleToForm + EMPTY_FORM updated. - `AnalyzeRuleFormFields`: new amber Preconditions panel above Conditions with eventType/dataField/operator/value/description rows. - `AnalyzeRuleCard`: read-only chip section above Conditions with "skipped silently if any fails" hint. Tests - `RuleEnginePreconditionTests` (7 cases): empty preconditions = legacy behavior, single pass/fail, AND-semantics with two preconditions, not_exists passes when event missing, equals fails closed when event missing, baseline. - 781/781 backend, 507/507 V1 agent, 782/782 V2 agent, 208/208 web vitest. No agent change yet — useful preconditions can already target existing event fields (os_info.edition, hardware_spec.cpuName, tpm_status.specVersion, …). Adding `hardware_spec.isVirtualMachine` is a follow-up PR (agent collector). Co-Authored-By: Claude Opus 4.7 (1M context) --- rules/schema/analyze-rule.schema.json | 41 +++ .../RuleEnginePreconditionTests.cs | 248 ++++++++++++++++++ .../RuleEngine.ConditionEvaluators.cs | 46 ++++ .../Services/RuleEngine.cs | 17 ++ .../Services/TableStorageService.Rules.cs | 2 + .../Models/Rules/AnalyzeRule.cs | 48 ++++ .../components/AnalyzeRuleCard.tsx | 21 ++ .../components/AnalyzeRuleFormFields.tsx | 47 +++- .../app/analyze-rules/page.tsx | 2 + .../app/analyze-rules/types.ts | 23 ++ 10 files changed, 492 insertions(+), 3 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/RuleEnginePreconditionTests.cs diff --git a/rules/schema/analyze-rule.schema.json b/rules/schema/analyze-rule.schema.json index d2436725..7c2b3ebf 100644 --- a/rules/schema/analyze-rule.schema.json +++ b/rules/schema/analyze-rule.schema.json @@ -81,6 +81,12 @@ "items": { "$ref": "#/$defs/ruleCondition" }, "minItems": 1 }, + "preconditions": { + "type": "array", + "description": "Optional device-fact gates evaluated BEFORE conditions. ALL preconditions must pass; if any fails the rule is silently skipped — no result, no UI card. Useful for filtering out hardware/OS profiles where the rule does not apply (e.g. virtual machines).", + "items": { "$ref": "#/$defs/rulePrecondition" }, + "default": [] + }, "baseConfidence": { "type": "integer", "description": "Base confidence score (0-100) when required conditions match.", @@ -223,6 +229,41 @@ "required": ["signal", "source"], "additionalProperties": false }, + "rulePrecondition": { + "type": "object", + "description": "A device-fact gate evaluated before conditions. Pure boolean filter — does not contribute to evidence or confidence.", + "properties": { + "source": { + "type": "string", + "description": "Currently only event_data is supported.", + "enum": ["event_data"], + "default": "event_data" + }, + "eventType": { + "type": "string", + "description": "Event type carrying the field to test (e.g., \"hardware_spec\", \"os_info\", \"tpm_status\")." + }, + "dataField": { + "type": "string", + "description": "Data field to test. Uses dot notation for nested fields." + }, + "operator": { + "type": "string", + "description": "Comparison operator. Same vocabulary as conditions.", + "enum": ["equals", "not_equals", "contains", "not_contains", "regex", "not_regex", "gt", "lt", "gte", "lte", "exists", "not_exists", "in", "not_in"] + }, + "value": { + "type": "string", + "description": "Value to compare against. Boolean values are stringified (\"true\"/\"false\")." + }, + "description": { + "type": "string", + "description": "Human-readable note explaining the intent (e.g., \"skip on virtual machines\")." + } + }, + "required": ["source", "operator"], + "additionalProperties": false + }, "confidenceFactor": { "type": "object", "description": "A factor that increases confidence when matched.", diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RuleEnginePreconditionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RuleEnginePreconditionTests.cs new file mode 100644 index 00000000..f6c2a7db --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/RuleEnginePreconditionTests.cs @@ -0,0 +1,248 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pins the precondition gate semantics on : +/// preconditions evaluate BEFORE conditions, are AND-combined, and a failing precondition +/// causes the rule to be silently skipped (no emitted, no UI card, +/// no telemetry-visible "rule fired"). Distinct from conditions which decide whether the +/// rule fires given that it applies. +/// +public class RuleEnginePreconditionTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + /// + /// Baseline: rule with no preconditions and a matching condition fires as expected. + /// Confirms the test harness is wired correctly before we exercise gating. + /// + [Fact] + public async Task NoPreconditions_RuleFires_WhenConditionMatches() + { + var rule = MakeSecureBootRule(preconditions: new List()); + var events = new List + { + HardwareSpecEvent(isVirtualMachine: "false"), + SecureBootStatusEvent(uefiCA2023Status: "unknown", secureBootEnabled: "true") + }; + + var outcome = await RunAsync(rule, events); + + Assert.Single(outcome.Results); + Assert.Equal(rule.RuleId, outcome.Results[0].RuleId); + } + + /// + /// Precondition passes (device is NOT a VM) → rule evaluates conditions and fires. + /// + [Fact] + public async Task PreconditionPasses_RuleFires() + { + var rule = MakeSecureBootRule(preconditions: new List + { + new() { Source = "event_data", EventType = "hardware_spec", DataField = "isVirtualMachine", Operator = "equals", Value = "false" } + }); + var events = new List + { + HardwareSpecEvent(isVirtualMachine: "false"), + SecureBootStatusEvent(uefiCA2023Status: "unknown", secureBootEnabled: "true") + }; + + var outcome = await RunAsync(rule, events); + + Assert.Single(outcome.Results); + } + + /// + /// Precondition fails (device IS a VM) → rule is silently skipped. No result row, even + /// though the underlying condition would have matched. + /// + [Fact] + public async Task PreconditionFails_RuleSilentlySkipped() + { + var rule = MakeSecureBootRule(preconditions: new List + { + new() { Source = "event_data", EventType = "hardware_spec", DataField = "isVirtualMachine", Operator = "equals", Value = "false" } + }); + var events = new List + { + HardwareSpecEvent(isVirtualMachine: "true"), + SecureBootStatusEvent(uefiCA2023Status: "unknown", secureBootEnabled: "true") + }; + + var outcome = await RunAsync(rule, events); + + Assert.Empty(outcome.Results); + // The rule is still in EvaluatedRules (we attempted it) but no result was produced. + Assert.Single(outcome.EvaluatedRules); + } + + /// + /// AND-semantics: two preconditions, one passes one fails → rule skipped. + /// + [Fact] + public async Task TwoPreconditions_AnyFailure_SkipsRule() + { + var rule = MakeSecureBootRule(preconditions: new List + { + new() { Source = "event_data", EventType = "hardware_spec", DataField = "isVirtualMachine", Operator = "equals", Value = "false" }, + new() { Source = "event_data", EventType = "os_info", DataField = "edition", Operator = "equals", Value = "Enterprise" } + }); + var events = new List + { + HardwareSpecEvent(isVirtualMachine: "false"), // first precondition passes + OsInfoEvent(edition: "Pro"), // second precondition fails + SecureBootStatusEvent(uefiCA2023Status: "unknown", secureBootEnabled: "true") + }; + + var outcome = await RunAsync(rule, events); + + Assert.Empty(outcome.Results); + } + + /// + /// Missing event type with non-existence operator: a precondition like + /// "not_exists hardware_spec.isVirtualMachine" passes when the event isn't there at all. + /// This is the "old agent without the new field" path. + /// + [Fact] + public async Task NotExistsOperator_PassesWhenEventMissing() + { + var rule = MakeSecureBootRule(preconditions: new List + { + new() { Source = "event_data", EventType = "hardware_spec", DataField = "isVirtualMachine", Operator = "not_exists", Value = "" } + }); + var events = new List + { + // No hardware_spec event at all + SecureBootStatusEvent(uefiCA2023Status: "unknown", secureBootEnabled: "true") + }; + + var outcome = await RunAsync(rule, events); + + Assert.Single(outcome.Results); + } + + /// + /// Missing event with comparison operator (equals, contains, …) → fail-closed (rule skipped). + /// We refuse to fire a rule when its precondition can't be assessed; better silent than wrong. + /// + [Fact] + public async Task EqualsOperator_FailsClosed_WhenEventMissing() + { + var rule = MakeSecureBootRule(preconditions: new List + { + new() { Source = "event_data", EventType = "hardware_spec", DataField = "isVirtualMachine", Operator = "equals", Value = "false" } + }); + var events = new List + { + // No hardware_spec event + SecureBootStatusEvent(uefiCA2023Status: "unknown", secureBootEnabled: "true") + }; + + var outcome = await RunAsync(rule, events); + + Assert.Empty(outcome.Results); + } + + /// + /// Empty preconditions array (or null) is the legacy default — must behave identically + /// to today: condition match → rule fires. + /// + [Fact] + public async Task EmptyPreconditions_BehavesLikeBefore() + { + var rule = MakeSecureBootRule(preconditions: null); + var events = new List + { + SecureBootStatusEvent(uefiCA2023Status: "unknown", secureBootEnabled: "true") + }; + + var outcome = await RunAsync(rule, events); + + Assert.Single(outcome.Results); + } + + // ===== Helpers ===== + + private static AnalyzeRule MakeSecureBootRule(List? preconditions) + { + return new AnalyzeRule + { + RuleId = "ANALYZE-SEC-001", + Title = "Secure Boot UEFI CA 2023 certificate not deployed", + Severity = "warning", + Category = "security", + Enabled = true, + BaseConfidence = 70, + ConfidenceThreshold = 40, + Preconditions = preconditions ?? new List(), + Conditions = new List + { + new() { Signal = "ca2023_not_updated", Source = "event_data", EventType = "secureboot_status", DataField = "uefiCA2023Status", Operator = "not_equals", Value = "Updated", Required = true }, + new() { Signal = "secureboot_enabled", Source = "event_data", EventType = "secureboot_status", DataField = "uefiSecureBootEnabled", Operator = "equals", Value = "true", Required = true } + }, + Explanation = "test" + }; + } + + private static EnrollmentEvent HardwareSpecEvent(string isVirtualMachine) => new() + { + EventId = Guid.NewGuid().ToString(), + TenantId = TenantId, + SessionId = SessionId, + EventType = "hardware_spec", + Timestamp = DateTime.UtcNow, + Sequence = 1, + Data = new Dictionary { ["isVirtualMachine"] = isVirtualMachine } + }; + + private static EnrollmentEvent OsInfoEvent(string edition) => new() + { + EventId = Guid.NewGuid().ToString(), + TenantId = TenantId, + SessionId = SessionId, + EventType = "os_info", + Timestamp = DateTime.UtcNow, + Sequence = 2, + Data = new Dictionary { ["edition"] = edition } + }; + + private static EnrollmentEvent SecureBootStatusEvent(string uefiCA2023Status, string secureBootEnabled) => new() + { + EventId = Guid.NewGuid().ToString(), + TenantId = TenantId, + SessionId = SessionId, + EventType = "secureboot_status", + Timestamp = DateTime.UtcNow, + Sequence = 3, + Data = new Dictionary + { + ["uefiCA2023Status"] = uefiCA2023Status, + ["uefiSecureBootEnabled"] = secureBootEnabled + } + }; + + private static async Task RunAsync(AnalyzeRule rule, List events) + { + var ruleRepo = new Mock(); + ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List { rule }); + ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List()); + ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny())).ReturnsAsync(new Dictionary()); + ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + + var sessionRepo = new Mock(); + sessionRepo.Setup(s => s.GetSessionEventsAsync(TenantId, SessionId, It.IsAny())).ReturnsAsync(events); + + var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger.Instance); + var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger.Instance); + + return await engine.AnalyzeSessionAsync(TenantId, SessionId); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs index 71298255..e7aeb725 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs @@ -11,6 +11,52 @@ namespace AutopilotMonitor.Functions.Services /// public partial class RuleEngine { + /// + /// Evaluates a single precondition (AND-semantics gate, silent skip on failure). + /// + /// Semantics: + /// - Source must be event_data (the only currently supported source). + /// - exists: passes when at least one event of + /// carries a non-null value at . + /// - not_exists: passes when no event of that type carries the field + /// (also passes when the event type itself is absent — common case for "no VM detected"). + /// - All other operators: pass when at least one event of that type matches the + /// against . + /// Missing event type or missing field → fail closed (rule skipped). + /// + private bool EvaluatePrecondition(RulePrecondition precondition, List events) + { + if (!string.Equals(precondition.Source, "event_data", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Unsupported precondition source '{Source}' — failing closed", precondition.Source); + return false; + } + + if (string.IsNullOrEmpty(precondition.EventType) || string.IsNullOrEmpty(precondition.DataField)) + return false; + + var matchingEvents = events.Where(e => MatchesEventType(e, precondition.EventType)).ToList(); + var op = precondition.Operator?.ToLowerInvariant() ?? string.Empty; + + if (op == "not_exists") + { + if (matchingEvents.Count == 0) return true; + return matchingEvents.All(e => string.IsNullOrEmpty(GetDataFieldValue(e, precondition.DataField))); + } + + if (matchingEvents.Count == 0) + return false; + + if (op == "exists") + return matchingEvents.Any(e => !string.IsNullOrEmpty(GetDataFieldValue(e, precondition.DataField))); + + return matchingEvents.Any(e => + { + var fieldValue = GetDataFieldValue(e, precondition.DataField); + return fieldValue != null && MatchesOperator(fieldValue, precondition.Operator ?? string.Empty, precondition.Value ?? string.Empty); + }); + } + private (bool matched, object evidence) EvaluateCondition(RuleCondition condition, List events) { switch (condition.Source) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs index fc208fcd..a7c7f47b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs @@ -222,6 +222,23 @@ namespace AutopilotMonitor.Functions.Services /// private RuleResult? EvaluateRule(AnalyzeRule rule, List events) { + // Preconditions gate (AND-semantics, silent skip): if any precondition fails the + // rule is not evaluated at all — no result row, no UI card. Distinct from + // conditions, which decide whether the rule fires given that it applies. + if (rule.Preconditions != null && rule.Preconditions.Count > 0) + { + foreach (var pre in rule.Preconditions) + { + if (!EvaluatePrecondition(pre, events)) + { + _logger.LogDebug( + "Rule {RuleId} skipped by precondition (eventType={EventType}, field={Field}, op={Op}, value={Value})", + rule.RuleId, pre.EventType, pre.DataField, pre.Operator, pre.Value); + return null; + } + } + } + var matchedConditions = new Dictionary(); int confidence = rule.BaseConfidence; bool allRequiredMet = true; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs index 3091551e..b062184f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs @@ -361,6 +361,7 @@ namespace AutopilotMonitor.Functions.Services ["IsBuiltIn"] = rule.IsBuiltIn, ["IsCommunity"] = rule.IsCommunity, ["Trigger"] = rule.Trigger ?? "single", + ["PreconditionsJson"] = JsonConvert.SerializeObject(rule.Preconditions ?? new List()), ["ConditionsJson"] = JsonConvert.SerializeObject(rule.Conditions ?? new List()), ["BaseConfidence"] = rule.BaseConfidence, ["ConfidenceFactorsJson"] = JsonConvert.SerializeObject(rule.ConfidenceFactors ?? new List()), @@ -487,6 +488,7 @@ namespace AutopilotMonitor.Functions.Services IsBuiltIn = entity.GetBoolean("IsBuiltIn") ?? false, IsCommunity = entity.GetBoolean("IsCommunity") ?? false, Trigger = entity.GetString("Trigger") ?? "single", + Preconditions = DeserializeJson>(entity.GetString("PreconditionsJson")), Conditions = DeserializeJson>(entity.GetString("ConditionsJson")), BaseConfidence = entity.GetInt32("BaseConfidence") ?? 50, ConfidenceFactors = DeserializeJson>(entity.GetString("ConfidenceFactorsJson")), diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Rules/AnalyzeRule.cs b/src/Shared/AutopilotMonitor.Shared/Models/Rules/AnalyzeRule.cs index ff9d20d8..19e2c830 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Rules/AnalyzeRule.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Rules/AnalyzeRule.cs @@ -69,6 +69,13 @@ namespace AutopilotMonitor.Shared.Models // ===== MATCHING CONDITIONS ===== + /// + /// Optional device-fact gates evaluated BEFORE conditions. ALL preconditions must pass; + /// if any fails the rule is silently skipped — no result, no UI card. Used to filter out + /// hardware/OS profiles where a rule does not apply (e.g. "skip on virtual machines"). + /// + public List Preconditions { get; set; } = new List(); + /// /// Conditions that must be evaluated against the event stream /// All required conditions must match for the rule to fire @@ -255,6 +262,47 @@ namespace AutopilotMonitor.Shared.Models public string EventAFilterValue { get; set; } = default!; } + /// + /// A device-fact gate evaluated before a rule's conditions. Pure boolean filter — + /// does not contribute to evidence or confidence. When any precondition on a rule + /// fails, the rule is silently skipped (no result emitted). Currently only + /// event_data source is supported. + /// + public class RulePrecondition + { + /// + /// Source of the fact. Currently only event_data. + /// + public string Source { get; set; } = "event_data"; + + /// + /// Event type carrying the field to test (e.g., hardware_spec, os_info). + /// + public string EventType { get; set; } = default!; + + /// + /// Data field to test (dot notation supported for nested fields). + /// + public string DataField { get; set; } = default!; + + /// + /// Comparison operator. Same vocabulary as + /// minus the count_/correlation-specific operators. + /// + public string Operator { get; set; } = default!; + + /// + /// Value to compare against. Boolean values are stringified ("true"/"false"). + /// + public string Value { get; set; } = default!; + + /// + /// Optional human-readable note explaining the intent + /// (e.g., "skip on virtual machines"). Not evaluated. + /// + public string? Description { get; set; } + } + /// /// A factor that increases confidence when matched /// diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx index 2d55a71f..3f28a54b 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx @@ -192,6 +192,27 @@ export default function AnalyzeRuleCard({

{rule.description}

+ {rule.preconditions && rule.preconditions.length > 0 && ( +
+

Preconditions ({rule.preconditions.length})

+

Rule is silently skipped if any of these fails.

+
+ {rule.preconditions.map((pre, idx) => ( +
+

+ Event: {pre.eventType} + {pre.dataField && ( | Field: {pre.dataField})} + {" "} + {pre.operator} + {pre.value !== "" && (<> {pre.value})} +

+ {pre.description && (

{pre.description}

)} +
+ ))} +
+
+ )} + {rule.conditions.length > 0 && (

Conditions ({rule.conditions.length})

diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleFormFields.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleFormFields.tsx index 355a6a4b..0bd82c13 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleFormFields.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleFormFields.tsx @@ -1,9 +1,9 @@ "use client"; import { - RuleForm, RuleCondition, - CATEGORIES, SEVERITIES, TRIGGERS, OPERATORS, SOURCES, - EMPTY_CONDITION, EMPTY_FACTOR, + RuleForm, RuleCondition, RulePrecondition, + CATEGORIES, SEVERITIES, TRIGGERS, OPERATORS, SOURCES, PRECONDITION_OPERATORS, + EMPTY_CONDITION, EMPTY_FACTOR, EMPTY_PRECONDITION, } from "../types"; interface AnalyzeRuleFormFieldsProps { @@ -63,6 +63,47 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
+ {/* Preconditions (device-fact gates evaluated BEFORE conditions) */} +
+
+
+ +

Optional. Rule is silently skipped (no result) when ANY precondition fails. Useful e.g. to skip on virtual machines.

+
+ +
+ {form.preconditions.length === 0 ? ( +

No preconditions. Rule applies to every device.

+ ) : ( +
+ {form.preconditions.map((pre, idx) => { + const updatePre = (patch: Partial) => { + const p = [...form.preconditions]; + p[idx] = { ...p[idx], ...patch }; + setForm({ ...form, preconditions: p }); + }; + return ( +
+
+ Precondition {idx + 1} + +
+
+ updatePre({ eventType: e.target.value })} placeholder="Event type (e.g. hardware_spec)" autoComplete="off" className="px-3 py-1.5 border border-amber-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-amber-500" /> + updatePre({ dataField: e.target.value })} placeholder="Data field (e.g. isVirtualMachine)" autoComplete="off" className="px-3 py-1.5 border border-amber-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-amber-500" /> + + updatePre({ value: e.target.value })} placeholder="Value (e.g. false)" autoComplete="off" className="px-3 py-1.5 border border-amber-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-amber-500" /> +
+ updatePre({ description: e.target.value })} placeholder="Description (optional, e.g. 'skip on VMs')" autoComplete="off" className="w-full px-3 py-1.5 border border-amber-200 rounded text-sm text-gray-700 placeholder-gray-400 bg-white focus:outline-none focus:ring-1 focus:ring-amber-500" /> +
+ ); + })} +
+ )} +
+ {/* Conditions */}
diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx index 83bdee4c..dc9f126c 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx @@ -261,6 +261,7 @@ export default function AnalyzeRulesPage() { explanation: form.explanation.trim(), baseConfidence: form.baseConfidence, confidenceThreshold: form.confidenceThreshold, + preconditions: form.preconditions.filter(p => p.eventType.trim() && p.operator.trim()), conditions: form.conditions.filter(c => c.signal.trim()), confidenceFactors: form.confidenceFactors.filter(f => f.signal.trim()), remediation: form.remediation.filter(r => r.title.trim()), @@ -310,6 +311,7 @@ export default function AnalyzeRulesPage() { explanation: form.explanation.trim(), baseConfidence: form.baseConfidence, confidenceThreshold: form.confidenceThreshold, + preconditions: form.preconditions.filter(p => p.eventType.trim() && p.operator.trim()), conditions: form.conditions.filter(c => c.signal.trim()), confidenceFactors: form.confidenceFactors.filter(f => f.signal.trim()), remediation: form.remediation.filter(r => r.title.trim()), diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/types.ts b/src/Web/autopilot-monitor-web/app/analyze-rules/types.ts index 0adb8025..4a7351a4 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/types.ts +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/types.ts @@ -31,6 +31,15 @@ export interface RuleCondition { eventAFilterValue?: string; } +export interface RulePrecondition { + source: string; + eventType: string; + dataField: string; + operator: string; + value: string; + description?: string; +} + export interface TemplateVariable { name: string; label: string; @@ -53,6 +62,7 @@ export interface AnalyzeRule { enabled: boolean; isBuiltIn: boolean; isCommunity: boolean; + preconditions?: RulePrecondition[]; conditions: RuleCondition[]; baseConfidence: number; confidenceFactors: ConfidenceFactor[]; @@ -81,6 +91,7 @@ export interface RuleForm { explanation: string; baseConfidence: number; confidenceThreshold: number; + preconditions: RulePrecondition[]; conditions: RuleCondition[]; confidenceFactors: ConfidenceFactor[]; remediation: RemediationStep[]; @@ -92,6 +103,7 @@ export const SEVERITIES = ["info", "warning", "high", "critical"] as const; export const TRIGGERS = ["single", "correlation"] as const; export const OPERATORS = ["equals", "not_equals", "contains", "not_contains", "regex", "not_regex", "gt", "lt", "gte", "lte", "exists", "not_exists", "count_gte", "count_per_group_gte", "in", "not_in"] as const; export const SOURCES = ["event_type", "event_data", "phase_duration", "event_count", "event_correlation"] as const; +export const PRECONDITION_OPERATORS = ["equals", "not_equals", "contains", "not_contains", "regex", "not_regex", "gt", "lt", "gte", "lte", "exists", "not_exists", "in", "not_in"] as const; export const SEVERITY_COLORS: Record = { critical: { bg: "bg-red-100", text: "text-red-800", border: "border-red-300", dot: "bg-red-500" }, @@ -139,6 +151,15 @@ export const EMPTY_FACTOR: ConfidenceFactor = { weight: 10, }; +export const EMPTY_PRECONDITION: RulePrecondition = { + source: "event_data", + eventType: "", + dataField: "", + operator: "equals", + value: "", + description: "", +}; + export const EMPTY_FORM: RuleForm = { ruleId: "", title: "", @@ -149,6 +170,7 @@ export const EMPTY_FORM: RuleForm = { explanation: "", baseConfidence: 50, confidenceThreshold: 40, + preconditions: [], conditions: [{ ...EMPTY_CONDITION }], confidenceFactors: [], remediation: [], @@ -166,6 +188,7 @@ export function ruleToForm(rule: AnalyzeRule): RuleForm { explanation: rule.explanation || "", baseConfidence: rule.baseConfidence, confidenceThreshold: rule.confidenceThreshold, + preconditions: (rule.preconditions ?? []).map(p => ({ ...p })), conditions: rule.conditions.length > 0 ? rule.conditions.map(c => ({ ...c })) : [{ ...EMPTY_CONDITION }], confidenceFactors: rule.confidenceFactors.map(f => ({ ...f })), remediation: rule.remediation.map(r => ({ title: r.title, steps: [...r.steps] })),