feat(agent+backend+web): OOBE-console / Shift+F10 detection (default-on opt-out)

Detects a SYSTEM console opened during enrollment — the classic Shift+F10
OOBE bypass (winlogon.exe spawning cmd.exe as SYSTEM). Two complementary
Warning signals, one per-tenant toggle EnableConsoleBypassDetection
(default ON / opt-out), ConfigVersion 31 -> 32:

- oobe_console_spawned (live): ConsoleBypassWatcher + ConsoleBypassHost.
  WMI Win32_ProcessStartTrace on cmd.exe + startup probe; flags only a
  cmd.exe whose parent is winlogon.exe. PID dedup happens ONLY after a
  confirmed winlogon match, so an ignored cmd cannot suppress a later real
  one via PID reuse.
- console_prefetch_detected (startup forensic): ConsolePrefetchScanner
  (IAgentAnalyzer). A CMD.EXE-*.pf prefetch artifact whose last-run is
  after boot, covering the pre-agent OOBE window the live watcher misses.
  stat-only; corroborating artifacts sorted for a stable gate fingerprint.

Both events carry coverageComplete:false + an honest coverageNote (and the
prefetch event attribution:"coarse") — detection is best-effort, not gapless,
and only reports, never blocks.

Plumbing: Constants (2 event types) + MCP resource-catalog.ts + Web
eventTypes.ts; AnalyzerConfiguration/TenantConfiguration + GetAgentConfig
(default true) + TableConfigRepository; DefaultComponentFactory host gate +
AgentAnalyzerManager registration; full Web opt-out toggle. Tests: watcher
classification (9) + prefetch scanner (7) + analyzer-manager on/off.

