mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat(rules): preconditions gate — skip rules silently on non-matching device facts
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the precondition gate semantics on <see cref="RuleEngine.EvaluateRule"/>:
|
||||
/// preconditions evaluate BEFORE conditions, are AND-combined, and a failing precondition
|
||||
/// causes the rule to be silently skipped (no <see cref="RuleResult"/> emitted, no UI card,
|
||||
/// no telemetry-visible "rule fired"). Distinct from conditions which decide whether the
|
||||
/// rule fires given that it applies.
|
||||
/// </summary>
|
||||
public class RuleEnginePreconditionTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
/// <summary>
|
||||
/// Baseline: rule with no preconditions and a matching condition fires as expected.
|
||||
/// Confirms the test harness is wired correctly before we exercise gating.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task NoPreconditions_RuleFires_WhenConditionMatches()
|
||||
{
|
||||
var rule = MakeSecureBootRule(preconditions: new List<RulePrecondition>());
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Precondition passes (device is NOT a VM) → rule evaluates conditions and fires.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task PreconditionPasses_RuleFires()
|
||||
{
|
||||
var rule = MakeSecureBootRule(preconditions: new List<RulePrecondition>
|
||||
{
|
||||
new() { Source = "event_data", EventType = "hardware_spec", DataField = "isVirtualMachine", Operator = "equals", Value = "false" }
|
||||
});
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
HardwareSpecEvent(isVirtualMachine: "false"),
|
||||
SecureBootStatusEvent(uefiCA2023Status: "unknown", secureBootEnabled: "true")
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
|
||||
Assert.Single(outcome.Results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Precondition fails (device IS a VM) → rule is silently skipped. No result row, even
|
||||
/// though the underlying condition would have matched.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task PreconditionFails_RuleSilentlySkipped()
|
||||
{
|
||||
var rule = MakeSecureBootRule(preconditions: new List<RulePrecondition>
|
||||
{
|
||||
new() { Source = "event_data", EventType = "hardware_spec", DataField = "isVirtualMachine", Operator = "equals", Value = "false" }
|
||||
});
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AND-semantics: two preconditions, one passes one fails → rule skipped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TwoPreconditions_AnyFailure_SkipsRule()
|
||||
{
|
||||
var rule = MakeSecureBootRule(preconditions: new List<RulePrecondition>
|
||||
{
|
||||
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<EnrollmentEvent>
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task NotExistsOperator_PassesWhenEventMissing()
|
||||
{
|
||||
var rule = MakeSecureBootRule(preconditions: new List<RulePrecondition>
|
||||
{
|
||||
new() { Source = "event_data", EventType = "hardware_spec", DataField = "isVirtualMachine", Operator = "not_exists", Value = "" }
|
||||
});
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
// No hardware_spec event at all
|
||||
SecureBootStatusEvent(uefiCA2023Status: "unknown", secureBootEnabled: "true")
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
|
||||
Assert.Single(outcome.Results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EqualsOperator_FailsClosed_WhenEventMissing()
|
||||
{
|
||||
var rule = MakeSecureBootRule(preconditions: new List<RulePrecondition>
|
||||
{
|
||||
new() { Source = "event_data", EventType = "hardware_spec", DataField = "isVirtualMachine", Operator = "equals", Value = "false" }
|
||||
});
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
// No hardware_spec event
|
||||
SecureBootStatusEvent(uefiCA2023Status: "unknown", secureBootEnabled: "true")
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Empty preconditions array (or null) is the legacy default — must behave identically
|
||||
/// to today: condition match → rule fires.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EmptyPreconditions_BehavesLikeBefore()
|
||||
{
|
||||
var rule = MakeSecureBootRule(preconditions: null);
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
SecureBootStatusEvent(uefiCA2023Status: "unknown", secureBootEnabled: "true")
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
|
||||
Assert.Single(outcome.Results);
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
private static AnalyzeRule MakeSecureBootRule(List<RulePrecondition>? 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<RulePrecondition>(),
|
||||
Conditions = new List<RuleCondition>
|
||||
{
|
||||
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<string, object> { ["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<string, object> { ["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<string, object>
|
||||
{
|
||||
["uefiCA2023Status"] = uefiCA2023Status,
|
||||
["uefiSecureBootEnabled"] = secureBootEnabled
|
||||
}
|
||||
};
|
||||
|
||||
private static async Task<AnalysisOutcome> RunAsync(AnalyzeRule rule, List<EnrollmentEvent> events)
|
||||
{
|
||||
var ruleRepo = new Mock<IRuleRepository>();
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule> { rule });
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule>());
|
||||
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(new List<RuleResult>());
|
||||
|
||||
var sessionRepo = new Mock<ISessionRepository>();
|
||||
sessionRepo.Setup(s => s.GetSessionEventsAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
|
||||
|
||||
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<RuleEnginePreconditionTests>.Instance);
|
||||
|
||||
return await engine.AnalyzeSessionAsync(TenantId, SessionId);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,52 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// </summary>
|
||||
public partial class RuleEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates a single precondition (AND-semantics gate, silent skip on failure).
|
||||
///
|
||||
/// Semantics:
|
||||
/// - Source must be <c>event_data</c> (the only currently supported source).
|
||||
/// - <c>exists</c>: passes when at least one event of <see cref="RulePrecondition.EventType"/>
|
||||
/// carries a non-null value at <see cref="RulePrecondition.DataField"/>.
|
||||
/// - <c>not_exists</c>: 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
|
||||
/// <see cref="RulePrecondition.Operator"/> against <see cref="RulePrecondition.Value"/>.
|
||||
/// Missing event type or missing field → fail closed (rule skipped).
|
||||
/// </summary>
|
||||
private bool EvaluatePrecondition(RulePrecondition precondition, List<EnrollmentEvent> 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<EnrollmentEvent> events)
|
||||
{
|
||||
switch (condition.Source)
|
||||
|
||||
@@ -222,6 +222,23 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// </summary>
|
||||
private RuleResult? EvaluateRule(AnalyzeRule rule, List<EnrollmentEvent> 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<string, object>();
|
||||
int confidence = rule.BaseConfidence;
|
||||
bool allRequiredMet = true;
|
||||
|
||||
@@ -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<RulePrecondition>()),
|
||||
["ConditionsJson"] = JsonConvert.SerializeObject(rule.Conditions ?? new List<RuleCondition>()),
|
||||
["BaseConfidence"] = rule.BaseConfidence,
|
||||
["ConfidenceFactorsJson"] = JsonConvert.SerializeObject(rule.ConfidenceFactors ?? new List<ConfidenceFactor>()),
|
||||
@@ -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<List<RulePrecondition>>(entity.GetString("PreconditionsJson")),
|
||||
Conditions = DeserializeJson<List<RuleCondition>>(entity.GetString("ConditionsJson")),
|
||||
BaseConfidence = entity.GetInt32("BaseConfidence") ?? 50,
|
||||
ConfidenceFactors = DeserializeJson<List<ConfidenceFactor>>(entity.GetString("ConfidenceFactorsJson")),
|
||||
|
||||
@@ -69,6 +69,13 @@ namespace AutopilotMonitor.Shared.Models
|
||||
|
||||
// ===== MATCHING CONDITIONS =====
|
||||
|
||||
/// <summary>
|
||||
/// 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").
|
||||
/// </summary>
|
||||
public List<RulePrecondition> Preconditions { get; set; } = new List<RulePrecondition>();
|
||||
|
||||
/// <summary>
|
||||
/// 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!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>event_data</c> source is supported.
|
||||
/// </summary>
|
||||
public class RulePrecondition
|
||||
{
|
||||
/// <summary>
|
||||
/// Source of the fact. Currently only <c>event_data</c>.
|
||||
/// </summary>
|
||||
public string Source { get; set; } = "event_data";
|
||||
|
||||
/// <summary>
|
||||
/// Event type carrying the field to test (e.g., <c>hardware_spec</c>, <c>os_info</c>).
|
||||
/// </summary>
|
||||
public string EventType { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Data field to test (dot notation supported for nested fields).
|
||||
/// </summary>
|
||||
public string DataField { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Comparison operator. Same vocabulary as <see cref="RuleCondition.Operator"/>
|
||||
/// minus the count_/correlation-specific operators.
|
||||
/// </summary>
|
||||
public string Operator { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Value to compare against. Boolean values are stringified (<c>"true"</c>/<c>"false"</c>).
|
||||
/// </summary>
|
||||
public string Value { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Optional human-readable note explaining the intent
|
||||
/// (e.g., <c>"skip on virtual machines"</c>). Not evaluated.
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A factor that increases confidence when matched
|
||||
/// </summary>
|
||||
|
||||
@@ -192,6 +192,27 @@ export default function AnalyzeRuleCard({
|
||||
<p className="text-sm text-gray-600 leading-relaxed">{rule.description}</p>
|
||||
</div>
|
||||
|
||||
{rule.preconditions && rule.preconditions.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-2">Preconditions ({rule.preconditions.length})</h4>
|
||||
<p className="text-xs text-gray-500 mb-2">Rule is silently skipped if any of these fails.</p>
|
||||
<div className="space-y-2">
|
||||
{rule.preconditions.map((pre, idx) => (
|
||||
<div key={idx} className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm">
|
||||
<p className="text-gray-700">
|
||||
<span className="text-gray-600 font-medium">Event:</span> {pre.eventType}
|
||||
{pre.dataField && (<span> | <span className="text-gray-600 font-medium">Field:</span> <code className="bg-amber-100 px-1 rounded text-xs">{pre.dataField}</code></span>)}
|
||||
{" "}
|
||||
<code className="bg-amber-100 px-1 rounded text-xs">{pre.operator}</code>
|
||||
{pre.value !== "" && (<> <code className="bg-amber-100 px-1 rounded text-xs">{pre.value}</code></>)}
|
||||
</p>
|
||||
{pre.description && (<p className="text-xs text-gray-500 italic mt-1">{pre.description}</p>)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rule.conditions.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-2">Conditions ({rule.conditions.length})</h4>
|
||||
|
||||
+44
-3
@@ -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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preconditions (device-fact gates evaluated BEFORE conditions) */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700">Preconditions</label>
|
||||
<p className="text-xs text-gray-500">Optional. Rule is silently skipped (no result) when ANY precondition fails. Useful e.g. to skip on virtual machines.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setForm({ ...form, preconditions: [...form.preconditions, { ...EMPTY_PRECONDITION }] })} className="text-xs text-indigo-600 hover:text-indigo-800 font-medium">+ Add Precondition</button>
|
||||
</div>
|
||||
{form.preconditions.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 italic">No preconditions. Rule applies to every device.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{form.preconditions.map((pre, idx) => {
|
||||
const updatePre = (patch: Partial<RulePrecondition>) => {
|
||||
const p = [...form.preconditions];
|
||||
p[idx] = { ...p[idx], ...patch };
|
||||
setForm({ ...form, preconditions: p });
|
||||
};
|
||||
return (
|
||||
<div key={idx} className="border border-amber-200 bg-amber-50 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-amber-700">Precondition {idx + 1}</span>
|
||||
<button type="button" onClick={() => setForm({ ...form, preconditions: form.preconditions.filter((_, i) => i !== idx) })} className="text-xs text-red-500 hover:text-red-700">Remove</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-2">
|
||||
<input type="text" value={pre.eventType} onChange={(e) => 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" />
|
||||
<input type="text" value={pre.dataField} onChange={(e) => 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" />
|
||||
<select value={pre.operator} onChange={(e) => updatePre({ operator: e.target.value })} className="px-3 py-1.5 border border-amber-300 rounded text-sm text-gray-900 bg-white focus:outline-none focus:ring-1 focus:ring-amber-500">
|
||||
{PRECONDITION_OPERATORS.map((o) => (<option key={o} value={o}>{o}</option>))}
|
||||
</select>
|
||||
<input type="text" value={pre.value} onChange={(e) => 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" />
|
||||
</div>
|
||||
<input type="text" value={pre.description ?? ""} onChange={(e) => 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" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conditions */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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<string, { bg: string; text: string; border: string; dot: string }> = {
|
||||
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] })),
|
||||
|
||||
Reference in New Issue
Block a user