diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/ConsolePrefetchScannerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/ConsolePrefetchScannerTests.cs new file mode 100644 index 00000000..9b62dde9 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/ConsolePrefetchScannerTests.cs @@ -0,0 +1,165 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Persistence; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers +{ + /// + /// ConsolePrefetchScanner — startup forensic over a synthetic prefetch directory with an injected + /// boot time (no WMI / real %WINDIR%). Asserts the post-boot-cmd-artifact heuristic: a CMD.EXE-*.pf + /// whose last-run is after boot emits a Warning console_prefetch_detected; a stale (pre-boot) + /// artifact, a missing artifact, or a missing directory stay silent. Restart dedup via the gate. + /// + public sealed class ConsolePrefetchScannerTests + { + private static readonly DateTime BootUtc = new DateTime(2026, 6, 19, 1, 9, 0, DateTimeKind.Utc); + + private static AgentLogger NewLogger(string dir) => new AgentLogger(dir, AgentLogLevel.Info); + + private sealed class Rig : IDisposable + { + private readonly TempDirectory _tmp = new TempDirectory(); + public FakeSignalIngressSink Sink { get; } = new FakeSignalIngressSink(); + public string PrefetchDir { get; } + public string StateDir { get; } + + public Rig() + { + PrefetchDir = Path.Combine(_tmp.Path, "Prefetch"); + StateDir = Path.Combine(_tmp.Path, "state"); + Directory.CreateDirectory(StateDir); + } + + public void CreatePrefetch() => Directory.CreateDirectory(PrefetchDir); + + public void WritePf(string name, DateTime lastWriteUtc) + { + Directory.CreateDirectory(PrefetchDir); + var path = Path.Combine(PrefetchDir, name); + File.WriteAllText(path, "pf"); + File.SetLastWriteTimeUtc(path, lastWriteUtc); + } + + public ConsolePrefetchScanner NewScanner(DateTime? bootTime = null, StartupEventGate? gate = null) + { + var post = new InformationalEventPost(Sink, new VirtualClock(BootUtc)); + return new ConsolePrefetchScanner( + "S1", "T1", post, NewLogger(StateDir), gate, + prefetchDirectory: PrefetchDir, + bootTimeProvider: () => bootTime ?? BootUtc); + } + + public StartupEventGate NewGate() => new StartupEventGate(StateDir, NewLogger(StateDir)); + + public FakeSignalIngressSink.PostedSignal? PrefetchEvent() => Sink.Posted.FirstOrDefault(p => + p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == "console_prefetch_detected"); + + public void Dispose() => _tmp.Dispose(); + } + + [Fact] + public void Cmd_prefetch_after_boot_emits_warning_with_signature() + { + using var rig = new Rig(); + rig.WritePf("CMD.EXE-0BD30981.pf", BootUtc.AddMinutes(4)); // ran after boot + + rig.NewScanner().AnalyzeAtStartup(); + + var evt = rig.PrefetchEvent(); + Assert.NotNull(evt); + Assert.Equal("Warning", evt!.Payload![SignalPayloadKeys.Severity]); + + var data = Assert.IsAssignableFrom>(evt.TypedPayload!); + Assert.Equal("console_prefetch_after_boot", data["decision"]); + Assert.Equal("CMD.EXE-0BD30981.pf", data["artifact"]); + Assert.Equal(true, data["ranAfterBoot"]); + Assert.Equal(false, data["coverageComplete"]); + } + + [Fact] + public void Conhost_artifact_after_boot_is_listed_as_corroborating() + { + using var rig = new Rig(); + rig.WritePf("CMD.EXE-0BD30981.pf", BootUtc.AddMinutes(4)); + rig.WritePf("CONHOST.EXE-0C6456FB.pf", BootUtc.AddMinutes(4)); + + rig.NewScanner().AnalyzeAtStartup(); + + var data = Assert.IsAssignableFrom>(rig.PrefetchEvent()!.TypedPayload!); + var corroborating = Assert.IsAssignableFrom>(data["corroboratingArtifacts"]); + Assert.Contains("CONHOST.EXE-0C6456FB.pf", corroborating); + } + + [Fact] + public void Stale_pre_boot_cmd_artifact_is_not_flagged() + { + using var rig = new Rig(); + rig.WritePf("CMD.EXE-0BD30981.pf", BootUtc.AddMinutes(-30)); // image-build artifact + + rig.NewScanner().AnalyzeAtStartup(); + + Assert.Null(rig.PrefetchEvent()); + } + + [Fact] + public void No_cmd_artifact_stays_silent() + { + using var rig = new Rig(); + rig.CreatePrefetch(); // dir exists, but no CMD.EXE-*.pf + + rig.NewScanner().AnalyzeAtStartup(); + + Assert.Null(rig.PrefetchEvent()); + } + + [Fact] + public void Missing_prefetch_directory_stays_silent() + { + using var rig = new Rig(); // never creates the Prefetch dir (prefetch disabled) + + rig.NewScanner().AnalyzeAtStartup(); + + Assert.Null(rig.PrefetchEvent()); + } + + [Fact] + public void Gate_suppresses_a_second_unchanged_run() + { + using var rig = new Rig(); + rig.WritePf("CMD.EXE-0BD30981.pf", BootUtc.AddMinutes(4)); + var gate = rig.NewGate(); + + rig.NewScanner(gate: gate).AnalyzeAtStartup(); + rig.NewScanner(gate: gate).AnalyzeAtStartup(); // restart, same artifact + + var emitted = rig.Sink.Posted.Count(p => + p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == "console_prefetch_detected"); + Assert.Equal(1, emitted); + } + + [Fact] + public void Shutdown_is_a_noop() + { + using var rig = new Rig(); + rig.WritePf("CMD.EXE-0BD30981.pf", BootUtc.AddMinutes(4)); + + rig.NewScanner().AnalyzeAtShutdown(); + + Assert.Null(rig.PrefetchEvent()); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Security/ConsoleBypassWatcherTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Security/ConsoleBypassWatcherTests.cs new file mode 100644 index 00000000..7c78b2ba --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Security/ConsoleBypassWatcherTests.cs @@ -0,0 +1,119 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security +{ + /// + /// ConsoleBypassWatcher classification core — driven via the internal HandleStart with an + /// injected parent-name resolver (no real WMI / processes). Asserts the winlogon-parent + /// discriminator: a cmd.exe parented to winlogon.exe raises exactly one detection; any other + /// parent (or an unresolvable one) is ignored, keeping the false-positive rate near zero. + /// + public sealed class ConsoleBypassWatcherTests + { + private static AgentLogger NewLogger(string dir) => new AgentLogger(dir, AgentLogLevel.Info); + + private sealed class Rig : IDisposable + { + private readonly TempDirectory _tmp = new TempDirectory(); + public List Detected { get; } = new List(); + public ConsoleBypassWatcher Sut { get; } + + public Rig(Func parentResolver) + { + Sut = new ConsoleBypassWatcher(NewLogger(_tmp.Path), parentResolver); + Sut.BypassConsoleDetected += (_, info) => Detected.Add(info); + } + + public void Dispose() { Sut.Dispose(); _tmp.Dispose(); } + } + + [Fact] + public void Parent_winlogon_raises_one_detection_with_full_signature() + { + using var rig = new Rig(parentPid => parentPid == 612 ? "winlogon" : "explorer"); + + rig.Sut.HandleStart(pid: 7244, parentPid: 612, sessionId: 1, detectedVia: "process_start_trace"); + + var info = Assert.Single(rig.Detected); + Assert.Equal("cmd.exe", info.ProcessName); + Assert.Equal(7244, info.ProcessId); + Assert.Equal("winlogon.exe", info.ParentProcessName); + Assert.Equal(612, info.ParentProcessId); + Assert.Equal(1, info.SessionId); + Assert.Equal("process_start_trace", info.DetectedVia); + } + + [Fact] + public void Parent_winlogon_match_is_case_insensitive() + { + using var rig = new Rig(_ => "WinLogon"); + rig.Sut.HandleStart(7244, 612, 1, "startup_probe"); + Assert.Single(rig.Detected); + } + + [Fact] + public void Non_winlogon_parent_is_ignored() + { + using var rig = new Rig(_ => "explorer"); // ordinary install-launched cmd + rig.Sut.HandleStart(7244, 999, 1, "process_start_trace"); + Assert.Empty(rig.Detected); + } + + [Fact] + public void Unresolvable_parent_is_ignored() + { + using var rig = new Rig(_ => null); + rig.Sut.HandleStart(7244, 612, 1, "process_start_trace"); + Assert.Empty(rig.Detected); + } + + [Fact] + public void Parent_resolver_throwing_is_swallowed_and_not_flagged() + { + using var rig = new Rig(_ => throw new InvalidOperationException("parent gone")); + rig.Sut.HandleStart(7244, 612, 1, "process_start_trace"); + Assert.Empty(rig.Detected); // conservative: cannot confirm winlogon → no false positive + } + + [Fact] + public void Same_pid_seen_twice_raises_only_once() + { + // The startup probe and the live trace can both observe the same still-running console. + using var rig = new Rig(_ => "winlogon"); + rig.Sut.HandleStart(7244, 612, 1, "startup_probe"); + rig.Sut.HandleStart(7244, 612, 1, "process_start_trace"); + Assert.Single(rig.Detected); + } + + [Fact] + public void Non_winlogon_start_does_not_suppress_a_later_winlogon_start_with_same_pid() + { + // A non-winlogon cmd (pid 7244) exits; the OS reuses pid 7244 for a real Shift+F10 + // console. The first non-match must NOT have marked the pid seen. + var parentNames = new Dictionary { [999] = "explorer", [612] = "winlogon" }; + using var rig = new Rig(parentPid => parentNames[parentPid]); + + rig.Sut.HandleStart(7244, 999, 1, "process_start_trace"); // ordinary cmd → ignored + rig.Sut.HandleStart(7244, 612, 1, "process_start_trace"); // reused pid, real bypass + + Assert.Single(rig.Detected); + Assert.Equal(612, rig.Detected[0].ParentProcessId); + } + + [Theory] + [InlineData(0, 612)] + [InlineData(7244, 0)] + public void Invalid_pids_are_ignored(int pid, int parentPid) + { + using var rig = new Rig(_ => "winlogon"); + rig.Sut.HandleStart(pid, parentPid, 1, "process_start_trace"); + Assert.Empty(rig.Detected); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/AgentAnalyzerManagerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/AgentAnalyzerManagerTests.cs index 1ae54e4c..d6fefea4 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/AgentAnalyzerManagerTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/AgentAnalyzerManagerTests.cs @@ -43,6 +43,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime EnableLocalAdminAnalyzer = false, EnableSoftwareInventoryAnalyzer = false, EnableIntegrityBypassAnalyzer = false, + EnableConsoleBypassDetection = false, // default-on opt-out; off for this isolation test }); sut.Initialize(); @@ -62,6 +63,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime EnableLocalAdminAnalyzer = true, EnableSoftwareInventoryAnalyzer = false, EnableIntegrityBypassAnalyzer = true, + EnableConsoleBypassDetection = false, // default-on opt-out; off so the count stays exact }); sut.Initialize(); @@ -74,6 +76,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime Assert.Equal(3, sut.Analyzers.Count); } + [Fact] + public void Initialize_registers_ConsolePrefetchScanner_when_console_bypass_detection_on() + { + using var tmp = new TempDirectory(); + var sut = new AgentAnalyzerManager( + Config(), NewLogger(tmp.Path), NewPost(), + new AnalyzerConfiguration + { + EnableLocalAdminAnalyzer = false, + EnableSoftwareInventoryAnalyzer = false, + EnableIntegrityBypassAnalyzer = false, + EnableConsoleBypassDetection = true, + }); + + sut.Initialize(); + Assert.Contains("ConsolePrefetchScanner", sut.Analyzers.Select(a => a.Name)); + } + + [Fact] + public void Initialize_omits_ConsolePrefetchScanner_when_console_bypass_detection_off() + { + using var tmp = new TempDirectory(); + var sut = new AgentAnalyzerManager( + Config(), NewLogger(tmp.Path), NewPost(), + new AnalyzerConfiguration + { + EnableLocalAdminAnalyzer = false, + EnableSoftwareInventoryAnalyzer = false, + EnableIntegrityBypassAnalyzer = false, + EnableConsoleBypassDetection = false, + }); + + sut.Initialize(); + Assert.DoesNotContain("ConsolePrefetchScanner", sut.Analyzers.Select(a => a.Name)); + } + [Fact] public void Initialize_tolerates_null_analyzer_config() { @@ -81,10 +119,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime var sut = new AgentAnalyzerManager(Config(), NewLogger(tmp.Path), NewPost(), analyzerConfig: null); sut.Initialize(); - // Default AnalyzerConfiguration enables LocalAdmin + IntegrityBypass, disables SoftwareInventory. + // Default AnalyzerConfiguration enables LocalAdmin + IntegrityBypass + ConsoleBypassDetection + // (opt-out), disables SoftwareInventory. var names = sut.Analyzers.Select(a => a.Name).ToList(); Assert.Contains("LocalAdminAnalyzer", names); Assert.Contains("IntegrityBypassAnalyzer", names); + Assert.Contains("ConsolePrefetchScanner", names); Assert.Contains("AutoLogonAnalyzer", names); Assert.DoesNotContain("SoftwareInventoryAnalyzer", names); } @@ -194,6 +234,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime EnableLocalAdminAnalyzer = false, EnableSoftwareInventoryAnalyzer = false, EnableIntegrityBypassAnalyzer = false, + EnableConsoleBypassDetection = false, }); sut.Initialize(); var ex = Record.Exception(() => sut.RunShutdown()); @@ -213,6 +254,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime EnableLocalAdminAnalyzer = false, EnableSoftwareInventoryAnalyzer = false, EnableIntegrityBypassAnalyzer = false, + EnableConsoleBypassDetection = false, }); sut.RunDeviceSetupCompleteAutoLogonCheck(); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/ConsolePrefetchScanner.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/ConsolePrefetchScanner.cs new file mode 100644 index 00000000..c8b36750 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/ConsolePrefetchScanner.cs @@ -0,0 +1,236 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers +{ + /// + /// Startup forensic complement to the live . The live + /// watcher only sees consoles spawned after the agent started; a Shift+F10 pressed earlier + /// in OOBE (before the agent installed) is its blind spot. Windows writes a Prefetch artifact + /// (%WINDIR%\Prefetch\CMD.EXE-*.pf) when cmd.exe runs — empirically confirmed to fire for a + /// Shift+F10 console in OOBE — so a cmd prefetch file whose last-run is after this boot is a + /// coarse "a console executed on this device" signal that covers the pre-agent window. + /// + /// Honest limits (encoded in the event payload): + /// + /// cmd.exe shares ONE prefetch file regardless of launcher. Once ESP runs, Intune Win32 + /// apps legitimately spawn cmd.exe /c ..., so the last-run timestamp can no longer be + /// attributed to Shift+F10 vs. an install — hence attribution: "coarse". + /// v1 reads only the file's NTFS last-write time (stat-only); it does NOT parse the + /// MAM-compressed run-history inside the .pf. + /// Prefetch may be disabled on some images → no artifact, no signal (fail-soft). + /// + /// Runs only at startup (the artifact is pre-enrollment state); shutdown is a no-op — a console + /// opened during enrollment is the live watcher's job. Restart-deduped via the StartupEventGate. + /// + /// + public sealed class ConsolePrefetchScanner : IAgentAnalyzer + { + internal const string CmdPrefetchPattern = "CMD.EXE-*.pf"; + internal const string ConhostPrefetchPattern = "CONHOST.EXE-*.pf"; + + private const string Attribution = + "coarse: a console executed on this device; cannot distinguish a human Shift+F10 from a " + + "legitimate install-launched cmd once ESP is running (cmd.exe shares one prefetch artifact)"; + private const string CoverageNote = + "complements the live winlogon-parent watcher by covering the pre-agent OOBE window"; + + private readonly string _sessionId; + private readonly string _tenantId; + private readonly InformationalEventPost _post; + private readonly AgentLogger _logger; + private readonly Core.Persistence.StartupEventGate? _startupGate; + private readonly string _prefetchDirectory; + private readonly Func _bootTimeProvider; + + public string Name => "ConsolePrefetchScanner"; + + public ConsolePrefetchScanner( + string sessionId, + string tenantId, + InformationalEventPost post, + AgentLogger logger, + Core.Persistence.StartupEventGate? startupGate = null, + string? prefetchDirectory = null, + Func? bootTimeProvider = null) + { + _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + _post = post ?? throw new ArgumentNullException(nameof(post)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _startupGate = startupGate; + _prefetchDirectory = prefetchDirectory ?? DefaultPrefetchDirectory(); + _bootTimeProvider = bootTimeProvider ?? QueryBootTimeUtc; + } + + public void AnalyzeAtStartup() + { + _logger.Info($"{Name}: scanning prefetch for a post-boot cmd.exe console artifact"); + try { RunScan(); } + catch (Exception ex) { _logger.Error($"{Name}: scan failed unexpectedly", ex); } + } + + public void AnalyzeAtShutdown() + { + // A console opened DURING enrollment is the live watcher's job; the prefetch artifact is + // ambiguous once ESP-launched cmd contaminates it. Re-scanning at shutdown would add noise. + _logger.Debug($"{Name}: shutdown no-op (pre-agent forensic already captured at startup)"); + } + + private void RunScan() + { + if (!Directory.Exists(_prefetchDirectory)) + { + _logger.Debug($"{Name}: prefetch directory not present ({_prefetchDirectory}) — prefetch likely disabled, no signal"); + return; + } + + var cmdArtifact = NewestArtifact(CmdPrefetchPattern); + if (cmdArtifact == null) + { + _logger.Debug($"{Name}: no {CmdPrefetchPattern} artifact present — cmd.exe has not run"); + return; + } + + var bootTimeUtc = _bootTimeProvider(); + if (!bootTimeUtc.HasValue) + { + _logger.Warning($"{Name}: could not determine boot time — cannot evaluate ranAfterBoot; skipping"); + return; + } + + bool ranAfterBoot = cmdArtifact.Value.lastRunUtc > bootTimeUtc.Value; + if (!ranAfterBoot) + { + // Stale artifact from the image build / sysprep — not this boot's console. + _logger.Debug($"{Name}: cmd prefetch last-run {cmdArtifact.Value.lastRunUtc:o} predates boot " + + $"{bootTimeUtc.Value:o} — stale image artifact, not flagged"); + return; + } + + var corroborating = ArtifactsRanAfter(ConhostPrefetchPattern, bootTimeUtc.Value); + + var data = new Dictionary + { + { "decision", "console_prefetch_after_boot" }, + { "artifact", cmdArtifact.Value.name }, + { "prefetchLastRunUtc", cmdArtifact.Value.lastRunUtc.ToString("o") }, + { "bootTimeUtc", bootTimeUtc.Value.ToString("o") }, + { "ranAfterBoot", true }, + { "corroboratingArtifacts", corroborating }, + { "detectedVia", "PrefetchTimestampScan" }, + { "attribution", Attribution }, + { "coverageNote", CoverageNote }, + { "coverageComplete", false }, + }; + + // Restart dedup: the same artifact + last-run was already reported by a previous agent run + // in this enrollment. A NEW cmd run (newer last-run) changes the fingerprint and re-emits. + if (_startupGate != null + && !_startupGate.ShouldEmit( + Constants.EventTypes.ConsolePrefetchDetected, + Core.Persistence.StartupEventGate.ComputeFingerprint(data, excludedKeys: new[] { "bootTimeUtc" }))) + { + _logger.Debug($"{Name}: same cmd prefetch artifact already reported — suppressed (restart dedup)"); + return; + } + + _logger.Warning($"{Name}: cmd.exe prefetch artifact ran after boot ({cmdArtifact.Value.name}, " + + $"last-run {cmdArtifact.Value.lastRunUtc:o}) — possible Shift+F10 console (pre-agent window)"); + + _post.Emit(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = Constants.EventTypes.ConsolePrefetchDetected, + Severity = EventSeverity.Warning, + Source = Name, + Phase = EnrollmentPhase.Unknown, + Message = "cmd.exe prefetch artifact ran after boot — possible Shift+F10 console (pre-agent window)", + ImmediateUpload = true, + Data = data, + }); + } + + /// Newest (max last-write) artifact matching , or null. + private (string name, DateTime lastRunUtc)? NewestArtifact(string pattern) + { + (string name, DateTime lastRunUtc)? newest = null; + foreach (var path in EnumerateArtifacts(pattern)) + { + DateTime lastWriteUtc; + try { lastWriteUtc = File.GetLastWriteTimeUtc(path); } + catch (Exception ex) { _logger.Debug($"{Name}: stat failed for {path}: {ex.Message}"); continue; } + + if (newest == null || lastWriteUtc > newest.Value.lastRunUtc) + newest = (Path.GetFileName(path), lastWriteUtc); + } + return newest; + } + + private List ArtifactsRanAfter(string pattern, DateTime thresholdUtc) + { + var hits = new List(); + foreach (var path in EnumerateArtifacts(pattern)) + { + try + { + if (File.GetLastWriteTimeUtc(path) > thresholdUtc) + hits.Add(Path.GetFileName(path)); + } + catch (Exception ex) { _logger.Debug($"{Name}: stat failed for {path}: {ex.Message}"); } + } + // Stable order: Directory.EnumerateFiles gives no ordering guarantee, and this list feeds + // the StartupEventGate fingerprint — an unsorted set would hash differently across restarts + // and re-emit the same finding. + hits.Sort(StringComparer.OrdinalIgnoreCase); + return hits; + } + + private IEnumerable EnumerateArtifacts(string pattern) + { + try { return Directory.EnumerateFiles(_prefetchDirectory, pattern, SearchOption.TopDirectoryOnly); } + catch (Exception ex) + { + _logger.Debug($"{Name}: enumerate '{pattern}' in {_prefetchDirectory} failed: {ex.Message}"); + return Array.Empty(); + } + } + + private static string DefaultPrefetchDirectory() + => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Windows), + "Prefetch"); + + private DateTime? QueryBootTimeUtc() + { + try + { + using var searcher = new ManagementObjectSearcher("SELECT LastBootUpTime FROM Win32_OperatingSystem"); + foreach (var mo in searcher.Get()) + { + using (mo) + { + var raw = mo["LastBootUpTime"]?.ToString(); + if (string.IsNullOrEmpty(raw)) continue; + var local = ManagementDateTimeConverter.ToDateTime(raw); + return DateTime.SpecifyKind(local.ToUniversalTime(), DateTimeKind.Utc); + } + } + } + catch (Exception ex) + { + _logger.Warning($"{Name}: boot-time query failed: {ex.Message}"); + } + return null; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Security/ConsoleBypassWatcher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Security/ConsoleBypassWatcher.cs new file mode 100644 index 00000000..0b47cee5 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Security/ConsoleBypassWatcher.cs @@ -0,0 +1,237 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Management; +using AutopilotMonitor.Agent.V2.Core.Logging; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security +{ + /// + /// Live watcher for a SYSTEM console opened during enrollment — the classic Shift+F10 + /// OOBE bypass, where winlogon.exe spawns a cmd.exe running as SYSTEM. The + /// discriminator is not the keystroke (un-hookable across sessions from session 0) but the + /// process signature: a watched console process whose parent is winlogon.exe. + /// That parent cleanly separates the bypass console from ordinary install-launched cmd (parented + /// to IME / a script host), keeping the false-positive rate near zero. + /// + /// Mirrors : a true ETW-backed push via WMI + /// Win32_ProcessStartTrace (no idle polling) plus a one-shot startup probe over + /// Win32_Process so a console still open when the agent boots (realistic during OOBE) is + /// still caught. Fail-soft — if the WMI watcher cannot start it is logged and the startup probe + /// still covers the already-open case; no polling fallback by design. + /// + /// + /// Coverage is best-effort: the agent installs during ESP, so a Shift+F10 pressed earlier + /// in OOBE (before the agent exists) is invisible here — that pre-agent window is covered, coarsely, + /// by the . The emitted event documents this gap. + /// + /// + public sealed class ConsoleBypassWatcher : IDisposable + { + /// + /// Console executables treated as a bypass console when parented to winlogon. v1 watches only + /// cmd.exe (the exact Shift+F10 spawn); the list is the single seam to broaden later + /// (e.g. powershell.exe) without touching the watcher logic. + /// + internal static readonly string[] WatchedProcessNames = { "cmd.exe" }; + + internal const string ParentProcessName = "winlogon.exe"; + private const string WinlogonNoExt = "winlogon"; + + private readonly AgentLogger _logger; + private readonly Func _parentNameResolver; + private readonly object _lock = new object(); + private readonly HashSet _seen = new HashSet(); + + private ManagementEventWatcher? _startWatcher; + private bool _started; + private bool _disposed; + + /// + /// Raised once per process when a watched console spawns with winlogon.exe as its + /// parent. The host translates this into a Warning oobe_console_spawned event. + /// + public event EventHandler? BypassConsoleDetected; + + public ConsoleBypassWatcher(AgentLogger logger, Func? parentNameResolver = null) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _parentNameResolver = parentNameResolver ?? DefaultResolveParentName; + } + + public void Start() + { + lock (_lock) + { + if (_started || _disposed) return; + _started = true; + } + + // 1) Startup probe — a console already open before the watcher armed (Shift+F10 pressed + // while the agent was installing). Win32_Process exposes the parent PID + session + // directly, so the same classification path is reused. + try + { + using var searcher = new ManagementObjectSearcher( + "SELECT ProcessId, ParentProcessId, SessionId FROM Win32_Process WHERE Name = 'cmd.exe'"); + using var results = searcher.Get(); + foreach (var mo in results) + { + using (mo) + { + HandleStart( + ToInt(mo["ProcessId"]), + ToInt(mo["ParentProcessId"]), + ToInt(mo["SessionId"]), + "startup_probe"); + } + } + } + catch (Exception ex) + { + _logger.Warning($"[ConsoleBypassWatcher] startup probe failed: {ex.Message}"); + } + + // 2) ETW-backed push on console creation — no polling. + try + { + var query = new WqlEventQuery( + "SELECT * FROM Win32_ProcessStartTrace WHERE ProcessName = 'cmd.exe'"); + _startWatcher = new ManagementEventWatcher(query); + _startWatcher.EventArrived += OnProcessStartTrace; + _startWatcher.Start(); + _logger.Info("[ConsoleBypassWatcher] watching Win32_ProcessStartTrace for cmd.exe (parent winlogon.exe)"); + } + catch (Exception ex) + { + _logger.Warning($"[ConsoleBypassWatcher] could not start WMI process-start watcher " + + $"({ex.GetType().Name}: {ex.Message}); relying on the startup probe only."); + _startWatcher = null; + } + } + + private void OnProcessStartTrace(object sender, EventArrivedEventArgs e) + { + try + { + var pid = ToInt(e.NewEvent?["ProcessID"]); + var parentPid = ToInt(e.NewEvent?["ParentProcessID"]); + var sessionId = ToInt(e.NewEvent?["SessionID"]); + HandleStart(pid, parentPid, sessionId, "process_start_trace"); + } + catch (Exception ex) + { + _logger.Debug($"[ConsoleBypassWatcher] start-trace handler error: {ex.Message}"); + } + } + + /// + /// Classification core (internal for tests): resolves the parent name and raises + /// exactly once per PID when the parent is winlogon. + /// A non-winlogon parent (ordinary install-launched cmd) is ignored. + /// + internal void HandleStart(int pid, int parentPid, int sessionId, string detectedVia) + { + if (pid <= 0 || parentPid <= 0) return; + + lock (_lock) { if (_disposed) return; } + + string? parentName; + try { parentName = _parentNameResolver(parentPid); } + catch (Exception ex) + { + // Parent already gone / inaccessible — cannot confirm winlogon, so stay conservative + // and do not flag (avoids a false positive). winlogon never exits during enrollment. + // Deliberately NOT marked seen: the PID may be reused later by a real winlogon-parented + // console, which must still be detected. + _logger.Debug($"[ConsoleBypassWatcher] parent resolve failed for pid={pid} parent={parentPid}: {ex.Message}"); + return; + } + + if (string.IsNullOrEmpty(parentName) || + !parentName!.Equals(WinlogonNoExt, StringComparison.OrdinalIgnoreCase)) + { + // Not a Shift+F10 SYSTEM console. Do NOT mark the PID seen — an ordinary cmd that + // exits could see its PID reused by a later genuine winlogon-parented console, which + // must not be suppressed by this non-match. + return; + } + + // Confirmed bypass console — dedup ONLY here, so the startup probe and the live trace + // observing the same console collapse to a single detection, while a non-winlogon start + // with the same PID never pre-empts a real one. + lock (_lock) + { + if (_disposed) return; + if (!_seen.Add(pid)) return; + } + + var info = new ConsoleSpawnInfo( + processName: "cmd.exe", + processId: pid, + parentProcessName: ParentProcessName, + parentProcessId: parentPid, + sessionId: sessionId, + detectedVia: detectedVia); + + _logger.Warning($"[ConsoleBypassWatcher] SYSTEM console detected: cmd.exe (PID={pid}) " + + $"parent winlogon.exe (PID={parentPid}), session={sessionId}, via={detectedVia} — possible Shift+F10"); + + try { BypassConsoleDetected?.Invoke(this, info); } + catch (Exception ex) { _logger.Warning($"[ConsoleBypassWatcher] handler threw: {ex.Message}"); } + } + + private static string? DefaultResolveParentName(int parentPid) + { + using var proc = Process.GetProcessById(parentPid); + return proc.ProcessName; // "winlogon" (no extension) + } + + private static int ToInt(object? value) + { + if (value == null) return 0; + try { return Convert.ToInt32(value); } + catch { return 0; } + } + + public void Dispose() + { + lock (_lock) + { + if (_disposed) return; + _disposed = true; + } + + if (_startWatcher != null) + { + try { _startWatcher.EventArrived -= OnProcessStartTrace; } catch { } + try { _startWatcher.Stop(); } catch { } + try { _startWatcher.Dispose(); } catch { } + _startWatcher = null; + } + } + } + + /// Immutable detail record for a detected bypass console spawn. + public sealed class ConsoleSpawnInfo + { + public ConsoleSpawnInfo(string processName, int processId, string parentProcessName, + int parentProcessId, int sessionId, string detectedVia) + { + ProcessName = processName; + ProcessId = processId; + ParentProcessName = parentProcessName; + ParentProcessId = parentProcessId; + SessionId = sessionId; + DetectedVia = detectedVia; + } + + public string ProcessName { get; } + public int ProcessId { get; } + public string ParentProcessName { get; } + public int ParentProcessId { get; } + public int SessionId { get; } + public string DetectedVia { get; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs index 141c5e8f..b149a4a7 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -201,6 +201,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration logger: logger)); } + // OOBE-console / Shift+F10 detection — opt-OUT per tenant (portal toggle → + // AnalyzerConfiguration.EnableConsoleBypassDetection, default ON). The LIVE half: a WMI + // Win32_ProcessStartTrace watcher that flags a cmd.exe spawned with parent winlogon.exe + // (the SYSTEM-console fingerprint) as a Warning oobe_console_spawned. The STARTUP-FORENSIC + // half (ConsolePrefetchScanner, covering the pre-agent OOBE window) is registered by the + // AgentAnalyzerManager under the same flag. Detection is best-effort, not gapless. + if (analyzers.EnableConsoleBypassDetection) + { + hosts.Add(new ConsoleBypassHost( + sessionId: sessionId, + tenantId: tenantId, + ingress: ingress, + clock: clock, + logger: logger)); + } + // Single-rail refactor (plan §5.8) — DeviceInfoCollector existed in V2.Core but had // no host so the Device-Details UI block was empty in V2 sessions (V1-Parity Issue #2). // Kernel host: always on, not remote-config-gated; fires CollectAll on Start on a diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ConsoleBypassHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ConsoleBypassHost.cs new file mode 100644 index 00000000..9639e0a7 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ConsoleBypassHost.cs @@ -0,0 +1,105 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Lifecycle host for the . Opt-OUT per tenant + /// (AnalyzerConfiguration.EnableConsoleBypassDetection, default ON): created by + /// DefaultComponentFactory unless the flag is explicitly disabled. Owns the watcher and translates each + /// detected SYSTEM-console spawn into a Warning oobe_console_spawned event on the single + /// rail. The event carries the full process signature plus an honest coverageNote / + /// coverageComplete:false — the live watcher only sees consoles spawned after the agent + /// started; the pre-agent OOBE window is covered (coarsely) by the ConsolePrefetchScanner. + /// + internal sealed class ConsoleBypassHost : ICollectorHost + { + private const string CoverageNote = + "best-effort: only consoles spawned after the agent started are observable; a Shift+F10 " + + "pressed earlier in OOBE (before agent install) is covered coarsely by console_prefetch_detected"; + + public string Name => "ConsoleBypassWatcher"; + + private readonly string _sessionId; + private readonly string _tenantId; + private readonly InformationalEventPost _post; + private readonly AgentLogger _logger; + private readonly ConsoleBypassWatcher _watcher; + private int _disposed; + + public ConsoleBypassHost( + string sessionId, + string tenantId, + ISignalIngressSink ingress, + IClock clock, + AgentLogger logger, + ConsoleBypassWatcher? watcher = null) + { + if (ingress == null) throw new ArgumentNullException(nameof(ingress)); + if (clock == null) throw new ArgumentNullException(nameof(clock)); + _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _post = new InformationalEventPost(ingress, clock, logger); + _watcher = watcher ?? new ConsoleBypassWatcher(logger); + _watcher.BypassConsoleDetected += OnBypassConsoleDetected; + } + + public void Start() => _watcher.Start(); + + public void Stop() => Dispose(); + + private void OnBypassConsoleDetected(object? sender, ConsoleSpawnInfo info) + { + try + { + _post.Emit(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = Constants.EventTypes.OobeConsoleSpawned, + Severity = EventSeverity.Warning, + Source = Name, + Phase = EnrollmentPhase.Unknown, + Message = $"SYSTEM console spawned during enrollment: {info.ProcessName} " + + $"(parent {info.ParentProcessName}) — possible Shift+F10", + ImmediateUpload = true, + Data = new Dictionary + { + { "decision", "console_spawn_detected" }, + { "processName", info.ProcessName }, + { "processId", info.ProcessId }, + { "parentProcessName", info.ParentProcessName }, + { "parentProcessId", info.ParentProcessId }, + { "parentMatchesWinlogon", true }, + { "sessionId", info.SessionId }, + { "detectedVia", info.DetectedVia }, + { "coverageNote", CoverageNote }, + { "coverageComplete", false }, + }, + }); + } + catch (Exception ex) + { + _logger.Warning($"[ConsoleBypassHost] failed to emit oobe_console_spawned: {ex.Message}"); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) return; + try { _watcher.BypassConsoleDetected -= OnBypassConsoleDetected; } catch { } + try { _watcher.Dispose(); } catch { } + } + + // Test seam. + internal ConsoleBypassWatcher WatcherForTest => _watcher; + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/AgentAnalyzerManager.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/AgentAnalyzerManager.cs index 22abf322..5a7c1796 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/AgentAnalyzerManager.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/AgentAnalyzerManager.cs @@ -101,6 +101,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime _logger.Info("IntegrityBypassAnalyzer disabled by remote config"); } + // ConsolePrefetchScanner — startup-forensic half of the OOBE-console / Shift+F10 + // detection (same opt-out flag, default ON, as the live ConsoleBypass host). Scans for a CMD.EXE-*.pf + // prefetch artifact that ran after boot, covering the pre-agent OOBE window the live + // watcher cannot see. Restart-deduped via the StartupEventGate. + if (_analyzerConfig.EnableConsoleBypassDetection) + { + _analyzers.Add(new ConsolePrefetchScanner( + _configuration.SessionId, + _configuration.TenantId, + _post, + _logger, + _startupEventGate)); + _logger.Info("ConsolePrefetchScanner registered"); + } + else + { + _logger.Info("ConsolePrefetchScanner disabled by remote config"); + } + // AutoLogon analyzer is ALWAYS registered (no remote-config toggle): it reports raw // Winlogon facts at Info severity and the grading lives in backend analyze-rules. Its // startup run is a no-op; it fires at DeviceSetup-phase completion and at final shutdown. diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index a788b7b3..fdab865f 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -1134 +1144 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 414c7cc0..471d5488 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1453 +1456 diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index 52230a47..f646a9ee 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -387,6 +387,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { "EnableIntegrityBypassAnalyzer", config.EnableIntegrityBypassAnalyzer }, { "EnableRealmJoinWatcher", config.EnableRealmJoinWatcher }, { "KeepAwakeDuringUserEsp", config.KeepAwakeDuringUserEsp }, + { "EnableConsoleBypassDetection", config.EnableConsoleBypassDetection }, { "LocalAdminAllowedAccountsJson", config.LocalAdminAllowedAccountsJson }, { "BootstrapTokenEnabled", config.BootstrapTokenEnabled }, { "UnrestrictedModeEnabled", config.UnrestrictedModeEnabled }, @@ -476,6 +477,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage EnableIntegrityBypassAnalyzer = entity.GetBoolean("EnableIntegrityBypassAnalyzer"), EnableRealmJoinWatcher = entity.GetBoolean("EnableRealmJoinWatcher"), KeepAwakeDuringUserEsp = entity.GetBoolean("KeepAwakeDuringUserEsp"), + EnableConsoleBypassDetection = entity.GetBoolean("EnableConsoleBypassDetection"), LocalAdminAllowedAccountsJson = entity.GetString("LocalAdminAllowedAccountsJson"), BootstrapTokenEnabled = entity.GetBoolean("BootstrapTokenEnabled") ?? false, UnrestrictedModeEnabled = entity.GetBoolean("UnrestrictedModeEnabled") ?? false, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs index 6ace3c77..8654319e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs @@ -194,7 +194,7 @@ namespace AutopilotMonitor.Functions.Functions.Config var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new AgentConfigResponse { - ConfigVersion = 31, // V2 keep-awake-during-User-ESP portal toggle (KeepAwakeDuringUserEsp; tenant-scoped, default off; gates UserEspKeepAwake host) + ConfigVersion = 32, // V2 OOBE-console / Shift+F10 detection portal toggle (EnableConsoleBypassDetection; tenant-scoped, default ON / opt-out; gates ConsoleBypass host + ConsolePrefetchScanner) UploadIntervalSeconds = 10, SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true, KeepLogFile = tenantConfig.KeepLogFile ?? false, @@ -222,7 +222,8 @@ namespace AutopilotMonitor.Functions.Functions.Config EnableSoftwareInventoryAnalyzer = tenantConfig.EnableSoftwareInventoryAnalyzer ?? false, EnableIntegrityBypassAnalyzer = tenantConfig.EnableIntegrityBypassAnalyzer ?? true, EnableRealmJoinWatcher = tenantConfig.EnableRealmJoinWatcher ?? false, - KeepAwakeDuringUserEsp = tenantConfig.KeepAwakeDuringUserEsp ?? false + KeepAwakeDuringUserEsp = tenantConfig.KeepAwakeDuringUserEsp ?? false, + EnableConsoleBypassDetection = tenantConfig.EnableConsoleBypassDetection ?? true }, LatestAgentSha256 = latestAgentSha256, LatestAgentExeSha256 = latestAgentExeSha256, diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts index ae089f11..da69c1af 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts @@ -154,6 +154,8 @@ export const EVENT_TYPES_CATALOG = { 'integrity_bypass_analysis', 'local_admin_analysis', 'autologon_analysis', + 'oobe_console_spawned', + 'console_prefetch_detected', 'security_warning', 'security_audit', 'provisioning_package_scan', diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 462ad407..88f62214 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -449,6 +449,19 @@ namespace AutopilotMonitor.Shared // SEC-003, escalated) and an optional kiosk allow-list template (ANALYZE-SEC-004, off by // default). AutoLogon may be legitimate (kiosk) OR an enrollment/OOBE manipulation vector. public const string AutoLogonAnalysis = "autologon_analysis"; + // OOBE-console / Shift+F10 detection (per-tenant opt-OUT, AnalyzerConfiguration. + // EnableConsoleBypassDetection, default ON). Two complementary Warning signals: + // oobe_console_spawned — LIVE: a cmd.exe spawned with parent winlogon.exe while the + // agent runs (the Shift+F10 SYSTEM-console fingerprint). Precise, + // low false-positive. Misses presses BEFORE the agent installed. + // console_prefetch_detected — STARTUP FORENSIC: a CMD.EXE-*.pf prefetch artifact whose + // last-run is after boot. Covers the pre-agent OOBE window the + // live watcher cannot see, but cannot attribute the run to + // Shift+F10 vs. a legitimate install-launched cmd once ESP runs + // (cmd.exe shares one .pf). Both carry coverageComplete=false + + // an honest coverageNote — detection is best-effort, not gapless. + public const string OobeConsoleSpawned = "oobe_console_spawned"; + public const string ConsolePrefetchDetected = "console_prefetch_detected"; // Termination / diagnostics / server actions public const string EnrollmentSummaryShown = "enrollment_summary_shown"; diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs index e0d7e80f..c5ba7ead 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs @@ -466,5 +466,17 @@ namespace AutopilotMonitor.Shared.Models /// Default: false (opt-in per tenant). /// public bool KeepAwakeDuringUserEsp { get; set; } = false; + + /// + /// Whether to detect a SYSTEM console opened during enrollment — the classic Shift+F10 OOBE + /// bypass (winlogon.exe spawning cmd.exe as SYSTEM). Gates BOTH the live ConsoleBypass host + /// (cmd.exe with parent winlogon.exe) and the startup ConsolePrefetchScanner (CMD.EXE-*.pf + /// ran after boot). Detection is best-effort, not gapless — a press before the agent installs + /// is only coarsely covered by the prefetch artifact. Security-sensitive; not an IAgentAnalyzer + /// toggle in the usual sense (it also gates a collector host). + /// Default: true (opt-out per tenant — a Shift+F10 SYSTEM console during enrollment is a finding + /// worth surfacing by default; tenants that knowingly use Shift+F10 for support can disable it). + /// + public bool EnableConsoleBypassDetection { get; set; } = true; } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index b52f6e8f..39d9d712 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -341,6 +341,14 @@ namespace AutopilotMonitor.Shared.Models /// public bool? KeepAwakeDuringUserEsp { get; set; } = null; + /// + /// Whether to detect a SYSTEM console opened during enrollment (Shift+F10 OOBE bypass) for + /// this tenant's devices. Gates the live ConsoleBypass watcher + the startup prefetch scanner. + /// On by default (opt-out); tenants that knowingly use Shift+F10 for support can disable it. + /// null = use agent default (true). + /// + public bool? EnableConsoleBypassDetection { get; set; } = null; + /// /// JSON-serialized list of additional local account names that are considered expected /// on a newly enrolled device (merged with built-in defaults on the agent). diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts index 34314baa..4557428e 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts +++ b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts @@ -132,6 +132,10 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [ description: "Output of another gather rule (can be chained)." }, { value: "provisioning_package_scan", label: "provisioning_package_scan", category: "diagnostics", description: "Single per-device provisioning-package (PPKG) scan at DeviceSetup-phase start. Carries an artifacts[] array — one entry per registry package, .ppkg file, or Recovery\\Customizations residue (each with a scalar identity) — plus .ppkg file metadata, registry package metadata, and best-effort content indicators. The PPKG analyze rules (ANALYZE-SEC-005/006) iterate the array against an allow-list regex. PPKGs can be legitimate bulk enrollment or a manipulation vector." }, + { value: "oobe_console_spawned", label: "oobe_console_spawned", category: "diagnostics", + description: "LIVE detection of a SYSTEM console opened during enrollment — the Shift+F10 OOBE bypass (cmd.exe spawned with parent winlogon.exe). Warning severity. On by default, opt-out per tenant (EnableConsoleBypassDetection). Best-effort: only consoles spawned after the agent started are observable (coverageComplete=false)." }, + { value: "console_prefetch_detected", label: "console_prefetch_detected", category: "diagnostics", + description: "STARTUP-FORENSIC complement to oobe_console_spawned: a CMD.EXE-*.pf prefetch artifact whose last-run is after boot, covering the pre-agent OOBE window. Warning severity. Attribution is coarse — cmd.exe shares one prefetch file, so once ESP runs the timestamp cannot be attributed to Shift+F10 vs. a legitimate install-launched cmd (coverageComplete=false)." }, ]; /** Lookup a single entry by its canonical value (case-insensitive). */ diff --git a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx index 34dd6091..fdf0b6a9 100644 --- a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx @@ -132,13 +132,15 @@ interface TenantConfigContextValue { setEnableRealmJoinWatcher: (v: boolean) => void; keepAwakeDuringUserEsp: boolean; setKeepAwakeDuringUserEsp: (v: boolean) => void; + enableConsoleBypassDetection: boolean; + setEnableConsoleBypassDetection: (v: boolean) => void; handleSaveAgentAnalyzers: () => void; handleResetAgentAnalyzers: () => void; // Unrestricted mode unrestrictedMode: boolean; setUnrestrictedMode: (v: boolean) => void; - handleSaveUnrestrictedMode: () => void; + handleSaveUnrestrictedMode: (value: boolean) => Promise; // Notifications / Webhook webhookProviderType: number; @@ -365,6 +367,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } const [enableIntegrityBypassAnalyzer, setEnableIntegrityBypassAnalyzer] = useState(true); const [enableRealmJoinWatcher, setEnableRealmJoinWatcher] = useState(false); const [keepAwakeDuringUserEsp, setKeepAwakeDuringUserEsp] = useState(false); + const [enableConsoleBypassDetection, setEnableConsoleBypassDetection] = useState(true); // Unrestricted mode const [unrestrictedMode, setUnrestrictedMode] = useState(false); @@ -478,6 +481,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } setEnableIntegrityBypassAnalyzer(data.enableIntegrityBypassAnalyzer ?? true); setEnableRealmJoinWatcher(data.enableRealmJoinWatcher ?? false); setKeepAwakeDuringUserEsp(data.keepAwakeDuringUserEsp ?? false); + setEnableConsoleBypassDetection(data.enableConsoleBypassDetection ?? true); setUnrestrictedMode(data.unrestrictedMode ?? false); // Parse SAS expiry and fire notification to bell if needed @@ -601,7 +605,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } // ----------------------------------------------------------------------- // Save configuration (shared by all sections) // ----------------------------------------------------------------------- - const saveConfiguration = useCallback(async (sectionName: string, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean }): Promise => { + const saveConfiguration = useCallback(async (sectionName: string, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; unrestrictedMode?: boolean }): Promise => { if (!tenantId || !config) return false; try { @@ -612,6 +616,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } const autopilotDeviceValidationValue = overrides?.validateAutopilotDevice ?? validateAutopilotDevice; const corporateIdentifierValidationValue = overrides?.validateCorporateIdentifier ?? validateCorporateIdentifier; const deviceAssociationValidationValue = overrides?.validateDeviceAssociation ?? validateDeviceAssociation; + const unrestrictedModeValue = overrides?.unrestrictedMode ?? unrestrictedMode; const updatedConfig: TenantConfiguration = { ...config, @@ -670,7 +675,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } enableIntegrityBypassAnalyzer, enableRealmJoinWatcher, keepAwakeDuringUserEsp, - unrestrictedMode, + enableConsoleBypassDetection, + unrestrictedMode: unrestrictedModeValue, }; const response = await authenticatedFetch(api.config.tenant(tenantId), getAccessToken, { @@ -720,7 +726,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } slaNotifyOnAppInstallBreach, slaNotifyOnConsecutiveFailures, slaConsecutiveFailureThreshold, diagnosticsBlobSasUrl, diagnosticsUploadMode, diagnosticsUploadDestination, tenantDiagPaths, enableLocalAdminAnalyzer, localAdminAllowedAccounts, enableSoftwareInventoryAnalyzer, - enableIntegrityBypassAnalyzer, enableRealmJoinWatcher, keepAwakeDuringUserEsp, unrestrictedMode, + enableIntegrityBypassAnalyzer, enableRealmJoinWatcher, keepAwakeDuringUserEsp, + enableConsoleBypassDetection, unrestrictedMode, ]); // ----------------------------------------------------------------------- @@ -1045,6 +1052,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } setEnableIntegrityBypassAnalyzer(config.enableIntegrityBypassAnalyzer ?? true); setEnableRealmJoinWatcher(config.enableRealmJoinWatcher ?? false); setKeepAwakeDuringUserEsp(config.keepAwakeDuringUserEsp ?? false); + setEnableConsoleBypassDetection(config.enableConsoleBypassDetection ?? true); }, [config]); const handleSaveNotifications = useCallback(() => saveConfiguration("notifications"), [saveConfiguration]); @@ -1103,7 +1111,14 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } setSessionTimeoutHours(config.sessionTimeoutHours ?? 5); }, [config]); - const handleSaveUnrestrictedMode = useCallback(() => saveConfiguration("unrestrictedMode"), [saveConfiguration]); + // Persist the toggle with the new value passed explicitly. The on/off value MUST be threaded + // through as an override rather than read from `unrestrictedMode` state: the toggle calls this + // synchronously after setUnrestrictedMode(...), so the closed-over state is still stale at the + // time the PUT body is built (matches the handleToggleDeviceAssociationValidation pattern). + const handleSaveUnrestrictedMode = useCallback( + (value: boolean) => saveConfiguration("unrestrictedMode", { unrestrictedMode: value }), + [saveConfiguration], + ); /** * Toggle the DevPrep "Device association" shadow validation. No consent flow needed — @@ -1428,6 +1443,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } enableIntegrityBypassAnalyzer, setEnableIntegrityBypassAnalyzer, enableRealmJoinWatcher, setEnableRealmJoinWatcher, keepAwakeDuringUserEsp, setKeepAwakeDuringUserEsp, + enableConsoleBypassDetection, setEnableConsoleBypassDetection, handleSaveAgentAnalyzers, handleResetAgentAnalyzers, // Unrestricted mode diff --git a/src/Web/autopilot-monitor-web/app/settings/agent/sections/SectionAgentAnalyzers.tsx b/src/Web/autopilot-monitor-web/app/settings/agent/sections/SectionAgentAnalyzers.tsx index d2d5bb01..04af65e1 100644 --- a/src/Web/autopilot-monitor-web/app/settings/agent/sections/SectionAgentAnalyzers.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/agent/sections/SectionAgentAnalyzers.tsx @@ -13,6 +13,7 @@ export function SectionAgentAnalyzers() { enableIntegrityBypassAnalyzer, setEnableIntegrityBypassAnalyzer, enableRealmJoinWatcher, setEnableRealmJoinWatcher, keepAwakeDuringUserEsp, setKeepAwakeDuringUserEsp, + enableConsoleBypassDetection, setEnableConsoleBypassDetection, handleSaveAgentAnalyzers, handleResetAgentAnalyzers, savingSection, } = useTenantConfig(); @@ -35,6 +36,8 @@ export function SectionAgentAnalyzers() { setEnableRealmJoinWatcher={setEnableRealmJoinWatcher} keepAwakeDuringUserEsp={keepAwakeDuringUserEsp} setKeepAwakeDuringUserEsp={setKeepAwakeDuringUserEsp} + enableConsoleBypassDetection={enableConsoleBypassDetection} + setEnableConsoleBypassDetection={setEnableConsoleBypassDetection} onSave={handleSaveAgentAnalyzers} onReset={handleResetAgentAnalyzers} saving={savingSection === "agentAnalyzers"} diff --git a/src/Web/autopilot-monitor-web/app/settings/components/AgentAnalyzersSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/AgentAnalyzersSection.tsx index c8f630f8..92ef45aa 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/AgentAnalyzersSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/AgentAnalyzersSection.tsx @@ -17,6 +17,8 @@ interface AgentAnalyzersSectionProps { setEnableRealmJoinWatcher: (value: boolean) => void; keepAwakeDuringUserEsp: boolean; setKeepAwakeDuringUserEsp: (value: boolean) => void; + enableConsoleBypassDetection: boolean; + setEnableConsoleBypassDetection: (value: boolean) => void; onSave: () => Promise | void; onReset: () => void; saving: boolean; @@ -51,6 +53,8 @@ export default function AgentAnalyzersSection({ setEnableRealmJoinWatcher, keepAwakeDuringUserEsp, setKeepAwakeDuringUserEsp, + enableConsoleBypassDetection, + setEnableConsoleBypassDetection, onSave, onReset, saving, @@ -287,6 +291,38 @@ export default function AgentAnalyzersSection({ + {/* ── Divider ─────────────────────────────────────── */} +
+ + {/* ── OOBE Console / Shift+F10 Detection ───────────── */} +
+

Detect OOBE Console Access (Shift+F10)

+

+ Flags a SYSTEM command prompt opened during enrollment — the classic Shift+F10 OOBE bypass + (winlogon.exe spawning cmd.exe as SYSTEM). Emits a Warning event with the full process + signature. A startup scan of the cmd.exe prefetch artifact additionally covers a console + opened before the agent installed. +

+

+ Best-effort, not gapless: a Shift+F10 pressed very early in OOBE may only be detected + coarsely (via the prefetch artifact), and detection cannot block the console — it only reports it. +

+ + {/* Enable toggle */} +
+
+

Enable OOBE Console Detection

+

On by default. Disable only if your support staff knowingly use Shift+F10 during enrollment

+
+ +
+
+ {/* Save / Reset */}
diff --git a/src/Web/autopilot-monitor-web/app/settings/components/UnrestrictedModeSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/UnrestrictedModeSection.tsx index 0f262f04..71c37a3a 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/UnrestrictedModeSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/UnrestrictedModeSection.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; interface UnrestrictedModeSectionProps { unrestrictedMode: boolean; setUnrestrictedMode: (value: boolean) => void; - onSave: () => Promise | void; + onSave: (value: boolean) => Promise | void; saving: boolean; } @@ -22,13 +22,13 @@ export default function UnrestrictedModeSection({ // Turning OFF — no confirmation needed setUnrestrictedMode(false); setAcknowledged(false); - // Auto-save after state update - setTimeout(() => onSave(), 0); + // Pass the new value explicitly — the React state update above is async, so onSave() + // would otherwise persist the stale (pre-toggle) value. + onSave(false); } else if (acknowledged) { // Turning ON with acknowledgment setUnrestrictedMode(true); - // Auto-save after state update - setTimeout(() => onSave(), 0); + onSave(true); } }; diff --git a/src/Web/autopilot-monitor-web/app/settings/types.ts b/src/Web/autopilot-monitor-web/app/settings/types.ts index 94684843..917c7f58 100644 --- a/src/Web/autopilot-monitor-web/app/settings/types.ts +++ b/src/Web/autopilot-monitor-web/app/settings/types.ts @@ -75,6 +75,7 @@ export interface TenantConfiguration { enableIntegrityBypassAnalyzer?: boolean; enableRealmJoinWatcher?: boolean; keepAwakeDuringUserEsp?: boolean; + enableConsoleBypassDetection?: boolean; // Bootstrap token bootstrapTokenEnabled?: boolean; // Unrestricted mode