Also fix(web): unrestricted-mode toggle persisted the stale pre-toggle value
because onSave() read async React state. saveConfiguration now takes an
explicit unrestrictedMode override that the toggle passes through, so the
saved value matches the UI state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-19 11:29:41 +02:00
parent b71df9fc18
commit 2e3c0b8db6
22 changed files with 1052 additions and 15 deletions
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<IReadOnlyDictionary<string, object>>(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<IReadOnlyDictionary<string, object>>(rig.PrefetchEvent()!.TypedPayload!);
var corroborating = Assert.IsAssignableFrom<IEnumerable<string>>(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());
}
}
}
@@ -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
{
/// <summary>
/// ConsoleBypassWatcher classification core — driven via the internal <c>HandleStart</c> 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.
/// </summary>
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<ConsoleSpawnInfo> Detected { get; } = new List<ConsoleSpawnInfo>();
public ConsoleBypassWatcher Sut { get; }
public Rig(Func<int, string?> 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<int, string?> { [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);
}
}
}
@@ -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();
@@ -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
{
/// <summary>
/// Startup forensic complement to the live <see cref="Security.ConsoleBypassWatcher"/>. The live
/// watcher only sees consoles spawned after the agent started; a <b>Shift+F10</b> pressed earlier
/// in OOBE (before the agent installed) is its blind spot. Windows writes a Prefetch artifact
/// (<c>%WINDIR%\Prefetch\CMD.EXE-*.pf</c>) when cmd.exe runs — empirically confirmed to fire for a
/// Shift+F10 console in OOBE — so a cmd prefetch file whose last-run is <i>after this boot</i> is a
/// coarse "a console executed on this device" signal that covers the pre-agent window.
/// <para>
/// <b>Honest limits</b> (encoded in the event payload):
/// <list type="bullet">
/// <item>cmd.exe shares ONE prefetch file regardless of launcher. Once ESP runs, Intune Win32
/// apps legitimately spawn <c>cmd.exe /c ...</c>, so the last-run timestamp can no longer be
/// attributed to Shift+F10 vs. an install — hence <c>attribution: "coarse"</c>.</item>
/// <item>v1 reads only the file's NTFS last-write time (stat-only); it does NOT parse the
/// MAM-compressed run-history inside the .pf.</item>
/// <item>Prefetch may be disabled on some images → no artifact, no signal (fail-soft).</item>
/// </list>
/// 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.
/// </para>
/// </summary>
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<DateTime?> _bootTimeProvider;
public string Name => "ConsolePrefetchScanner";
public ConsolePrefetchScanner(
string sessionId,
string tenantId,
InformationalEventPost post,
AgentLogger logger,
Core.Persistence.StartupEventGate? startupGate = null,
string? prefetchDirectory = null,
Func<DateTime?>? 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<string, object>
{
{ "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,
});
}
/// <summary>Newest (max last-write) artifact matching <paramref name="pattern"/>, or null.</summary>
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<string> ArtifactsRanAfter(string pattern, DateTime thresholdUtc)
{
var hits = new List<string>();
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<string> 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<string>();
}
}
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;
}
}
}
@@ -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
{
/// <summary>
/// Live watcher for a SYSTEM console opened during enrollment — the classic <b>Shift+F10</b>
/// OOBE bypass, where <c>winlogon.exe</c> spawns a <c>cmd.exe</c> running as SYSTEM. The
/// discriminator is not the keystroke (un-hookable across sessions from session 0) but the
/// <b>process signature</b>: a watched console process whose <i>parent</i> is <c>winlogon.exe</c>.
/// 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.
/// <para>
/// Mirrors <see cref="Office.OfficeProcessWatcher"/>: a true ETW-backed push via WMI
/// <c>Win32_ProcessStartTrace</c> (no idle polling) plus a one-shot startup probe over
/// <c>Win32_Process</c> 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.
/// </para>
/// <para>
/// <b>Coverage</b> 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 <see cref="Analyzers.ConsolePrefetchScanner"/>. The emitted event documents this gap.
/// </para>
/// </summary>
public sealed class ConsoleBypassWatcher : IDisposable
{
/// <summary>
/// Console executables treated as a bypass console when parented to winlogon. v1 watches only
/// <c>cmd.exe</c> (the exact Shift+F10 spawn); the list is the single seam to broaden later
/// (e.g. <c>powershell.exe</c>) without touching the watcher logic.
/// </summary>
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<int, string?> _parentNameResolver;
private readonly object _lock = new object();
private readonly HashSet<int> _seen = new HashSet<int>();
private ManagementEventWatcher? _startWatcher;
private bool _started;
private bool _disposed;
/// <summary>
/// Raised once per process when a watched console spawns with <c>winlogon.exe</c> as its
/// parent. The host translates this into a Warning <c>oobe_console_spawned</c> event.
/// </summary>
public event EventHandler<ConsoleSpawnInfo>? BypassConsoleDetected;
public ConsoleBypassWatcher(AgentLogger logger, Func<int, string?>? 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}");
}
}
/// <summary>
/// Classification core (internal for tests): resolves the parent name and raises
/// <see cref="BypassConsoleDetected"/> exactly once per PID when the parent is winlogon.
/// A non-winlogon parent (ordinary install-launched cmd) is ignored.
/// </summary>
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;
}
}
}
/// <summary>Immutable detail record for a detected bypass console spawn.</summary>
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; }
}
}
@@ -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
@@ -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
{
/// <summary>
/// Lifecycle host for the <see cref="ConsoleBypassWatcher"/>. Opt-OUT per tenant
/// (AnalyzerConfiguration.EnableConsoleBypassDetection, default ON): created by
/// <c>DefaultComponentFactory</c> unless the flag is explicitly disabled. Owns the watcher and translates each
/// detected SYSTEM-console spawn into a Warning <c>oobe_console_spawned</c> event on the single
/// rail. The event carries the full process signature plus an honest <c>coverageNote</c> /
/// <c>coverageComplete:false</c> — the live watcher only sees consoles spawned after the agent
/// started; the pre-agent OOBE window is covered (coarsely) by the ConsolePrefetchScanner.
/// </summary>
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<string, object>
{
{ "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;
}
}
@@ -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.
@@ -1 +1 @@
1134
1144
@@ -1 +1 @@
1453
1456
@@ -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,
@@ -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,
@@ -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',
@@ -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";
@@ -466,5 +466,17 @@ namespace AutopilotMonitor.Shared.Models
/// Default: false (opt-in per tenant).
/// </summary>
public bool KeepAwakeDuringUserEsp { get; set; } = false;
/// <summary>
/// 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).
/// </summary>
public bool EnableConsoleBypassDetection { get; set; } = true;
}
}
@@ -341,6 +341,14 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public bool? KeepAwakeDuringUserEsp { get; set; } = null;
/// <summary>
/// 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).
/// </summary>
public bool? EnableConsoleBypassDetection { get; set; } = null;
/// <summary>
/// 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).
@@ -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). */
@@ -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<boolean>;
// 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<boolean> => {
const saveConfiguration = useCallback(async (sectionName: string, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; unrestrictedMode?: boolean }): Promise<boolean> => {
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
@@ -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"}
@@ -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> | 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({
</div>
</div>
{/* ── Divider ─────────────────────────────────────── */}
<div className="border-t border-gray-200" />
{/* ── OOBE Console / Shift+F10 Detection ───────────── */}
<div>
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-3">Detect OOBE Console Access (Shift+F10)</h3>
<p className="text-sm text-gray-500 mb-4">
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.
</p>
<p className="text-sm text-amber-600 mb-4">
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.
</p>
{/* Enable toggle */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-rose-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Enable OOBE Console Detection</p>
<p className="text-sm text-gray-500">On by default. Disable only if your support staff knowingly use Shift+F10 during enrollment</p>
</div>
<button
onClick={() => setEnableConsoleBypassDetection(!enableConsoleBypassDetection)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableConsoleBypassDetection ? 'bg-rose-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableConsoleBypassDetection ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
</div>
{/* Save / Reset */}
<SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />
</div>
@@ -5,7 +5,7 @@ import { useState } from "react";
interface UnrestrictedModeSectionProps {
unrestrictedMode: boolean;
setUnrestrictedMode: (value: boolean) => void;
onSave: () => Promise<void> | void;
onSave: (value: boolean) => Promise<unknown> | 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);
}
};
@@ -75,6 +75,7 @@ export interface TenantConfiguration {
enableIntegrityBypassAnalyzer?: boolean;
enableRealmJoinWatcher?: boolean;
keepAwakeDuringUserEsp?: boolean;
enableConsoleBypassDetection?: boolean;
// Bootstrap token
bootstrapTokenEnabled?: boolean;
// Unrestricted mode