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:
Oliver Kieselbach
2026-04-28 23:18:40 +02:00
parent f985e9bd71
commit 5abdcb6eee
10 changed files with 492 additions and 3 deletions
+41
View File
@@ -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>
@@ -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] })),