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 index 1d5b201c..9bf05d57 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Security/ConsoleBypassWatcherTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Security/ConsoleBypassWatcherTests.cs @@ -143,14 +143,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security [InlineData(@"""C:\WINDOWS\system32\cmd.exe""", false)] [InlineData(@"cmd.exe /d", false)] // /d skips AutoRun — still interactive [InlineData(@"C:\cmd\cmd.exe", false)] // folder named 'cmd', not a switch + [InlineData(@"""C:/k/cmd.exe""", false)] // forward-slash path stripped before scan [InlineData("", false)] [InlineData(@"cmd.exe /c whoami", true)] [InlineData(@"cmd.exe /C whoami", true)] [InlineData(@"cmd.exe /k", true)] + [InlineData(@"cmd.exe /d/q/c exit 9", true)] // combined switches — the field case + [InlineData(@"cmd.exe /D/Q/K stay", true)] [InlineData(@"""C:\WINDOWS\system32\cmd.exe"" /c ""x.bat""", true)] public void HasScriptArgument_detects_run_command_switch(string commandLine, bool expected) { Assert.Equal(expected, ConsoleBypassWatcher.HasScriptArgument(commandLine)); } + + [Theory] + [InlineData(@"C:\WINDOWS\system32\cmd.exe", "")] + [InlineData(@"cmd.exe /d/q/c exit 9", "/d/q/c exit 9")] + [InlineData(@"""C:\Program Files\x\cmd.exe"" /c y", @" /c y")] + public void StripExecutable_removes_leading_exe_token(string commandLine, string expected) + { + Assert.Equal(expected, ConsoleBypassWatcher.StripExecutable(commandLine)); + } } } 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 index c8b36750..097139fc 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/ConsolePrefetchScanner.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/ConsolePrefetchScanner.cs @@ -41,7 +41,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers "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"; + "complements the live console watcher by covering the pre-agent OOBE window"; private readonly string _sessionId; private readonly string _tenantId; 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 index 9167d35d..de4d02a6 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Security/ConsoleBypassWatcher.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Security/ConsoleBypassWatcher.cs @@ -47,9 +47,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security internal static readonly string[] WatchedProcessNames = { "cmd.exe" }; // cmd switches that turn the shell into a one-shot scripted invocation (install/script), as - // opposed to a bare interactive console. Matched as a whitespace-delimited /c or /k token. + // opposed to a bare interactive console. Matched as a /c or /k token anywhere in the ARGUMENTS + // (the executable path is stripped first) so combined switches like /d/q/c are caught too. private static readonly Regex ScriptSwitchRegex = - new Regex(@"(?:^|\s)/[ck]\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); + new Regex(@"/[ck]\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); private readonly AgentLogger _logger; private readonly Func _processProbe; @@ -67,6 +68,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security /// public event EventHandler? BypassConsoleDetected; + /// + /// Raised when the WMI Win32_ProcessStartTrace watcher fails to arm — the LIVE detector + /// is dead and only the one-shot startup probe ran. The host surfaces this as a + /// collector_degraded event so the backend can tell "no console seen" from "detector + /// never started". + /// + public event EventHandler? WatcherArmFailed; + /// Resolves the command line + owner for a live PID; returns null /// when the process has already exited (the instant-close race). Defaults to a WMI query. public ConsoleBypassWatcher(AgentLogger logger, Func? processProbe = null) @@ -83,8 +92,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security _started = true; } - // 1) Startup probe — a console already open before the watcher armed. Win32_Process exposes - // SessionId directly, so the same classification path is reused. + // 1) ETW-backed push on console creation — armed FIRST so a console spawned during the + // startup snapshot below is still caught by the live trace. The _seen set dedups the + // overlap (a console that starts mid-arming is seen by both). Arming after the snapshot + // would leave a blind gap for a fast cmd that starts between snapshot and arm. + 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 (interactive-session + bare command line)"); + } + 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; + // Surface the dead live detector so the backend can distinguish it from a clean run. + var handler = WatcherArmFailed; + try { handler?.Invoke(this, ex); } catch { /* never throw from Start */ } + } + + // 2) Startup probe — catch a console already open before the watcher armed. Win32_Process + // exposes SessionId directly, so the same classification path is reused. Runs AFTER the + // arm so there is no gap; overlap with the live trace is deduped by _seen. try { using var searcher = new ManagementObjectSearcher( @@ -107,23 +140,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security { _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 (interactive-session + bare command line)"); - } - 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) @@ -205,9 +221,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security } /// True when the command line carries a /c or /k run-command switch - /// (a scripted invocation), false for a bare interactive shell. + /// (a scripted invocation), false for a bare interactive shell. The executable token is + /// stripped first so a path can never spoof a switch, and switches may be combined + /// (e.g. cmd /d/q/c exit 9). internal static bool HasScriptArgument(string commandLine) - => !string.IsNullOrEmpty(commandLine) && ScriptSwitchRegex.IsMatch(commandLine); + { + if (string.IsNullOrEmpty(commandLine)) return false; + return ScriptSwitchRegex.IsMatch(StripExecutable(commandLine)); + } + + /// Returns the command line with the leading executable token (quoted or + /// whitespace-delimited) removed, so switch detection only inspects the arguments. + internal static string StripExecutable(string commandLine) + { + var s = commandLine.TrimStart(); + if (s.Length == 0) return string.Empty; + if (s[0] == '"') + { + int end = s.IndexOf('"', 1); + return end >= 0 ? s.Substring(end + 1) : string.Empty; + } + int space = s.IndexOf(' '); + return space >= 0 ? s.Substring(space + 1) : string.Empty; + } private static ProcessProbe? DefaultProbe(int pid) { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ConsoleBypassHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ConsoleBypassHost.cs index ef1aed70..0eaea018 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ConsoleBypassHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ConsoleBypassHost.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Threading; using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security; using AutopilotMonitor.DecisionCore.Engine; using AutopilotMonitor.Shared; @@ -52,6 +53,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration _post = new InformationalEventPost(ingress, clock, logger); _watcher = watcher ?? new ConsoleBypassWatcher(logger); _watcher.BypassConsoleDetected += OnBypassConsoleDetected; + _watcher.WatcherArmFailed += OnWatcherArmFailed; } public void Start() => _watcher.Start(); @@ -108,10 +110,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration } } + // The WMI live watcher could not arm — surface it as collector_degraded so the backend can + // tell "no console detected" from "live detector never started" (MON-D1 pattern). + private void OnWatcherArmFailed(object? sender, Exception ex) + { + CollectorDegradationReporter.Report(_post, _sessionId, _tenantId, Name, "watcher_arm_failed", ex); + } + public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) == 1) return; try { _watcher.BypassConsoleDetected -= OnBypassConsoleDetected; } catch { } + try { _watcher.WatcherArmFailed -= OnWatcherArmFailed; } catch { } try { _watcher.Dispose(); } catch { } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 765f5bf7..17edc7ae 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -1150 +1155 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 107359ca..2794bbe0 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1457 +1458 diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs index c5ba7ead..d8cd0129 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs @@ -468,14 +468,14 @@ namespace AutopilotMonitor.Shared.Models 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). + /// Whether to detect an interactive console opened during enrollment — the classic Shift+F10 OOBE + /// bypass. Gates BOTH the live ConsoleBypass host (an interactive-session cmd.exe with a bare, + /// non-scripted command line; stopped on real-user desktop arrival) 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 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/Web/autopilot-monitor-web/app/settings/components/AgentAnalyzersSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/AgentAnalyzersSection.tsx index 92ef45aa..178b4105 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/AgentAnalyzersSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/AgentAnalyzersSection.tsx @@ -298,10 +298,11 @@ export default function AgentAnalyzersSection({

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. + Flags an interactive command prompt opened during enrollment — the classic Shift+F10 OOBE + bypass. Detects an interactive-session cmd.exe with a bare (non-scripted) command line and + emits a Warning event with the process signature; it stops once the real-user desktop arrives + (Shift+F10 no longer possible). 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