mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
+165
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -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
-1
@@ -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();
|
||||
|
||||
+236
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+237
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user