From 45edbab0e16dbcc8762cc63410ba9133794e4aee Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 14 Apr 2026 21:46:20 +0200 Subject: [PATCH] feat: enhanced analyze rule engine (in not_in support) MarkSessionAsFailed support, new ESP rule for cert errors --- rules/analyze/ANALYZE-ESP-002.json | 58 +++++ rules/dist/analyze-rules.json | 62 +++++ rules/schema/analyze-rule.schema.json | 9 +- .../Collectors/ProvisioningStatusTracker.cs | 13 + .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../AnalyzeRuleSessionFailureTests.cs | 82 +++++++ .../TableStorage/TableRuleRepository.cs | 6 +- .../TableStorage/TableSessionRepository.cs | 5 +- .../Services/AnalyzeRuleService.cs | 19 +- .../Services/GatherRuleService.cs | 9 +- .../RuleEngine.ConditionEvaluators.cs | 22 ++ .../Services/RuleEngine.cs | 48 ++++ .../Services/TableStorageService.Rules.cs | 30 ++- .../Services/TableStorageService.Sessions.cs | 17 +- .../DataAccess/IRuleRepository.cs | 4 +- .../DataAccess/ISessionRepository.cs | 3 +- .../Models/Rules/AnalyzeRule.cs | 17 ++ .../Models/Rules/RuleState.cs | 22 ++ .../Models/SessionApiModels.cs | 9 + .../components/AnalyzeRuleCard.tsx | 24 +- .../app/analyze-rules/page.tsx | 31 +++ .../app/analyze-rules/types.ts | 6 +- .../components/SessionInfoCard.tsx | 36 ++- .../lib/guardrails.generated.ts | 232 ++++++++++++++++++ .../autopilot-monitor-web/types/session.ts | 2 + 25 files changed, 726 insertions(+), 42 deletions(-) create mode 100644 rules/analyze/ANALYZE-ESP-002.json create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/AnalyzeRuleSessionFailureTests.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/Rules/RuleState.cs create mode 100644 src/Web/autopilot-monitor-web/lib/guardrails.generated.ts diff --git a/rules/analyze/ANALYZE-ESP-002.json b/rules/analyze/ANALYZE-ESP-002.json new file mode 100644 index 00000000..758df3df --- /dev/null +++ b/rules/analyze/ANALYZE-ESP-002.json @@ -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"] +} diff --git a/rules/dist/analyze-rules.json b/rules/dist/analyze-rules.json index 270ca99a..ba7d6d93 100644 --- a/rules/dist/analyze-rules.json +++ b/rules/dist/analyze-rules.json @@ -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", diff --git a/rules/schema/analyze-rule.schema.json b/rules/schema/analyze-rule.schema.json index 3b7675c1..d2436725 100644 --- a/rules/schema/analyze-rule.schema.json +++ b/rules/schema/analyze-rule.schema.json @@ -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", diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/ProvisioningStatusTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/ProvisioningStatusTracker.cs index 0d11880a..b4c14700 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/ProvisioningStatusTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/ProvisioningStatusTracker.cs @@ -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 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index e12ce0f8..3d78d3c5 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -963 +971 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AnalyzeRuleSessionFailureTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AnalyzeRuleSessionFailureTests.cs new file mode 100644 index 00000000..fb6fef01 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AnalyzeRuleSessionFailureTests.cs @@ -0,0 +1,82 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// 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. +/// +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); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableRuleRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableRuleRepository.cs index 268bcaed..f3b305d6 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableRuleRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableRuleRepository.cs @@ -39,10 +39,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public Task DeleteGatherRuleAsync(string tenantId, string ruleId) => _storage.DeleteGatherRuleAsync(tenantId, ruleId); - public Task StoreRuleStateAsync(string tenantId, string ruleId, bool enabled) - => _storage.StoreRuleStateAsync(tenantId, ruleId, enabled); + public Task StoreRuleStateAsync(string tenantId, string ruleId, RuleState state) + => _storage.StoreRuleStateAsync(tenantId, ruleId, state); - public Task> GetRuleStatesAsync(string tenantId) + public Task> GetRuleStatesAsync(string tenantId) => _storage.GetRuleStatesAsync(tenantId); public Task DeleteRuleStateAsync(string tenantId, string ruleId) diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs index 618880d7..c1ec15cd 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs @@ -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, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs b/src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs index ed29081d..5c3e471d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs @@ -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); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs b/src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs index db812f2e..9ecbdeaa 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs @@ -52,11 +52,12 @@ namespace AutopilotMonitor.Functions.Services var mergedRules = new List(); - // 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 diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs index d844f021..7373fa0d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs @@ -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 events) { var sortedEvents = events.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList(); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs index 0bc8fa59..2ec21643 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs @@ -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; } + /// + /// 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. + /// + 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}"); + } + } + /// /// Evaluates a single rule against the full session event stream /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs index 15b8ab2e..280b478d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs @@ -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 /// - public async Task StoreRuleStateAsync(string tenantId, string ruleId, bool enabled) + public async Task 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 } /// - /// 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. /// - public async Task> GetRuleStatesAsync(string tenantId) + public async Task> 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(filter: $"PartitionKey eq '{tenantId}'"); - var states = new Dictionary(); + var states = new Dictionary(); 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(); + return new Dictionary(); } } @@ -330,6 +342,7 @@ namespace AutopilotMonitor.Functions.Services ["TagsJson"] = JsonConvert.SerializeObject(rule.Tags ?? new string[0]), ["TemplateVariablesJson"] = JsonConvert.SerializeObject(rule.TemplateVariables ?? new List()), ["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>(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 }; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs index 563d6200..e7e2ab09 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs @@ -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. /// - public async Task 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 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: / 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", diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IRuleRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IRuleRepository.cs index fade7ead..902da106 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IRuleRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IRuleRepository.cs @@ -20,8 +20,8 @@ namespace AutopilotMonitor.Shared.DataAccess Task DeleteGatherRuleAsync(string tenantId, string ruleId); // --- Rule States --- - Task StoreRuleStateAsync(string tenantId, string ruleId, bool enabled); - Task> GetRuleStatesAsync(string tenantId); + Task StoreRuleStateAsync(string tenantId, string ruleId, RuleState state); + Task> GetRuleStatesAsync(string tenantId); Task DeleteRuleStateAsync(string tenantId, string ruleId); // --- Analyze Rules --- diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs index 50aa37fc..98a95088 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs @@ -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, diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Rules/AnalyzeRule.cs b/src/Shared/AutopilotMonitor.Shared/Models/Rules/AnalyzeRule.cs index 39645b19..ff9d20d8 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Rules/AnalyzeRule.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Rules/AnalyzeRule.cs @@ -129,6 +129,23 @@ namespace AutopilotMonitor.Shared.Models // ===== METADATA ===== + // ===== SESSION FAILURE POLICY ===== + + /// + /// 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 + /// in their RuleState — a firing rule is considered a "KO criterion" for the enrollment when the + /// effective value (override ?? default) is true. + /// + public bool MarkSessionAsFailedDefault { get; set; } = false; + + /// + /// Tenant-scoped override for . 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). + /// + public bool? MarkSessionAsFailed { get; set; } + /// /// Tags for filtering and categorization /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Rules/RuleState.cs b/src/Shared/AutopilotMonitor.Shared/Models/Rules/RuleState.cs new file mode 100644 index 00000000..7760c3b2 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/Rules/RuleState.cs @@ -0,0 +1,22 @@ +namespace AutopilotMonitor.Shared.Models +{ + /// + /// 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". + /// + public class RuleState + { + /// + /// Whether the rule is enabled for this tenant. Overrides the rule's default. + /// + public bool Enabled { get; set; } = true; + + /// + /// Tenant-scoped override for . + /// Null means the tenant has not opted in or out — the rule's default applies. + /// Only meaningful for analyze rules; ignored for gather rules. + /// + public bool? MarkSessionAsFailed { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs index f6a637dc..9be8f776 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs @@ -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!; + + /// + /// 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). + /// + public string FailureSource { get; set; } = string.Empty; public int EventCount { get; set; } public int? DurationSeconds { get; set; } diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx index ea6b3187..d11d847c 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx @@ -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 (
)} + {/* 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 && ( + + )} {rule.severity.charAt(0).toUpperCase() + rule.severity.slice(1)} diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx index 103ed727..83bdee4c 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx @@ -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); }} diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/types.ts b/src/Web/autopilot-monitor-web/app/analyze-rules/types.ts index 2eadb096..0adb8025 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/types.ts +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/types.ts @@ -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 = { diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/SessionInfoCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/SessionInfoCard.tsx index e228c9e3..fd366def 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/SessionInfoCard.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/SessionInfoCard.tsx @@ -50,7 +50,7 @@ export default function SessionInfoCard({ session, enrollmentDuration, displaySt } copyText={session.sessionId} /> - } /> + } /> @@ -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 ( - - {config.text} - {isTimeout && ( - - ⏱️ - + + + {config.text} + {isTimeout && ( + + ⏱️ + + )} + + {ruleId && ( + + via rule {ruleId} + )} ); diff --git a/src/Web/autopilot-monitor-web/lib/guardrails.generated.ts b/src/Web/autopilot-monitor-web/lib/guardrails.generated.ts new file mode 100644 index 00000000..2d64e64f --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/guardrails.generated.ts @@ -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", +]; diff --git a/src/Web/autopilot-monitor-web/types/session.ts b/src/Web/autopilot-monitor-web/types/session.ts index 76a023e0..28455c11 100644 --- a/src/Web/autopilot-monitor-web/types/session.ts +++ b/src/Web/autopilot-monitor-web/types/session.ts @@ -12,6 +12,8 @@ export interface Session { eventCount: number; durationSeconds: number; failureReason?: string; + /** Origin of a Failed status — "" for agent-reported, "rule:" for rule-based, "manual" for portal. */ + failureSource?: string; enrollmentType?: string; // "v1" | "v2" — absent for sessions before this feature diagnosticsBlobName?: string; lastEventAt?: string;