feat: enhanced analyze rule engine (in not_in support) MarkSessionAsFailed support, new ESP rule for cert errors

This commit is contained in:
Oliver Kieselbach
2026-04-14 21:46:20 +02:00
parent 3dbfdc0dba
commit 45edbab0e1
25 changed files with 726 additions and 42 deletions
+58
View File
@@ -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
View File
@@ -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",
+7 -2
View File
@@ -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",
@@ -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);
}
}
@@ -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)
@@ -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:&lt;RuleId&gt;": 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 }> = {
@@ -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;