mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat: enhanced analyze rule engine (in not_in support) MarkSessionAsFailed support, new ESP rule for cert errors
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ESP-002",
|
||||
"title": "ESP Subcategory Failed (Certificates)",
|
||||
"description": "Detects when the Certificates subcategory of the Enrollment Status Page reported a failure, even if the enrollment later appears to have succeeded. Users can click 'Continue anyway' on an ESP error, which masks the underlying certificate provisioning failure — this rule surfaces the transition so it stays visible.",
|
||||
"severity": "high",
|
||||
"category": "esp",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "single",
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "certificates_subcategory_failed",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_provisioning_status",
|
||||
"dataField": "failedSubcategories",
|
||||
"operator": "contains",
|
||||
"value": "Certificates",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [],
|
||||
"confidenceThreshold": 70,
|
||||
"explanation": "The ESP Certificates subcategory transitioned to **failed** during enrollment. This is frequently masked when the user clicks 'Continue anyway' on the ESP error screen — the overall enrollment then appears successful while a required certificate (e.g. Wi-Fi, VPN, SCEP/PKCS) was never deployed.\n\nThe subcategory name is read from the registry (`CertificatesSubcategory`) and is language-invariant, so this rule fires regardless of OS display language.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Validate certificate delivery on the device",
|
||||
"steps": [
|
||||
"Run `certutil -store My` and `certutil -store -user My` on the device to inspect installed certificates",
|
||||
"Check `Event Viewer → Applications and Services Logs → Microsoft → Windows → DeviceManagement-Enterprise-Diagnostics-Provider → Admin` for SCEP/PKCS errors",
|
||||
"Review the Intune portal for the device's certificate profile assignments and deployment status"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Fix common certificate provisioning issues",
|
||||
"steps": [
|
||||
"Verify the NDES/SCEP connector is online and healthy",
|
||||
"Confirm the certificate template permissions grant 'Enroll' to the Intune service account / NDES computer account",
|
||||
"Check that the device has network line-of-sight to the SCEP endpoint at the time of ESP",
|
||||
"Re-sync the device (Settings → Accounts → Access work or school → Info → Sync) and re-check the Certificates subcategory"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Troubleshoot certificate profiles in Intune",
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/protect/troubleshoot-certificate-profiles"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Enrollment Status Page tracking",
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/enrollment-status"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["esp", "certificates", "subcategory", "silent-failure"]
|
||||
}
|
||||
+62
@@ -960,6 +960,68 @@
|
||||
"timeout"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-ESP-002",
|
||||
"title": "ESP Subcategory Failed (Certificates)",
|
||||
"description": "Detects when the Certificates subcategory of the Enrollment Status Page reported a failure, even if the enrollment later appears to have succeeded. Users can click 'Continue anyway' on an ESP error, which masks the underlying certificate provisioning failure — this rule surfaces the transition so it stays visible.",
|
||||
"severity": "high",
|
||||
"category": "esp",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "single",
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "certificates_subcategory_failed",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_provisioning_status",
|
||||
"dataField": "failedSubcategories",
|
||||
"operator": "contains",
|
||||
"value": "Certificates",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [],
|
||||
"confidenceThreshold": 70,
|
||||
"explanation": "The ESP Certificates subcategory transitioned to **failed** during enrollment. This is frequently masked when the user clicks 'Continue anyway' on the ESP error screen — the overall enrollment then appears successful while a required certificate (e.g. Wi-Fi, VPN, SCEP/PKCS) was never deployed.\n\nThe subcategory name is read from the registry (`CertificatesSubcategory`) and is language-invariant, so this rule fires regardless of OS display language.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Validate certificate delivery on the device",
|
||||
"steps": [
|
||||
"Run `certutil -store My` and `certutil -store -user My` on the device to inspect installed certificates",
|
||||
"Check `Event Viewer → Applications and Services Logs → Microsoft → Windows → DeviceManagement-Enterprise-Diagnostics-Provider → Admin` for SCEP/PKCS errors",
|
||||
"Review the Intune portal for the device's certificate profile assignments and deployment status"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Fix common certificate provisioning issues",
|
||||
"steps": [
|
||||
"Verify the NDES/SCEP connector is online and healthy",
|
||||
"Confirm the certificate template permissions grant 'Enroll' to the Intune service account / NDES computer account",
|
||||
"Check that the device has network line-of-sight to the SCEP endpoint at the time of ESP",
|
||||
"Re-sync the device (Settings → Accounts → Access work or school → Info → Sync) and re-check the Certificates subcategory"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Troubleshoot certificate profiles in Intune",
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/protect/troubleshoot-certificate-profiles"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Enrollment Status Page tracking",
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/enrollment-status"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"esp",
|
||||
"certificates",
|
||||
"subcategory",
|
||||
"silent-failure"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-ESP-003",
|
||||
"title": "Enrollment Duration Exceeded 60 Minutes",
|
||||
|
||||
@@ -123,6 +123,11 @@
|
||||
"items": { "type": "string" },
|
||||
"default": []
|
||||
},
|
||||
"markSessionAsFailedDefault": {
|
||||
"type": "boolean",
|
||||
"description": "When true, a firing of this rule causes the entire enrollment session to be marked as failed (KO criterion). Tenants can override this via the portal.",
|
||||
"default": false
|
||||
},
|
||||
"templateVariables": {
|
||||
"type": "array",
|
||||
"description": "Template variables that must be customized per-tenant. If non-empty, the rule is a template: enabling it creates a tenant custom copy with the user's values substituted.",
|
||||
@@ -161,7 +166,7 @@
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"description": "Comparison operator.",
|
||||
"enum": ["equals", "not_equals", "contains", "not_contains", "regex", "not_regex", "gt", "lt", "gte", "lte", "exists", "not_exists", "count_gte", "count_per_group_gte"]
|
||||
"enum": ["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"]
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
@@ -208,7 +213,7 @@
|
||||
"eventAFilterOperator": {
|
||||
"type": "string",
|
||||
"description": "Operator for the Event A filter.",
|
||||
"enum": ["equals", "not_equals", "contains", "not_contains", "regex", "not_regex", "gt", "lt", "gte", "lte", "exists", "not_exists", "count_gte", "count_per_group_gte"]
|
||||
"enum": ["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"]
|
||||
},
|
||||
"eventAFilterValue": {
|
||||
"type": "string",
|
||||
|
||||
+13
@@ -739,6 +739,19 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
|
||||
});
|
||||
}
|
||||
eventData["transitions"] = transitionData;
|
||||
|
||||
// Flat top-level field for RuleEngine matching — the RuleEngine cannot
|
||||
// traverse nested collections via dataField, so we expose the names of
|
||||
// subcategories that just transitioned to "failed" as a comma-separated
|
||||
// string. Names come from registry keys and are language-invariant.
|
||||
var failedNames = transitions
|
||||
.Where(t => t.IsFailure)
|
||||
.Select(t => t.SubcategoryName)
|
||||
.ToList();
|
||||
if (failedNames.Count > 0)
|
||||
{
|
||||
eventData["failedSubcategories"] = string.Join(",", failedNames);
|
||||
}
|
||||
}
|
||||
|
||||
_onEventCollected(new EnrollmentEvent
|
||||
|
||||
@@ -1 +1 @@
|
||||
963
|
||||
971
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the rule-JSON side of the KO-criterion feature (MarkSessionAsFailed).
|
||||
/// Deep end-to-end behavior (RuleEngine actually flipping a session to Failed) is exercised
|
||||
/// against live Table Storage during manual QA — these tests pin the rule shape so future
|
||||
/// refactors can't silently break the wiring.
|
||||
/// </summary>
|
||||
public class AnalyzeRuleSessionFailureTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuiltInAnalyzeRules_EspCertificatesRule_HasExpectedShape()
|
||||
{
|
||||
var rules = BuiltInAnalyzeRules.GetAll();
|
||||
var rule = rules.FirstOrDefault(r => r.RuleId == "ANALYZE-ESP-002");
|
||||
|
||||
Assert.NotNull(rule);
|
||||
Assert.Equal("high", rule!.Severity);
|
||||
Assert.Equal("esp", rule.Category);
|
||||
|
||||
// The rule must match against the flat top-level field emitted by ProvisioningStatusTracker,
|
||||
// not a nested subcategories path — the RuleEngine can't traverse nested collections.
|
||||
var cond = rule.Conditions.Single();
|
||||
Assert.Equal("esp_provisioning_status", cond.EventType);
|
||||
Assert.Equal("event_data", cond.Source);
|
||||
Assert.Equal("failedSubcategories", cond.DataField);
|
||||
Assert.Equal("contains", cond.Operator);
|
||||
Assert.Equal("Certificates", cond.Value);
|
||||
Assert.True(cond.Required);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInAnalyzeRules_MarkSessionAsFailedDefault_IsFalseForAllBuiltIns()
|
||||
{
|
||||
// Opt-in only: no built-in rule should force sessions to failed out of the box.
|
||||
// Tenants enable KO behavior per rule via the portal. If you add a rule that genuinely
|
||||
// needs `true` as a default, update this test with a narrow allow-list.
|
||||
foreach (var rule in BuiltInAnalyzeRules.GetAll())
|
||||
{
|
||||
Assert.False(rule.MarkSessionAsFailedDefault,
|
||||
$"Rule {rule.RuleId} ships with MarkSessionAsFailedDefault=true, which would fail sessions for every tenant without opt-in.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuleState_NullOverride_MeansInheritDefault()
|
||||
{
|
||||
// The dict returned by GetRuleStatesAsync maps ruleId → RuleState. A null MarkSessionAsFailed
|
||||
// is the canonical "tenant did not express a preference" value and AnalyzeRuleService
|
||||
// must copy it through to AnalyzeRule.MarkSessionAsFailed unchanged.
|
||||
var state = new RuleState { Enabled = true, MarkSessionAsFailed = null };
|
||||
var rule = new AnalyzeRule
|
||||
{
|
||||
RuleId = "ANALYZE-ESP-002",
|
||||
MarkSessionAsFailedDefault = false
|
||||
};
|
||||
|
||||
rule.Enabled = state.Enabled;
|
||||
rule.MarkSessionAsFailed = state.MarkSessionAsFailed;
|
||||
|
||||
var effective = rule.MarkSessionAsFailed ?? rule.MarkSessionAsFailedDefault;
|
||||
Assert.False(effective);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuleState_ExplicitOverride_WinsOverDefault()
|
||||
{
|
||||
var state = new RuleState { Enabled = true, MarkSessionAsFailed = true };
|
||||
var rule = new AnalyzeRule
|
||||
{
|
||||
RuleId = "ANALYZE-ESP-002",
|
||||
MarkSessionAsFailedDefault = false
|
||||
};
|
||||
|
||||
rule.MarkSessionAsFailed = state.MarkSessionAsFailed;
|
||||
var effective = rule.MarkSessionAsFailed ?? rule.MarkSessionAsFailedDefault;
|
||||
Assert.True(effective);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -39,10 +39,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<bool> DeleteGatherRuleAsync(string tenantId, string ruleId)
|
||||
=> _storage.DeleteGatherRuleAsync(tenantId, ruleId);
|
||||
|
||||
public Task<bool> StoreRuleStateAsync(string tenantId, string ruleId, bool enabled)
|
||||
=> _storage.StoreRuleStateAsync(tenantId, ruleId, enabled);
|
||||
public Task<bool> StoreRuleStateAsync(string tenantId, string ruleId, RuleState state)
|
||||
=> _storage.StoreRuleStateAsync(tenantId, ruleId, state);
|
||||
|
||||
public Task<Dictionary<string, bool>> GetRuleStatesAsync(string tenantId)
|
||||
public Task<Dictionary<string, RuleState>> GetRuleStatesAsync(string tenantId)
|
||||
=> _storage.GetRuleStatesAsync(tenantId);
|
||||
|
||||
public Task<bool> DeleteRuleStateAsync(string tenantId, string ruleId)
|
||||
|
||||
+3
-2
@@ -48,11 +48,12 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
DateTime? completedAt = null, DateTime? earliestEventTimestamp = null,
|
||||
DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null,
|
||||
bool? isUserDriven = null, DateTime? resumedAt = null,
|
||||
DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false)
|
||||
DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false,
|
||||
string? failureSource = null)
|
||||
=> _storage.UpdateSessionStatusAsync(tenantId, sessionId, status,
|
||||
currentPhase, failureReason, completedAt, earliestEventTimestamp,
|
||||
latestEventTimestamp, isPreProvisioned, isUserDriven, resumedAt,
|
||||
stalledAt, clearStalledAt, clearFailureReason);
|
||||
stalledAt, clearStalledAt, clearFailureReason, failureSource);
|
||||
|
||||
public Task IncrementSessionEventCountAsync(
|
||||
string tenantId, string sessionId, int increment,
|
||||
|
||||
@@ -55,12 +55,16 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// Apply tenant state overrides to global rules
|
||||
foreach (var rule in globalRules)
|
||||
{
|
||||
if (ruleStates.TryGetValue(rule.RuleId, out var enabled))
|
||||
rule.Enabled = enabled;
|
||||
if (ruleStates.TryGetValue(rule.RuleId, out var state))
|
||||
{
|
||||
rule.Enabled = state.Enabled;
|
||||
rule.MarkSessionAsFailed = state.MarkSessionAsFailed;
|
||||
}
|
||||
mergedRules.Add(rule);
|
||||
}
|
||||
|
||||
// Add tenant-specific custom rules
|
||||
// Tenant custom rules carry their own MarkSessionAsFailedDefault; no override needed
|
||||
// since the tenant already fully owns the rule definition.
|
||||
mergedRules.AddRange(customRules);
|
||||
|
||||
return mergedRules;
|
||||
@@ -95,7 +99,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
if (rule.IsBuiltIn || rule.IsCommunity)
|
||||
{
|
||||
return await _ruleRepo.StoreRuleStateAsync(tenantId, rule.RuleId, rule.Enabled);
|
||||
var state = new RuleState
|
||||
{
|
||||
Enabled = rule.Enabled,
|
||||
MarkSessionAsFailed = rule.MarkSessionAsFailed
|
||||
};
|
||||
return await _ruleRepo.StoreRuleStateAsync(tenantId, rule.RuleId, state);
|
||||
}
|
||||
|
||||
rule.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -182,7 +191,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
throw new InvalidOperationException("Failed to store custom rule.");
|
||||
|
||||
// Ensure the template rule is disabled for this tenant
|
||||
await _ruleRepo.StoreRuleStateAsync(tenantId, templateRuleId, false);
|
||||
await _ruleRepo.StoreRuleStateAsync(tenantId, templateRuleId, new RuleState { Enabled = false });
|
||||
|
||||
_logger.LogInformation("Created custom rule '{CustomRuleId}' from template '{TemplateRuleId}' for tenant '{TenantId}'",
|
||||
customRule.RuleId, templateRuleId, tenantId);
|
||||
|
||||
@@ -52,11 +52,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
var mergedRules = new List<GatherRule>();
|
||||
|
||||
// Apply tenant state overrides to global rules
|
||||
// Apply tenant state overrides to global rules.
|
||||
// MarkSessionAsFailed is analyze-rule-only; gather rules ignore it.
|
||||
foreach (var rule in globalRules)
|
||||
{
|
||||
if (ruleStates.TryGetValue(rule.RuleId, out var enabled))
|
||||
rule.Enabled = enabled;
|
||||
if (ruleStates.TryGetValue(rule.RuleId, out var state))
|
||||
rule.Enabled = state.Enabled;
|
||||
mergedRules.Add(rule);
|
||||
}
|
||||
|
||||
@@ -104,7 +105,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (globalRule != null && (globalRule.IsBuiltIn || globalRule.IsCommunity))
|
||||
{
|
||||
// Built-in/community rule: only persist enabled state per tenant
|
||||
return await _ruleRepo.StoreRuleStateAsync(tenantId, rule.RuleId, rule.Enabled);
|
||||
return await _ruleRepo.StoreRuleStateAsync(tenantId, rule.RuleId, new RuleState { Enabled = rule.Enabled });
|
||||
}
|
||||
|
||||
// Ensure custom rules keep correct flags (the incoming payload may
|
||||
|
||||
@@ -384,11 +384,33 @@ namespace AutopilotMonitor.Functions.Services
|
||||
case "not_exists":
|
||||
return string.IsNullOrEmpty(fieldValue);
|
||||
|
||||
case "in":
|
||||
return MatchesInList(fieldValue, compareValue);
|
||||
|
||||
case "not_in":
|
||||
return !MatchesInList(fieldValue, compareValue);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesInList(string fieldValue, string compareValue)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fieldValue) || string.IsNullOrEmpty(compareValue))
|
||||
return false;
|
||||
|
||||
foreach (var entry in compareValue.Split(','))
|
||||
{
|
||||
var trimmed = entry.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
continue;
|
||||
if (string.Equals(fieldValue, trimmed, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private (bool matched, object evidence) EvaluateAppInstallDurationCondition(RuleCondition condition, List<EnrollmentEvent> events)
|
||||
{
|
||||
var sortedEvents = events.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList();
|
||||
|
||||
@@ -79,6 +79,15 @@ namespace AutopilotMonitor.Functions.Services
|
||||
result.TenantId = tenantId;
|
||||
outcome.Results.Add(result);
|
||||
_logger.LogInformation($"Rule {rule.RuleId} ({rule.Trigger}) fired for session {sessionId} with confidence {result.ConfidenceScore}%");
|
||||
|
||||
// KO-criterion: if the (effective) MarkSessionAsFailed flag is on,
|
||||
// escalate the rule finding to a terminal session status. Tenant override
|
||||
// wins; otherwise we honor the rule-definition default.
|
||||
var shouldFailSession = rule.MarkSessionAsFailed ?? rule.MarkSessionAsFailedDefault;
|
||||
if (shouldFailSession)
|
||||
{
|
||||
await TryMarkSessionFailedFromRuleAsync(tenantId, sessionId, rule);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -95,6 +104,45 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotes a fired rule to a terminal Failed status on the session, but only when the session
|
||||
/// is still in a non-terminal state (InProgress/Pending/Stalled). Terminal states
|
||||
/// (Succeeded/Failed) are left untouched — the agent's own terminal signal wins, and we never
|
||||
/// overwrite a prior rule-based failure.
|
||||
/// </summary>
|
||||
private async Task TryMarkSessionFailedFromRuleAsync(string tenantId, string sessionId, AnalyzeRule rule)
|
||||
{
|
||||
try
|
||||
{
|
||||
var session = await _sessionRepo.GetSessionAsync(tenantId, sessionId);
|
||||
if (session == null)
|
||||
return;
|
||||
|
||||
// Don't stomp on an already-terminal session. This also makes the call idempotent:
|
||||
// on re-analysis the rule may fire again, but we only flip the status once.
|
||||
if (session.Status == SessionStatus.Succeeded || session.Status == SessionStatus.Failed)
|
||||
{
|
||||
_logger.LogDebug($"Rule {rule.RuleId} fired for session {sessionId} but status is already {session.Status} — skipping session failure");
|
||||
return;
|
||||
}
|
||||
|
||||
var failureSource = $"rule:{rule.RuleId}";
|
||||
var failureReason = $"Rule: {rule.Title}";
|
||||
|
||||
await _sessionRepo.UpdateSessionStatusAsync(
|
||||
tenantId, sessionId, SessionStatus.Failed,
|
||||
failureReason: failureReason,
|
||||
failureSource: failureSource,
|
||||
completedAt: DateTime.UtcNow);
|
||||
|
||||
_logger.LogWarning($"Session {sessionId} marked as failed by rule {rule.RuleId} ('{rule.Title}')");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Failed to mark session {sessionId} as failed via rule {rule.RuleId}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a single rule against the full session event stream
|
||||
/// </summary>
|
||||
|
||||
@@ -224,7 +224,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// Stores or updates the enabled/disabled state for a built-in or community rule per tenant
|
||||
/// PartitionKey: TenantId, RowKey: RuleId
|
||||
/// </summary>
|
||||
public async Task<bool> StoreRuleStateAsync(string tenantId, string ruleId, bool enabled)
|
||||
public async Task<bool> StoreRuleStateAsync(string tenantId, string ruleId, RuleState state)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
|
||||
|
||||
@@ -234,12 +234,20 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
var entity = new TableEntity(tenantId, ruleId)
|
||||
{
|
||||
["Enabled"] = enabled,
|
||||
["Enabled"] = state.Enabled,
|
||||
["UpdatedAt"] = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Nullable override — store as boolean column when set, write an empty string when
|
||||
// cleared so existing rows reset cleanly. Omitting the column would leave a stale value
|
||||
// on the row after a merge.
|
||||
if (state.MarkSessionAsFailed.HasValue)
|
||||
entity["MarkSessionAsFailed"] = state.MarkSessionAsFailed.Value;
|
||||
else
|
||||
entity["MarkSessionAsFailed"] = null;
|
||||
|
||||
await tableClient.UpsertEntityAsync(entity);
|
||||
_logger.LogDebug($"Stored rule state {ruleId} for {tenantId}: enabled={enabled}");
|
||||
_logger.LogDebug($"Stored rule state {ruleId} for {tenantId}: enabled={state.Enabled}, markAsFailed={state.MarkSessionAsFailed?.ToString() ?? "inherit"}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -250,9 +258,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all rule states for a tenant as a dictionary of ruleId → enabled
|
||||
/// Gets all rule states for a tenant as a dictionary of ruleId → RuleState.
|
||||
/// </summary>
|
||||
public async Task<Dictionary<string, bool>> GetRuleStatesAsync(string tenantId)
|
||||
public async Task<Dictionary<string, RuleState>> GetRuleStatesAsync(string tenantId)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
|
||||
|
||||
@@ -261,10 +269,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleStates);
|
||||
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{tenantId}'");
|
||||
|
||||
var states = new Dictionary<string, bool>();
|
||||
var states = new Dictionary<string, RuleState>();
|
||||
await foreach (var entity in query)
|
||||
{
|
||||
states[entity.RowKey] = entity.GetBoolean("Enabled") ?? true;
|
||||
states[entity.RowKey] = new RuleState
|
||||
{
|
||||
Enabled = entity.GetBoolean("Enabled") ?? true,
|
||||
MarkSessionAsFailed = entity.GetBoolean("MarkSessionAsFailed")
|
||||
};
|
||||
}
|
||||
|
||||
return states;
|
||||
@@ -272,7 +284,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Failed to get rule states for {tenantId}");
|
||||
return new Dictionary<string, bool>();
|
||||
return new Dictionary<string, RuleState>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +342,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
["TagsJson"] = JsonConvert.SerializeObject(rule.Tags ?? new string[0]),
|
||||
["TemplateVariablesJson"] = JsonConvert.SerializeObject(rule.TemplateVariables ?? new List<TemplateVariable>()),
|
||||
["DerivedFromTemplateRuleId"] = rule.DerivedFromTemplateRuleId ?? string.Empty,
|
||||
["MarkSessionAsFailedDefault"] = rule.MarkSessionAsFailedDefault,
|
||||
["CreatedAt"] = rule.CreatedAt,
|
||||
["UpdatedAt"] = rule.UpdatedAt
|
||||
};
|
||||
@@ -455,6 +468,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
Tags = DeserializeJsonArray(entity.GetString("TagsJson")),
|
||||
TemplateVariables = DeserializeJson<List<TemplateVariable>>(entity.GetString("TemplateVariablesJson")),
|
||||
DerivedFromTemplateRuleId = string.IsNullOrEmpty(derivedFrom) ? null : derivedFrom,
|
||||
MarkSessionAsFailedDefault = entity.GetBoolean("MarkSessionAsFailedDefault") ?? false,
|
||||
CreatedAt = entity.GetDateTimeOffset("CreatedAt")?.UtcDateTime ?? DateTime.UtcNow,
|
||||
UpdatedAt = entity.GetDateTimeOffset("UpdatedAt")?.UtcDateTime ?? DateTime.UtcNow
|
||||
};
|
||||
|
||||
@@ -211,6 +211,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
CurrentPhaseDetail = entity.GetString("CurrentPhaseDetail") ?? string.Empty,
|
||||
Status = status,
|
||||
FailureReason = entity.GetString("FailureReason") ?? string.Empty,
|
||||
FailureSource = entity.GetString("FailureSource") ?? string.Empty,
|
||||
EventCount = SafeGetInt32(entity, "EventCount") ?? 0,
|
||||
DurationSeconds = ComputeEffectiveDuration(entity, status, startedAt, completedAt),
|
||||
EnrollmentType = entity.GetString("EnrollmentType") ?? "v1",
|
||||
@@ -909,7 +910,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// Event count is maintained atomically by IncrementSessionEventCountAsync and is not
|
||||
/// recounted here — avoiding an expensive full-partition scan on every status change.
|
||||
/// </summary>
|
||||
public async Task<bool> UpdateSessionStatusAsync(string tenantId, string sessionId, SessionStatus status, EnrollmentPhase? currentPhase = null, string? failureReason = null, DateTime? completedAt = null, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null, bool? isUserDriven = null, DateTime? resumedAt = null, DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false)
|
||||
public async Task<bool> UpdateSessionStatusAsync(string tenantId, string sessionId, SessionStatus status, EnrollmentPhase? currentPhase = null, string? failureReason = null, DateTime? completedAt = null, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null, bool? isUserDriven = null, DateTime? resumedAt = null, DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false, string? failureSource = null)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
|
||||
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
|
||||
@@ -1041,6 +1042,19 @@ namespace AutopilotMonitor.Functions.Services
|
||||
update["FailureReason"] = failureReason;
|
||||
}
|
||||
|
||||
// Record the origin of a Failed status (agent / rule:<id> / manual). When the caller
|
||||
// passes null we leave the existing value untouched — concurrent updates from different
|
||||
// sources shouldn't stomp on each other's attribution.
|
||||
if (status == SessionStatus.Failed && !string.IsNullOrEmpty(failureSource))
|
||||
{
|
||||
update["FailureSource"] = failureSource;
|
||||
}
|
||||
// Heal: clear attribution when the session is un-failed (e.g. back to InProgress).
|
||||
if (clearFailureReason)
|
||||
{
|
||||
update["FailureSource"] = string.Empty;
|
||||
}
|
||||
|
||||
// Set IsPreProvisioned flag atomically with the status update (WhiteGlove)
|
||||
if (isPreProvisioned.HasValue)
|
||||
{
|
||||
@@ -1691,6 +1705,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
CurrentPhaseDetail = entity.GetString("CurrentPhaseDetail") ?? string.Empty,
|
||||
Status = status,
|
||||
FailureReason = entity.GetString("FailureReason") ?? string.Empty,
|
||||
FailureSource = entity.GetString("FailureSource") ?? string.Empty,
|
||||
EventCount = SafeGetInt32(entity, "EventCount") ?? 0,
|
||||
DurationSeconds = ComputeEffectiveDuration(entity, status, startedAt, completedAt),
|
||||
EnrollmentType = entity.GetString("EnrollmentType") ?? "v1",
|
||||
|
||||
@@ -20,8 +20,8 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
Task<bool> DeleteGatherRuleAsync(string tenantId, string ruleId);
|
||||
|
||||
// --- Rule States ---
|
||||
Task<bool> StoreRuleStateAsync(string tenantId, string ruleId, bool enabled);
|
||||
Task<Dictionary<string, bool>> GetRuleStatesAsync(string tenantId);
|
||||
Task<bool> StoreRuleStateAsync(string tenantId, string ruleId, RuleState state);
|
||||
Task<Dictionary<string, RuleState>> GetRuleStatesAsync(string tenantId);
|
||||
Task<bool> DeleteRuleStateAsync(string tenantId, string ruleId);
|
||||
|
||||
// --- Analyze Rules ---
|
||||
|
||||
@@ -26,7 +26,8 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
DateTime? completedAt = null, DateTime? earliestEventTimestamp = null,
|
||||
DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null,
|
||||
bool? isUserDriven = null, DateTime? resumedAt = null,
|
||||
DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false);
|
||||
DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false,
|
||||
string? failureSource = null);
|
||||
Task IncrementSessionEventCountAsync(
|
||||
string tenantId, string sessionId, int increment,
|
||||
DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null,
|
||||
|
||||
@@ -129,6 +129,23 @@ namespace AutopilotMonitor.Shared.Models
|
||||
|
||||
// ===== METADATA =====
|
||||
|
||||
// ===== SESSION FAILURE POLICY =====
|
||||
|
||||
/// <summary>
|
||||
/// Rule-definition default for whether firing this rule should mark the entire session as failed.
|
||||
/// Shipped in the rule JSON. A tenant can override this via <see cref="MarkSessionAsFailed"/>
|
||||
/// in their RuleState — a firing rule is considered a "KO criterion" for the enrollment when the
|
||||
/// effective value (override ?? default) is true.
|
||||
/// </summary>
|
||||
public bool MarkSessionAsFailedDefault { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant-scoped override for <see cref="MarkSessionAsFailedDefault"/>. Not persisted in the rule
|
||||
/// JSON — populated at load time from the RuleStates table. Null means the tenant has not expressed
|
||||
/// a preference (fall back to the default).
|
||||
/// </summary>
|
||||
public bool? MarkSessionAsFailed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tags for filtering and categorization
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-tenant override of a rule's default behavior.
|
||||
/// Stored in the RuleStates table (PartitionKey=TenantId, RowKey=RuleId).
|
||||
/// Absent entries mean "use rule defaults from the definition".
|
||||
/// </summary>
|
||||
public class RuleState
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the rule is enabled for this tenant. Overrides the rule's default.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant-scoped override for <see cref="AnalyzeRule.MarkSessionAsFailedDefault"/>.
|
||||
/// Null means the tenant has not opted in or out — the rule's default applies.
|
||||
/// Only meaningful for analyze rules; ignored for gather rules.
|
||||
/// </summary>
|
||||
public bool? MarkSessionAsFailed { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,15 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string CurrentPhaseDetail { get; set; } = default!;
|
||||
public SessionStatus Status { get; set; }
|
||||
public string FailureReason { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Origin of a Failed status. Values:
|
||||
/// - "" / null: agent-reported (default; terminal enrollment_failed event)
|
||||
/// - "rule:<RuleId>": session failed because an analyze rule with MarkSessionAsFailed fired
|
||||
/// - "manual": operator flipped the session via the portal
|
||||
/// Consumers use this to render rule-based failures distinctly (badge + link to rule).
|
||||
/// </summary>
|
||||
public string FailureSource { get; set; } = string.Empty;
|
||||
public int EventCount { get; set; }
|
||||
public int? DurationSeconds { get; set; }
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ interface AnalyzeRuleCardProps {
|
||||
jsonError: string | null;
|
||||
onToggle: () => void;
|
||||
onToggleEnabled: (rule: AnalyzeRule) => void;
|
||||
onToggleMarkAsFailed?: (rule: AnalyzeRule) => void;
|
||||
onStartEditing: (rule: AnalyzeRule) => void;
|
||||
onSaveEdit: (rule: AnalyzeRule, formOverride?: RuleForm) => void;
|
||||
onCancelEdit: () => void;
|
||||
@@ -42,7 +43,7 @@ export default function AnalyzeRuleCard({
|
||||
rule, isExpanded, isEditing, editForm, setEditForm, saving,
|
||||
togglingRuleId, deletingRuleId,
|
||||
jsonModeEdit, jsonText, jsonError,
|
||||
onToggle, onToggleEnabled, onStartEditing, onSaveEdit, onCancelEdit,
|
||||
onToggle, onToggleEnabled, onToggleMarkAsFailed, onStartEditing, onSaveEdit, onCancelEdit,
|
||||
onDelete, onExport,
|
||||
onSetJsonModeEdit, onSetJsonText, onSetJsonError,
|
||||
readOnly = false,
|
||||
@@ -55,6 +56,8 @@ export default function AnalyzeRuleCard({
|
||||
const canEdit = !rule.isBuiltIn && !rule.isCommunity;
|
||||
const isTemplate = (rule.templateVariables?.length ?? 0) > 0;
|
||||
const isDerived = !!rule.derivedFromTemplateRuleId;
|
||||
const effectiveMarkAsFailed = (rule.markSessionAsFailed ?? rule.markSessionAsFailedDefault) ?? false;
|
||||
const koOverrideIsExplicit = rule.markSessionAsFailed !== null && rule.markSessionAsFailed !== undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -82,6 +85,25 @@ export default function AnalyzeRuleCard({
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${rule.enabled ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</button>
|
||||
)}
|
||||
{/* KO-criterion toggle: only meaningful when the rule is actually enabled. Click cycles
|
||||
the tenant override; the badge colour reflects the effective value. */}
|
||||
{!readOnly && rule.enabled && onToggleMarkAsFailed && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onToggleMarkAsFailed(rule); }}
|
||||
disabled={togglingRuleId === rule.ruleId}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold flex-shrink-0 border transition-colors ${
|
||||
effectiveMarkAsFailed
|
||||
? "bg-red-100 text-red-800 border-red-300 hover:bg-red-200"
|
||||
: "bg-gray-50 text-gray-500 border-gray-200 hover:bg-gray-100"
|
||||
} ${togglingRuleId === rule.ruleId ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
title={effectiveMarkAsFailed
|
||||
? `KO criterion: when this rule fires, the session is marked as failed${koOverrideIsExplicit ? " (tenant override)" : " (rule default)"}. Click to disable.`
|
||||
: `Not a KO criterion${koOverrideIsExplicit ? " (tenant override)" : ""}. Click to enable — firing the rule will then mark the session as failed.`}
|
||||
>
|
||||
{effectiveMarkAsFailed ? "KO" : "KO off"}
|
||||
{koOverrideIsExplicit && <span className="ml-1 opacity-70">*</span>}
|
||||
</button>
|
||||
)}
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${sevColor.bg} ${sevColor.text} flex-shrink-0`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${sevColor.dot} mr-1.5`}></span>
|
||||
{rule.severity.charAt(0).toUpperCase() + rule.severity.slice(1)}
|
||||
|
||||
@@ -187,6 +187,36 @@ export default function AnalyzeRulesPage() {
|
||||
setTogglingRuleId(null);
|
||||
};
|
||||
|
||||
// Toggle the "mark session as failed" (KO-criterion) override for a rule.
|
||||
// Cycles between: default (unset) → opt-in (true) → opt-out (false) → default.
|
||||
// Built-in rules' definitions ship a `markSessionAsFailedDefault`; the tenant
|
||||
// state only stores an explicit override when it differs from the default.
|
||||
const handleToggleMarkAsFailed = async (rule: AnalyzeRule) => {
|
||||
const currentEffective = rule.markSessionAsFailed ?? rule.markSessionAsFailedDefault ?? false;
|
||||
const nextEffective = !currentEffective;
|
||||
// Write the override only when it diverges from the rule default; otherwise clear it
|
||||
// so future default changes propagate.
|
||||
const nextOverride: boolean | null =
|
||||
nextEffective === (rule.markSessionAsFailedDefault ?? false) ? null : nextEffective;
|
||||
|
||||
setTogglingRuleId(rule.ruleId);
|
||||
const result = await mutate(
|
||||
api.rules.analyzeRule(rule.ruleId),
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...rule, markSessionAsFailed: nextOverride }),
|
||||
}
|
||||
);
|
||||
if (result !== null) {
|
||||
setRules((prev) =>
|
||||
(prev || []).map((r) => (r.ruleId === rule.ruleId ? { ...r, markSessionAsFailed: nextOverride } : r))
|
||||
);
|
||||
showSuccess(`Rule "${rule.title}" ${nextEffective ? "set as KO criterion (session will be marked failed)" : "will no longer mark session as failed"}`);
|
||||
}
|
||||
setTogglingRuleId(null);
|
||||
};
|
||||
|
||||
// Delete custom rule
|
||||
const handleDeleteRule = async (rule: AnalyzeRule) => {
|
||||
if (!confirm(`Are you sure you want to delete the rule "${rule.title}"? This action cannot be undone.`)) {
|
||||
@@ -703,6 +733,7 @@ export default function AnalyzeRulesPage() {
|
||||
if (expandedRuleId === rule.ruleId && editingRuleId === rule.ruleId) setEditingRuleId(null);
|
||||
}}
|
||||
onToggleEnabled={handleToggleRule}
|
||||
onToggleMarkAsFailed={handleToggleMarkAsFailed}
|
||||
onStartEditing={startEditing}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onCancelEdit={() => { setEditingRuleId(null); setJsonModeEdit(false); setJsonError(null); }}
|
||||
|
||||
@@ -63,6 +63,10 @@ export interface AnalyzeRule {
|
||||
tags: string[];
|
||||
templateVariables?: TemplateVariable[];
|
||||
derivedFromTemplateRuleId?: string;
|
||||
/** Rule-definition default for "fire → mark the whole session as failed". */
|
||||
markSessionAsFailedDefault?: boolean;
|
||||
/** Tenant override. `undefined`/`null` = inherit the default. */
|
||||
markSessionAsFailed?: boolean | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -86,7 +90,7 @@ export interface RuleForm {
|
||||
export const CATEGORIES = ["network", "identity", "apps", "device", "esp", "enrollment"] as const;
|
||||
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"] 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 SEVERITY_COLORS: Record<string, { bg: string; text: string; border: string; dot: string }> = {
|
||||
|
||||
+25
-11
@@ -50,7 +50,7 @@ export default function SessionInfoCard({ session, enrollmentDuration, displaySt
|
||||
}
|
||||
copyText={session.sessionId}
|
||||
/>
|
||||
<InfoItem label="Status" value={<StatusBadge status={displayStatus} failureReason={session.failureReason} />} />
|
||||
<InfoItem label="Status" value={<StatusBadge status={displayStatus} failureReason={session.failureReason} failureSource={session.failureSource} />} />
|
||||
<InfoItem label="Started" value={new Date(session.startedAt).toLocaleString([], { dateStyle: "short", timeStyle: "short" })} />
|
||||
<InfoItem label="Duration" value={enrollmentDuration ?? `${Math.round(session.durationSeconds / 60)} min`} />
|
||||
<InfoItem label="Events" value={session.eventCount.toString()} />
|
||||
@@ -129,7 +129,7 @@ function InfoItem({ label, value, copyText }: { label: string; value: React.Reac
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status, failureReason }: { status: string; failureReason?: string }) {
|
||||
function StatusBadge({ status, failureReason, failureSource }: { status: string; failureReason?: string; failureSource?: string }) {
|
||||
const statusConfig = {
|
||||
InProgress: { color: "bg-blue-100 text-blue-800", text: "In Progress" },
|
||||
Pending: { color: "bg-amber-100 text-amber-800", text: "Pending" },
|
||||
@@ -142,17 +142,31 @@ function StatusBadge({ status, failureReason }: { status: string; failureReason?
|
||||
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.Unknown;
|
||||
|
||||
const isTimeout = status === "Failed" && failureReason && failureReason.toLowerCase().includes("timed out");
|
||||
const ruleId = status === "Failed" && failureSource && failureSource.startsWith("rule:")
|
||||
? failureSource.substring("rule:".length)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`px-2 inline-flex items-center gap-1 text-xs leading-5 font-semibold rounded-full ${config.color}`}
|
||||
title={failureReason || undefined}
|
||||
>
|
||||
{config.text}
|
||||
{isTimeout && (
|
||||
<span title={failureReason} className="inline-flex items-center">
|
||||
⏱️
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={`px-2 inline-flex items-center gap-1 text-xs leading-5 font-semibold rounded-full ${config.color}`}
|
||||
title={failureReason || undefined}
|
||||
>
|
||||
{config.text}
|
||||
{isTimeout && (
|
||||
<span title={failureReason} className="inline-flex items-center">
|
||||
⏱️
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{ruleId && (
|
||||
<a
|
||||
href={`/analyze-rules?highlight=${encodeURIComponent(ruleId)}`}
|
||||
className="px-1.5 py-0.5 text-[10px] leading-4 font-semibold rounded border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 transition-colors"
|
||||
title={`Failed by analyze rule ${ruleId}${failureReason ? ` — ${failureReason}` : ""}`}
|
||||
>
|
||||
via rule {ruleId}
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* AUTO-GENERATED from rules/guardrails.json — DO NOT EDIT.
|
||||
* Run: node rules/scripts/combine.js
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Categorized data (for documentation / UI display)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GuardrailCategory {
|
||||
readonly category: string;
|
||||
readonly items: readonly string[];
|
||||
}
|
||||
|
||||
export const REGISTRY_PREFIX_CATEGORIES: readonly GuardrailCategory[] = [
|
||||
{
|
||||
category: "MDM / Enrollment",
|
||||
items: [
|
||||
"SOFTWARE\\Microsoft\\Enrollments",
|
||||
"SOFTWARE\\Microsoft\\EnterpriseDesktopAppManagement",
|
||||
"SOFTWARE\\Microsoft\\Provisioning",
|
||||
"SOFTWARE\\Microsoft\\PolicyManager",
|
||||
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\MDM",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "AAD / Entra Join",
|
||||
items: [
|
||||
"SOFTWARE\\Microsoft\\IdentityStore",
|
||||
"SYSTEM\\CurrentControlSet\\Control\\CloudDomainJoin",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Windows Update / WUfB",
|
||||
items: [
|
||||
"SOFTWARE\\Microsoft\\WindowsUpdate",
|
||||
"SOFTWARE\\Policies\\Microsoft\\Windows\\WindowsUpdate",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "BitLocker",
|
||||
items: [
|
||||
"SOFTWARE\\Microsoft\\BitLocker",
|
||||
"SYSTEM\\CurrentControlSet\\Control\\BitLockerStatus",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Network / Proxy",
|
||||
items: [
|
||||
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
|
||||
"SYSTEM\\CurrentControlSet\\Services\\Tcpip",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Autopilot / OOBE / Setup",
|
||||
items: [
|
||||
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Setup",
|
||||
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\OOBE",
|
||||
"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "TPM",
|
||||
items: [
|
||||
"SYSTEM\\CurrentControlSet\\Services\\TPM",
|
||||
"SOFTWARE\\Microsoft\\Tpm",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Secure Boot",
|
||||
items: [
|
||||
"SYSTEM\\CurrentControlSet\\Control\\SecureBoot",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Intune IME",
|
||||
items: [
|
||||
"SOFTWARE\\Microsoft\\IntuneManagementExtension",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Certificates (SCEP)",
|
||||
items: [
|
||||
"SOFTWARE\\Microsoft\\SystemCertificates",
|
||||
"SOFTWARE\\Policies\\Microsoft\\SystemCertificates",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Servicing",
|
||||
items: [
|
||||
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const COMMAND_CATEGORIES: readonly GuardrailCategory[] = [
|
||||
{
|
||||
category: "TPM and Security",
|
||||
items: [
|
||||
"Get-Tpm",
|
||||
"Get-SecureBootPolicy",
|
||||
"Get-SecureBootUEFI -Name SetupMode",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "BitLocker",
|
||||
items: [
|
||||
"Get-BitLockerVolume -MountPoint C:",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Network",
|
||||
items: [
|
||||
"Get-NetAdapter | Select-Object Name, Status, InterfaceDescription, MacAddress, LinkSpeed",
|
||||
"Get-DnsClientServerAddress | Select-Object InterfaceAlias, ServerAddresses",
|
||||
"Get-NetIPConfiguration | Select-Object InterfaceAlias, IPv4Address, IPv4DefaultGateway, DNSServer",
|
||||
"netsh winhttp show proxy",
|
||||
"ipconfig /all",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Domain / Identity",
|
||||
items: [
|
||||
"nltest /dsgetdc:",
|
||||
"dsregcmd /status",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Certificate",
|
||||
items: [
|
||||
"certutil -store My",
|
||||
"$c = Get-ChildItem Cert:\\LocalMachine\\My; if ($c) { $c | Select-Object Subject, Thumbprint, Issuer, NotBefore, NotAfter, HasPrivateKey | ConvertTo-Json } else { '{\"CertificateCount\": 0}' }",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Windows Update",
|
||||
items: [
|
||||
"Get-HotFix | Select-Object -First 10 HotFixID, InstalledOn, Description",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flat arrays (for validation logic)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ALLOWED_REGISTRY_PREFIXES: readonly string[] = [
|
||||
"SOFTWARE\\Microsoft\\Enrollments",
|
||||
"SOFTWARE\\Microsoft\\EnterpriseDesktopAppManagement",
|
||||
"SOFTWARE\\Microsoft\\Provisioning",
|
||||
"SOFTWARE\\Microsoft\\PolicyManager",
|
||||
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\MDM",
|
||||
"SOFTWARE\\Microsoft\\IdentityStore",
|
||||
"SYSTEM\\CurrentControlSet\\Control\\CloudDomainJoin",
|
||||
"SOFTWARE\\Microsoft\\WindowsUpdate",
|
||||
"SOFTWARE\\Policies\\Microsoft\\Windows\\WindowsUpdate",
|
||||
"SOFTWARE\\Microsoft\\BitLocker",
|
||||
"SYSTEM\\CurrentControlSet\\Control\\BitLockerStatus",
|
||||
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
|
||||
"SYSTEM\\CurrentControlSet\\Services\\Tcpip",
|
||||
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Setup",
|
||||
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\OOBE",
|
||||
"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon",
|
||||
"SYSTEM\\CurrentControlSet\\Services\\TPM",
|
||||
"SOFTWARE\\Microsoft\\Tpm",
|
||||
"SYSTEM\\CurrentControlSet\\Control\\SecureBoot",
|
||||
"SOFTWARE\\Microsoft\\IntuneManagementExtension",
|
||||
"SOFTWARE\\Microsoft\\SystemCertificates",
|
||||
"SOFTWARE\\Policies\\Microsoft\\SystemCertificates",
|
||||
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing",
|
||||
];
|
||||
|
||||
export const ALLOWED_FILE_PREFIXES: readonly string[] = [
|
||||
"C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs",
|
||||
"C:\\Windows\\CCM\\Logs",
|
||||
"C:\\Windows\\Logs",
|
||||
"C:\\Windows\\Panther",
|
||||
"C:\\Windows\\SetupDiag",
|
||||
"C:\\ProgramData\\Microsoft\\DiagnosticLogCSP",
|
||||
"C:\\Windows\\SoftwareDistribution\\ReportingEvents.log",
|
||||
];
|
||||
|
||||
export const ALLOWED_WMI_QUERY_PREFIXES: readonly string[] = [
|
||||
"SELECT * FROM Win32_OperatingSystem",
|
||||
"SELECT * FROM Win32_ComputerSystem",
|
||||
"SELECT * FROM Win32_BIOS",
|
||||
"SELECT * FROM Win32_Processor",
|
||||
"SELECT * FROM Win32_BaseBoard",
|
||||
"SELECT * FROM Win32_Battery",
|
||||
"SELECT * FROM Win32_TPM",
|
||||
"SELECT * FROM Win32_NetworkAdapter",
|
||||
"SELECT * FROM Win32_NetworkAdapterConfiguration",
|
||||
"SELECT * FROM Win32_DiskDrive",
|
||||
"SELECT * FROM Win32_LogicalDisk",
|
||||
"SELECT * FROM SoftwareLicensingProduct",
|
||||
];
|
||||
|
||||
export const ALLOWED_COMMANDS_LIST: readonly string[] = [
|
||||
"Get-Tpm",
|
||||
"Get-SecureBootPolicy",
|
||||
"Get-SecureBootUEFI -Name SetupMode",
|
||||
"Get-BitLockerVolume -MountPoint C:",
|
||||
"Get-NetAdapter | Select-Object Name, Status, InterfaceDescription, MacAddress, LinkSpeed",
|
||||
"Get-DnsClientServerAddress | Select-Object InterfaceAlias, ServerAddresses",
|
||||
"Get-NetIPConfiguration | Select-Object InterfaceAlias, IPv4Address, IPv4DefaultGateway, DNSServer",
|
||||
"netsh winhttp show proxy",
|
||||
"ipconfig /all",
|
||||
"nltest /dsgetdc:",
|
||||
"dsregcmd /status",
|
||||
"certutil -store My",
|
||||
"$c = Get-ChildItem Cert:\\LocalMachine\\My; if ($c) { $c | Select-Object Subject, Thumbprint, Issuer, NotBefore, NotAfter, HasPrivateKey | ConvertTo-Json } else { '{\"CertificateCount\": 0}' }",
|
||||
"Get-HotFix | Select-Object -First 10 HotFixID, InstalledOn, Description",
|
||||
];
|
||||
|
||||
export const ALLOWED_DIAGNOSTICS_PATH_PREFIXES: readonly string[] = [
|
||||
"C:\\ProgramData\\AutopilotMonitor",
|
||||
"C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs",
|
||||
"C:\\Windows\\Panther",
|
||||
"C:\\Windows\\Logs",
|
||||
"C:\\Windows\\SetupDiag",
|
||||
"C:\\Windows\\SoftwareDistribution\\ReportingEvents.log",
|
||||
"C:\\Windows\\System32\\winevt\\Logs",
|
||||
"C:\\Windows\\CCM\\Logs",
|
||||
"C:\\ProgramData\\Microsoft\\DiagnosticLogCSP",
|
||||
"C:\\ProgramData\\Microsoft\\Windows\\WER",
|
||||
"C:\\Windows\\Logs\\CBS",
|
||||
"C:\\Install\\Log",
|
||||
];
|
||||
|
||||
export const BLOCKED_FILE_PREFIXES: readonly string[] = [
|
||||
"C:\\Users",
|
||||
];
|
||||
@@ -12,6 +12,8 @@ export interface Session {
|
||||
eventCount: number;
|
||||
durationSeconds: number;
|
||||
failureReason?: string;
|
||||
/** Origin of a Failed status — "" for agent-reported, "rule:<RuleId>" for rule-based, "manual" for portal. */
|
||||
failureSource?: string;
|
||||
enrollmentType?: string; // "v1" | "v2" — absent for sessions before this feature
|
||||
diagnosticsBlobName?: string;
|
||||
lastEventAt?: string;
|
||||
|
||||
Reference in New Issue
Block a user