Compare commits

...

7 Commits

Author SHA1 Message Date
Oliver Kieselbach a1433141ce bump pointer: internal (D-435 follow-up: counting needs certainty, plan step B1 taken back)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 19:19:11 +01:00
Oliver Kieselbach a7e76f0584 Console detection: a running process that cannot be read is reported, and every console warning is uploaded at once
The probe answered "gone" whenever it had no row, also when the query
itself failed. A console that still ran was then counted as script
activity instead of being reported. A start is now counted only on
certainty: the process list no longer has the id, it has another image
under it, or a fact that was read contradicts the start trace. A failed
or empty query, or a fact that could not be read, is reported at low
confidence, as before the counting was introduced.

The immediate upload is back for every oobe_console_spawned. The
confidence follows the command line, which whoever opens the console
chooses, so it labels the warning and never delays it. With short-lived
starts counted there is no burst left that the limit would have spared.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 19:18:38 +01:00
Oliver Kieselbach 3829b6ce92 bump pointer: internal (D-435 console detection: decision, plan)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 18:57:02 +01:00
Oliver Kieselbach dabd0d5b7e Console detection: short-lived cmd.exe are counted per window instead of reported one by one
The watcher learns of a process start about a second late, because WMI
delivers the start trace in batches. A scripted cmd /c has exited by
then, and its unreadable command line was reported as a low-confidence
console. A script that starts many of them produced one Warning with an
immediate upload each.

A start whose process is gone, or whose id now belongs to another process
(other image name, session 0, other parent), is now counted. The host
reports the count as one Info console_script_activity per 60 s window,
with the owner SIDs and the parent processes by image name, at most 20
windows per agent run and the rest when it stops. The type does not
count as activity, neither for the agent's idle and stall clocks nor for
the backend's stall heal.

oobe_console_spawned stays for a console that is still running when
probed. It carries parentProcessName and is uploaded immediately only at
high confidence.

Fixed in the same code: a reused process id could be reported with the
command line of another program; the owner of a console found at agent
start was never read, because the query lacked the key property; a later
console that was given an id seen before was dropped; the WMI
subscription was cancelled on the stop thread.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 18:56:29 +01:00
Oliver Kieselbach 68dab1e154 Office watcher: a traced process id is tracked only while it still carries the worker's name
WMI delivers the process start trace with a delay. By then the id can
belong to another process. The watcher took whatever ran under the id as
an Office worker, and a foreign process held the install window open
until it exited.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 18:56:16 +01:00
Oliver Kieselbach a1590579f9 Agent: the image path of another process comes from one shared helper
ImeProcessIdentity carried a private wrapper around
QueryFullProcessImageName. It moves unchanged to
Monitoring/Interop/ProcessImagePath, so a second caller can read a
process's image name without a copy of the P/Invoke.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 18:56:15 +01:00
Oliver Kieselbach a558ca5510 bump pointer: internal, .claude/commands (D-434 released: backend 1.5.1330, agent 2.0.1503, web and MCP 1.6.596 on e01e7ab)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:39:32 +01:00
22 changed files with 1627 additions and 214 deletions
@@ -148,5 +148,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
"the window never closed: the worker exited before the watcher subscribed to Exited");
Assert.False(watcher.IsActive);
}
[Fact]
public void A_traced_id_that_belongs_to_another_program_is_not_tracked()
{
// The start trace arrives late: by then the id can belong to an unrelated process.
using var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
watcher.WorkerSource = () => Array.Empty<Process>();
watcher.Start(armWmi: false);
var child = Spawn();
watcher.TrackTracedProcess(child.Id, "OfficeC2RClient");
Assert.False(watcher.IsActive);
}
[Fact]
public void A_traced_id_that_still_carries_the_expected_name_is_tracked()
{
using var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
watcher.WorkerSource = () => Array.Empty<Process>();
watcher.Start(armWmi: false);
var child = Spawn();
watcher.TrackTracedProcess(child.Id, "cmd");
Assert.True(watcher.IsActive);
}
}
}
@@ -1,6 +1,7 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
@@ -10,14 +11,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
{
/// <summary>
/// ConsoleBypassWatcher classification — driven via the internal <c>HandleStart</c>/<c>Classify</c>
/// with an injected process probe (no real WMI / processes). Asserts the parent-free discriminator:
/// an interactive-session cmd with a bare command line is flagged (high confidence); a scripted
/// <c>cmd /c</c> and a session-0 (service) cmd are ignored; an interactive-session cmd whose command
/// line cannot be read (instant-close race) is still surfaced at low confidence.
/// with injected probes (no real WMI / processes). A console that is still running when probed is
/// reported: bare command line (high confidence), <c>/k</c> or unreadable (low). A scripted
/// <c>cmd /c</c> and a session-0 cmd are ignored. A start whose process is gone, or whose id now
/// belongs to another process, is counted as script activity and never reported as a console.
/// </summary>
public sealed class ConsoleBypassWatcherTests
{
private const string Cmd = "cmd.exe";
private const string BareCmd = @"C:\WINDOWS\system32\cmd.exe";
private const string OobeAccount = "S-1-5-21-1-2-3-1000";
private const string Created = "20261111143453.119277+060";
private const int Pid = 9636;
private const int Parent = 9016;
private static readonly DateTime At = new DateTime(2026, 11, 11, 14, 34, 53, DateTimeKind.Utc);
private static AgentLogger NewLogger(string dir) => new AgentLogger(dir, AgentLogLevel.Info);
@@ -25,59 +32,67 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
{
private readonly TempDirectory _tmp = new TempDirectory();
public List<ConsoleSpawnInfo> Detected { get; } = new List<ConsoleSpawnInfo>();
public List<int> ParentLookups { get; } = new List<int>();
public int WindowsOpened { get; private set; }
public ConsoleBypassWatcher Sut { get; }
public Rig(Func<int, ProcessProbe?> probe)
public Rig(Func<int, string, ProcessProbe?> probe, bool alive = true, Func<int, string?>? parentName = null)
{
Sut = new ConsoleBypassWatcher(NewLogger(_tmp.Path), probe);
Sut = new ConsoleBypassWatcher(
NewLogger(_tmp.Path),
probe,
isAlive: _ => alive,
parentName: pid => { ParentLookups.Add(pid); return parentName?.Invoke(pid); },
utcNow: () => At);
Sut.BypassConsoleDetected += (_, info) => Detected.Add(info);
Sut.ScriptActivityStarted += (_, __) => WindowsOpened++;
}
public void Trace(int pid = Pid, int parentPid = Parent, int sessionId = 1, string? ownerSid = OobeAccount) =>
Sut.HandleStart(pid, parentPid, sessionId, ownerSid, "process_start_trace", Cmd);
public void Dispose() { Sut.Dispose(); _tmp.Dispose(); }
}
private static Func<int, ProcessProbe?> Probe(string? commandLine, string? owner = @"PC\defaultuser0")
=> _ => new ProcessProbe(commandLine, owner);
/// <summary>A running process that is the traced one, with the given command line.</summary>
private static Func<int, string, ProcessProbe?> Running(
string? commandLine, string name = Cmd, int sessionId = 1, int parentPid = Parent, string creationDate = Created)
=> (_, __) => new ProcessProbe(name, sessionId, parentPid, commandLine, creationDate);
private static readonly Func<int, ProcessProbe?> GoneProbe = _ => null;
/// <summary>The process has exited before it could be read.</summary>
private static readonly Func<int, string, ProcessProbe?> Gone = (_, __) => null;
// ---------------------------------------------------------------- HandleStart end-to-end
// ---------------------------------------------------------------- consoles that are reported
[Fact]
public void Interactive_session_bare_cmd_raises_high_confidence_with_signature()
{
using var rig = new Rig(Probe(BareCmd, owner: @"PC\defaultuser0"));
using var rig = new Rig(Running(BareCmd), parentName: _ => "explorer.exe");
rig.Sut.HandleStart(pid: 9636, parentPid: 9016, sessionId: 1, ownerSidHint: null, detectedVia: "process_start_trace");
rig.Trace();
var info = Assert.Single(rig.Detected);
Assert.Equal("cmd.exe", info.ProcessName);
Assert.Equal(9636, info.ProcessId);
Assert.Equal(9016, info.ParentProcessId);
Assert.Equal(Cmd, info.ProcessName);
Assert.Equal(Pid, info.ProcessId);
Assert.Equal(Parent, info.ParentProcessId);
Assert.Equal("explorer.exe", info.ParentProcessName);
Assert.Equal(1, info.SessionId);
Assert.Equal("high", info.Confidence);
Assert.Equal("interactive_console", info.Classification);
Assert.Equal(@"PC\defaultuser0", info.Owner);
Assert.Equal(OobeAccount, info.Owner);
Assert.Equal(BareCmd, info.CommandLine);
Assert.Null(rig.Sut.TakeScriptActivity());
}
[Fact]
public void Session_zero_cmd_is_ignored_even_when_bare()
public void A_console_whose_launcher_is_gone_has_no_parent_name()
{
// IME / Intune install cmd run as SYSTEM in session 0 — never an interactive console.
using var rig = new Rig(Probe(BareCmd));
rig.Sut.HandleStart(1234, 1, sessionId: 0, ownerSidHint: null, detectedVia: "process_start_trace");
Assert.Empty(rig.Detected);
}
// The launcher behind Shift+F10 exits within milliseconds.
using var rig = new Rig(Running(BareCmd));
[Theory]
[InlineData(@"C:\WINDOWS\system32\cmd.exe /c ""install.bat""")]
[InlineData(@"cmd.exe /C ping")]
public void Scripted_cmd_is_ignored(string commandLine)
{
using var rig = new Rig(Probe(commandLine));
rig.Sut.HandleStart(2222, 1, sessionId: 1, ownerSidHint: null, detectedVia: "process_start_trace");
Assert.Empty(rig.Detected);
rig.Trace();
Assert.Null(Assert.Single(rig.Detected).ParentProcessName);
}
[Fact]
@@ -85,9 +100,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
{
// L12: /k runs its command and then leaves a fully interactive shell — a deliberate
// false-negative seam if ignored (e.g. a technician-planted `cmd /k whoami`).
using var rig = new Rig(Probe(@"cmd.exe /k whoami"));
using var rig = new Rig(Running(@"cmd.exe /k whoami"));
rig.Sut.HandleStart(2222, 1, sessionId: 1, ownerSidHint: null, detectedVia: "process_start_trace");
rig.Trace();
var info = Assert.Single(rig.Detected);
Assert.Equal("low", info.Confidence);
@@ -101,9 +116,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
{
// cmd.exe honors only the FIRST /c-or-/k switch; a '/c' inside the /k payload is inert, so
// the console is a persistent human-usable shell and must still be surfaced (low confidence).
using var rig = new Rig(Probe(commandLine));
using var rig = new Rig(Running(commandLine));
rig.Sut.HandleStart(2223, 1, sessionId: 1, ownerSidHint: null, detectedVia: "process_start_trace");
rig.Trace();
var info = Assert.Single(rig.Detected);
Assert.Equal("low", info.Confidence);
@@ -111,88 +126,317 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
}
[Fact]
public void Instant_close_unreadable_commandline_raises_low_confidence()
public void A_running_process_without_a_readable_command_line_raises_low_confidence()
{
// Win32_Process query loses the race (process already exited). SessionID from the trace is
// still known, so the console is surfaced — low confidence, owner from the trace SID hint.
using var rig = new Rig(GoneProbe);
using var rig = new Rig(Running(commandLine: null), alive: true);
rig.Sut.HandleStart(9636, 9016, sessionId: 1, ownerSidHint: "S-1-5-18", detectedVia: "process_start_trace");
rig.Trace();
var info = Assert.Single(rig.Detected);
Assert.Equal("low", info.Confidence);
Assert.Equal("interactive_session_unclassified", info.Classification);
Assert.Equal("S-1-5-18", info.Owner);
Assert.Null(info.CommandLine);
Assert.Null(rig.Sut.TakeScriptActivity());
}
[Fact]
public void Owner_prefers_probe_over_trace_hint()
public void A_running_process_that_could_not_be_read_at_all_raises_low_confidence()
{
using var rig = new Rig(Probe(BareCmd, owner: @"PC\defaultuser0"));
rig.Sut.HandleStart(9636, 9016, sessionId: 1, ownerSidHint: "S-1-5-18", detectedVia: "process_start_trace");
Assert.Equal(@"PC\defaultuser0", Assert.Single(rig.Detected).Owner);
// The query failed. That says nothing about the process: it is reported, never counted away.
using var rig = new Rig((_, __) => ProcessProbe.Unreadable, alive: true);
rig.Trace();
var info = Assert.Single(rig.Detected);
Assert.Equal("low", info.Confidence);
Assert.Equal("interactive_session_unclassified", info.Classification);
Assert.Equal(OobeAccount, info.Owner);
Assert.Null(rig.Sut.TakeScriptActivity());
}
// ---------------------------------------------------------------- starts that are ignored
[Fact]
public void Same_pid_seen_twice_raises_only_once()
public void Session_zero_cmd_is_ignored_even_when_bare()
{
// The startup probe and the live trace can both observe the same still-open console.
using var rig = new Rig(Probe(BareCmd));
rig.Sut.HandleStart(9636, 9016, 1, null, "startup_probe");
rig.Sut.HandleStart(9636, 9016, 1, null, "process_start_trace");
Assert.Single(rig.Detected);
// IME / Intune install cmd run as SYSTEM in session 0 — never an interactive console.
using var rig = new Rig(Running(BareCmd));
rig.Trace(sessionId: 0);
Assert.Empty(rig.Detected);
Assert.Null(rig.Sut.TakeScriptActivity());
}
[Theory]
[InlineData(@"C:\WINDOWS\system32\cmd.exe /c ""install.bat""")]
[InlineData(@"cmd.exe /C ping")]
public void A_running_scripted_cmd_is_neither_reported_nor_counted(string commandLine)
{
using var rig = new Rig(Running(commandLine));
rig.Trace();
Assert.Empty(rig.Detected);
Assert.Null(rig.Sut.TakeScriptActivity());
}
[Fact]
public void Invalid_pid_is_ignored()
{
using var rig = new Rig(Probe(BareCmd));
rig.Sut.HandleStart(0, 9016, 1, null, "process_start_trace");
using var rig = new Rig(Running(BareCmd));
rig.Trace(pid: 0);
Assert.Empty(rig.Detected);
Assert.Null(rig.Sut.TakeScriptActivity());
}
// ---------------------------------------------------------------- starts that are counted
[Fact]
public void A_process_that_has_exited_is_counted_and_not_reported()
{
// WMI delivers the start about a second late; a scripted cmd /c is gone by then. That is
// script activity, not a console someone types into.
using var rig = new Rig(Gone, parentName: _ => "powershell.exe");
rig.Trace();
Assert.Empty(rig.Detected);
var activity = rig.Sut.TakeScriptActivity()!;
Assert.Equal(1, activity.Count);
Assert.Equal(Cmd, activity.ProcessName);
Assert.Equal(At, activity.FirstSeenUtc);
Assert.Equal(new[] { 1 }, activity.WindowsSessions);
Assert.Equal(new[] { OobeAccount }, activity.Owners);
var parent = Assert.Single(activity.Parents);
Assert.Equal(Parent, parent.ProcessId);
Assert.Equal("powershell.exe", parent.Name);
}
[Theory]
[InlineData("SearchFilterHost.exe", 1, Parent)] // another program was given the id
[InlineData(Cmd, 0, Parent)] // a service-session cmd was given the id
[InlineData(Cmd, 1, 4242)] // a cmd of another parent was given the id
public void An_id_that_now_belongs_to_another_process_is_counted_and_not_reported(string name, int sessionId, int parentPid)
{
// Its command line has no /c, which used to read as a bare interactive console.
using var rig = new Rig(Running(@"C:\Windows\System32\other.exe -Embedding", name, sessionId, parentPid));
rig.Trace();
Assert.Empty(rig.Detected);
Assert.Equal(1, rig.Sut.TakeScriptActivity()!.Count);
}
[Fact]
public void A_row_without_a_command_line_whose_process_has_ended_is_counted()
{
using var rig = new Rig(Running(commandLine: null), alive: false);
rig.Trace();
Assert.Empty(rig.Detected);
Assert.Equal(1, rig.Sut.TakeScriptActivity()!.Count);
}
[Fact]
public void A_process_that_could_not_be_read_and_is_gone_at_the_second_look_is_counted()
{
using var rig = new Rig((_, __) => ProcessProbe.Unreadable, alive: false);
rig.Trace();
Assert.Empty(rig.Detected);
Assert.Equal(1, rig.Sut.TakeScriptActivity()!.Count);
}
[Fact]
public void A_burst_opens_one_window_and_asks_for_each_parent_once()
{
using var rig = new Rig(Gone, parentName: _ => "python.exe");
for (var i = 0; i < 25; i++) rig.Trace(pid: 5000 + i, parentPid: 2688);
for (var i = 0; i < 21; i++) rig.Trace(pid: 6000 + i, parentPid: 9296);
Assert.Empty(rig.Detected);
Assert.Equal(1, rig.WindowsOpened);
Assert.Equal(new[] { 2688, 9296 }, rig.ParentLookups);
var activity = rig.Sut.TakeScriptActivity()!;
Assert.Equal(46, activity.Count);
Assert.Equal(new[] { 25, 21 }, activity.Parents.Select(p => p.Children));
}
[Fact]
public void The_next_start_after_the_content_was_taken_opens_a_new_window()
{
using var rig = new Rig(Gone);
rig.Trace(pid: 1);
rig.Trace(pid: 2);
rig.Sut.TakeScriptActivity();
rig.Trace(pid: 3);
Assert.Equal(2, rig.WindowsOpened);
Assert.Equal(1, rig.Sut.TakeScriptActivity()!.Count);
}
[Fact]
public void Nothing_is_reported_or_counted_after_dispose()
{
var rig = new Rig(Gone);
rig.Sut.Dispose();
rig.Trace();
Assert.Empty(rig.Detected);
Assert.Null(rig.Sut.TakeScriptActivity());
rig.Dispose();
}
// ---------------------------------------------------------------- de-duplication
[Fact]
public void The_same_process_seen_by_startup_probe_and_trace_raises_only_once()
{
// The startup probe and the live trace can both observe the same still-open console.
using var rig = new Rig(Running(BareCmd));
rig.Sut.HandleProbed(Pid, Parent, 1, OobeAccount, "startup_probe", Cmd, new ProcessProbe(Cmd, 1, Parent, BareCmd, Created));
rig.Trace();
Assert.Equal("startup_probe", Assert.Single(rig.Detected).DetectedVia);
}
[Fact]
public void A_later_console_that_was_given_an_id_seen_before_is_reported_again()
{
var created = Created;
using var rig = new Rig((_, __) => new ProcessProbe(Cmd, 1, Parent, BareCmd, created));
rig.Trace();
created = "20261111150324.504661+060";
rig.Trace();
Assert.Equal(2, rig.Detected.Count);
}
// ---------------------------------------------------------------- the probe's decision
private static readonly ProcessProbe Row = new ProcessProbe(Cmd, 1, Parent, BareCmd, Created);
[Theory]
[InlineData("NotRunning")]
[InlineData("OtherImage")]
public void The_probe_says_gone_only_when_the_process_list_is_certain(string listed)
{
var asked = false;
var state = (ConsoleBypassWatcher.ListedAs)Enum.Parse(typeof(ConsoleBypassWatcher.ListedAs), listed);
var probe = ConsoleBypassWatcher.Probe(Pid, state, _ => { asked = true; return Row; });
Assert.Null(probe);
Assert.False(asked, "a process that is certainly gone costs no query");
}
[Theory]
[InlineData("Expected")]
[InlineData("Unknown")]
public void The_probe_returns_the_row_of_a_process_that_may_still_run(string listed)
{
var state = (ConsoleBypassWatcher.ListedAs)Enum.Parse(typeof(ConsoleBypassWatcher.ListedAs), listed);
Assert.Same(Row, ConsoleBypassWatcher.Probe(Pid, state, _ => Row));
}
[Fact]
public void A_failed_query_is_unreadable_and_never_gone()
{
var probe = ConsoleBypassWatcher.Probe(
Pid, ConsoleBypassWatcher.ListedAs.Expected, _ => throw new InvalidOperationException("WinMgmt did not answer"));
Assert.Same(ProcessProbe.Unreadable, probe);
}
[Fact]
public void A_query_without_a_row_is_unreadable_and_never_gone()
{
var probe = ConsoleBypassWatcher.Probe(Pid, ConsoleBypassWatcher.ListedAs.Expected, _ => null);
Assert.Same(ProcessProbe.Unreadable, probe);
}
[Fact]
public void The_process_list_tells_running_other_image_and_not_running_apart()
{
using var me = System.Diagnostics.Process.GetCurrentProcess();
const int neverAProcessId = int.MaxValue;
Assert.Equal(ConsoleBypassWatcher.ListedAs.Expected, ConsoleBypassWatcher.ProcessListState(me.Id, me.ProcessName + ".exe"));
Assert.Equal(ConsoleBypassWatcher.ListedAs.OtherImage, ConsoleBypassWatcher.ProcessListState(me.Id, "not-" + me.ProcessName + ".exe"));
Assert.Equal(ConsoleBypassWatcher.ListedAs.NotRunning, ConsoleBypassWatcher.ProcessListState(neverAProcessId, Cmd));
Assert.True(ConsoleBypassWatcher.IsRunning(me.Id));
Assert.False(ConsoleBypassWatcher.IsRunning(neverAProcessId));
}
// ---------------------------------------------------------------- IsTracedProcess (pure)
[Theory]
[InlineData("cmd.exe", 1, Parent, true)]
[InlineData("CMD.EXE", 2, Parent, true)] // image name compares without case
[InlineData("cmd.exe", 1, 0, true)] // the row does not know its parent
// A fact that could not be read contradicts nothing — the process stays the traced one.
[InlineData(null, null, null, true)]
[InlineData(null, 1, Parent, true)]
[InlineData("cmd.exe", null, Parent, true)]
[InlineData("cmd.exe", 1, null, true)]
// A fact that was read and differs makes it another process.
[InlineData("conhost.exe", 1, Parent, false)]
[InlineData("cmd.exe", 0, Parent, false)]
[InlineData("cmd.exe", 1, 77, false)]
public void IsTracedProcess_compares_name_session_and_parent(string? name, int? sessionId, int? rowParent, bool expected)
{
var probe = new ProcessProbe(name, sessionId, rowParent, BareCmd, Created);
Assert.Equal(expected, ConsoleBypassWatcher.IsTracedProcess(probe, Cmd, Parent));
}
[Fact]
public void IsTracedProcess_accepts_any_parent_when_the_trace_has_none()
{
var probe = new ProcessProbe(Cmd, 1, 77, BareCmd, Created);
Assert.True(ConsoleBypassWatcher.IsTracedProcess(probe, Cmd, parentPid: 0));
}
// ---------------------------------------------------------------- Classify (pure)
[Theory]
[InlineData(0, BareCmd, "Ignore")] // service session
[InlineData(1, null, "UnclassifiedInteractive")] // instant-close
[InlineData(1, null, "UnclassifiedInteractive")] // running, command line not readable
[InlineData(1, BareCmd, "InteractiveConsole")] // bare shell
[InlineData(1, @"""C:\WINDOWS\system32\cmd.exe""", "InteractiveConsole")]
[InlineData(1, "", "InteractiveConsole")]
[InlineData(1, @"cmd.exe /d", "InteractiveConsole")] // /d skips AutoRun — still interactive
[InlineData(1, @"C:\cmd\cmd.exe", "InteractiveConsole")] // folder named 'cmd', not a switch
[InlineData(1, @"""C:/k/cmd.exe""", "InteractiveConsole")] // forward-slash path stripped before scan
[InlineData(1, @"cmd.exe /c foo", "Ignore")] // scripted, run-and-exit
[InlineData(1, @"cmd.exe /C whoami", "Ignore")]
[InlineData(1, @"""C:\WINDOWS\system32\cmd.exe"" /c ""x.bat""", "Ignore")]
[InlineData(2, @"C:\x\cmd.exe /K bar", "InteractiveWithCommand")] // L12: /k stays interactive
[InlineData(1, @"cmd.exe /k whoami", "InteractiveWithCommand")] // technician-planted shell
[InlineData(1, @"cmd.exe /k", "InteractiveWithCommand")]
[InlineData(1, @"cmd.exe /D/Q/K stay", "InteractiveWithCommand")] // combined run-and-stay
[InlineData(1, @"cmd.exe /d/q/c exit 9", "Ignore")] // combined run-and-exit wins
[InlineData(1, @"cmd /k rem /c", "InteractiveWithCommand")] // cmd.exe honors the FIRST switch: /k stays interactive
[InlineData(1, @"cmd.exe /k ""title /c""", "InteractiveWithCommand")] // /c inside the /k payload is not a switch
[InlineData(1, @"cmd /k /c", "InteractiveWithCommand")] // /c after /k is just the /k payload
[InlineData(1, @"cmd.exe /c cmd /k", "Ignore")] // first switch /c wins the other way round
[InlineData(1, @"cmd.exe /c rem /k", "Ignore")]
public void Classify_maps_session_and_commandline(int sessionId, string? commandLine, string expected)
{
Assert.Equal(expected, ConsoleBypassWatcher.Classify(sessionId, commandLine).ToString());
}
// ---------------------------------------------------------------- HasScriptArgument (pure)
[Theory]
[InlineData(@"C:\WINDOWS\system32\cmd.exe", false)]
[InlineData(@"""C:\WINDOWS\system32\cmd.exe""", false)]
[InlineData(@"cmd.exe /d", false)] // /d skips AutoRun — still interactive
[InlineData(@"C:\cmd\cmd.exe", false)] // folder named 'cmd', not a switch
[InlineData(@"""C:/k/cmd.exe""", false)] // forward-slash path stripped before scan
[InlineData("", false)]
[InlineData(@"cmd.exe /c whoami", true)]
[InlineData(@"cmd.exe /C whoami", true)]
[InlineData(@"cmd.exe /k", false)] // L12: /k leaves an interactive shell
[InlineData(@"cmd.exe /d/q/c exit 9", true)] // combined switches — the field case
[InlineData(@"cmd.exe /D/Q/K stay", false)] // L12: run-and-stay is not scripted
[InlineData(@"cmd.exe /k rem /c", false)] // first switch /k wins — payload /c is inert
[InlineData(@"cmd.exe /c rem /k", true)] // first switch /c wins
[InlineData(@"""C:\WINDOWS\system32\cmd.exe"" /c ""x.bat""", true)]
public void HasScriptArgument_detects_run_command_switch(string commandLine, bool expected)
{
Assert.Equal(expected, ConsoleBypassWatcher.HasScriptArgument(commandLine));
}
[Theory]
[InlineData(@"C:\WINDOWS\system32\cmd.exe", "")]
[InlineData(@"cmd.exe /d/q/c exit 9", "/d/q/c exit 9")]
@@ -0,0 +1,152 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
{
/// <summary>
/// The tally behind <c>console_script_activity</c>: what it counts, what it lists and caps, and
/// that taking the content empties it.
/// </summary>
public sealed class ConsoleScriptActivityTallyTests
{
private static readonly DateTime At = new DateTime(2026, 11, 11, 14, 41, 35, DateTimeKind.Utc);
private const string Cmd = "cmd.exe";
private const string OobeAccount = "S-1-5-21-1-2-3-1000";
private static string? NoName(int pid) => null;
[Fact]
public void An_empty_tally_has_nothing_to_take()
{
Assert.Null(new ConsoleScriptActivityTally().Take());
}
[Fact]
public void Starts_are_counted_with_time_span_sessions_owners_and_parents()
{
var sut = new ConsoleScriptActivityTally();
sut.Add(Cmd, 101, 50, sessionId: 2, OobeAccount, At, _ => "powershell.exe");
sut.Add(Cmd, 102, 50, sessionId: 1, OobeAccount, At.AddSeconds(1), _ => "powershell.exe");
sut.Add(Cmd, 103, 60, sessionId: 1, "S-1-5-18", At.AddSeconds(9), _ => "setup.exe");
var a = sut.Take()!;
Assert.Equal(Cmd, a.ProcessName);
Assert.Equal(3, a.Count);
Assert.Equal(At, a.FirstSeenUtc);
Assert.Equal(At.AddSeconds(9), a.LastSeenUtc);
Assert.Equal(new[] { 1, 2 }, a.WindowsSessions);
Assert.Equal(new[] { OobeAccount, "S-1-5-18" }, a.Owners);
Assert.Equal(2, a.OwnersTotal);
Assert.Equal(2, a.ParentsTotal);
Assert.Equal(new[] { 50, 60 }, a.Parents.Select(p => p.ProcessId));
Assert.Equal(new[] { 2, 1 }, a.Parents.Select(p => p.Children));
Assert.Equal(new[] { "powershell.exe", "setup.exe" }, a.Parents.Select(p => p.Name));
}
[Fact]
public void Taking_empties_the_tally_and_the_next_start_opens_it_again()
{
var sut = new ConsoleScriptActivityTally();
Assert.True(sut.Add(Cmd, 101, 50, 1, OobeAccount, At, NoName));
Assert.False(sut.Add(Cmd, 102, 50, 1, OobeAccount, At, NoName));
Assert.Equal(2, sut.Take()!.Count);
Assert.Null(sut.Take());
Assert.True(sut.Add(Cmd, 103, 50, 1, OobeAccount, At, NoName));
Assert.Equal(1, sut.Take()!.Count);
}
[Fact]
public void A_parent_is_looked_up_once_even_when_the_lookup_finds_nothing()
{
var sut = new ConsoleScriptActivityTally();
var asked = new List<int>();
string? Lookup(int pid) { asked.Add(pid); return null; }
for (var i = 0; i < 5; i++) sut.Add(Cmd, 100 + i, 50, 1, OobeAccount, At, Lookup);
Assert.Equal(new[] { 50 }, asked);
var parent = Assert.Single(sut.Take()!.Parents);
Assert.Null(parent.Name);
Assert.Equal(5, parent.Children);
}
[Fact]
public void A_parent_that_was_counted_itself_is_named_without_a_lookup()
{
// A script shell that is already gone, and the children it started.
var sut = new ConsoleScriptActivityTally();
var asked = new List<int>();
string? Lookup(int pid) { asked.Add(pid); return "AgentExecutor.exe"; }
sut.Add(Cmd, 100, 50, 2, OobeAccount, At, Lookup);
for (var i = 1; i <= 6; i++) sut.Add(Cmd, 100 + i, 100, 2, OobeAccount, At, Lookup);
Assert.Equal(new[] { 50 }, asked);
var a = sut.Take()!;
Assert.Equal(7, a.Count);
Assert.Equal(new[] { 100, 50 }, a.Parents.Select(p => p.ProcessId));
Assert.Equal(new[] { Cmd, "AgentExecutor.exe" }, a.Parents.Select(p => p.Name));
}
[Fact]
public void Lookups_are_limited_per_window_and_allowed_again_after_taking()
{
var sut = new ConsoleScriptActivityTally();
var lookups = 0;
string? Lookup(int pid) { lookups++; return "p" + pid + ".exe"; }
for (var i = 0; i < ConsoleScriptActivityTally.MaxParentLookups + 3; i++)
sut.Add(Cmd, 1000 + i, 10 + i, 1, OobeAccount, At, Lookup);
Assert.Equal(ConsoleScriptActivityTally.MaxParentLookups, lookups);
sut.Take();
sut.Add(Cmd, 2000, 99, 1, OobeAccount, At, Lookup);
Assert.Equal(ConsoleScriptActivityTally.MaxParentLookups + 1, lookups);
}
[Fact]
public void Lists_are_capped_and_the_totals_tell_how_much_there_was()
{
var sut = new ConsoleScriptActivityTally();
const int parents = ConsoleScriptActivityTally.MaxParentsListed + 3;
const int owners = ConsoleScriptActivityTally.MaxOwnersListed + 2;
for (var p = 0; p < parents; p++)
{
// Parent p gets p+1 children, so the busiest parents are the last ones added.
for (var c = 0; c <= p; c++)
sut.Add(Cmd, 10_000 + p * 100 + c, 500 + p, 1, "S-1-5-21-9-9-9-" + (p % owners), At, NoName);
}
var a = sut.Take()!;
Assert.Equal(parents, a.ParentsTotal);
Assert.Equal(ConsoleScriptActivityTally.MaxParentsListed, a.Parents.Count);
Assert.Equal(500 + parents - 1, a.Parents[0].ProcessId);
Assert.Equal(parents, a.Parents[0].Children);
Assert.Equal(owners, a.OwnersTotal);
Assert.Equal(ConsoleScriptActivityTally.MaxOwnersListed, a.Owners.Count);
}
[Fact]
public void A_start_without_a_parent_id_or_owner_still_counts()
{
var sut = new ConsoleScriptActivityTally();
sut.Add(Cmd, 101, 0, 1, null, At, _ => throw new InvalidOperationException("no parent to look up"));
var a = sut.Take()!;
Assert.Equal(1, a.Count);
Assert.Empty(a.Parents);
Assert.Equal(0, a.ParentsTotal);
Assert.Empty(a.Owners);
}
}
}
@@ -0,0 +1,325 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Persistence;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
{
/// <summary>
/// ConsoleBypassHost — what goes onto the single rail: a console the watcher reports becomes a
/// Warning <c>oobe_console_spawned</c>; starts the watcher only counts become one Info
/// <c>console_script_activity</c> per window, capped per run, and whatever is still counted goes
/// out on every way the host stops. The watcher is driven through its internal entry points with
/// injected probes; no WMI subscription and no window timer runs here.
/// </summary>
[Collection("SerialThreading")] // one test runs a real SignalIngress worker
public sealed class ConsoleBypassHostTests : IDisposable
{
private static readonly DateTime At = new DateTime(2026, 11, 11, 14, 41, 35, DateTimeKind.Utc);
private const string Cmd = "cmd.exe";
private const string BareCmd = @"C:\WINDOWS\system32\cmd.exe";
private const string OobeAccount = "S-1-5-21-1-2-3-1000";
private const int ScriptHost = 2688;
private const int GoneLauncher = 9016;
private readonly TempDirectory _tmp = new TempDirectory();
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
private readonly AgentLogger _logger;
private string? _runningCommandLine; // null = every probed process has exited
private DateTime _now = At;
public ConsoleBypassHostTests()
{
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
}
public void Dispose() => _tmp.Dispose();
private ConsoleBypassWatcher NewWatcher() => new ConsoleBypassWatcher(
_logger,
processProbe: (pid, name) => _runningCommandLine == null
? null
: new ProcessProbe(name, 1, 0, _runningCommandLine, "20261111144135.000000+060"),
isAlive: _ => true,
parentName: pid => pid == ScriptHost ? "powershell.exe" : null,
utcNow: () => _now);
private ConsoleBypassHost Build(ConsoleBypassWatcher watcher, ISignalIngressSink? ingress = null) =>
new ConsoleBypassHost("sess-console", "tenant-console", ingress ?? _sink, new VirtualClock(At), _logger, watcher);
private static void Trace(ConsoleBypassWatcher watcher, int pid, int parentPid = ScriptHost) =>
watcher.HandleStart(pid, parentPid, 1, OobeAccount, "process_start_trace", Cmd);
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> Events(string eventType) =>
_sink.Posted.Where(p =>
p.Kind == DecisionSignalKind.InformationalEvent
&& p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == eventType).ToList();
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal s) =>
(IReadOnlyDictionary<string, object>)s.TypedPayload!;
// ---------------------------------------------------------------- consoles
[Fact]
public void A_console_is_a_warning_with_immediate_upload_and_the_name_of_its_parent()
{
_runningCommandLine = BareCmd;
var watcher = NewWatcher();
using var host = Build(watcher);
Trace(watcher, pid: 9636);
var s = Assert.Single(Events(Constants.EventTypes.OobeConsoleSpawned));
Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]);
Assert.Equal("true", s.Payload![SignalPayloadKeys.ImmediateUpload]);
var data = Data(s);
Assert.Equal("high", data["confidence"]);
Assert.Equal("powershell.exe", data["parentProcessName"]);
Assert.Equal(ScriptHost, data["parentProcessId"]);
Assert.Equal(OobeAccount, data["owner"]);
Assert.Equal(BareCmd, data["commandLine"]);
Assert.Empty(Events(Constants.EventTypes.ConsoleScriptActivity));
}
[Fact]
public void A_low_confidence_console_is_uploaded_at_once_too_and_names_a_gone_parent_unknown()
{
// The confidence follows the command line, which whoever opens the console chooses:
// it labels the warning, it never delays it.
_runningCommandLine = "cmd.exe /k whoami";
var watcher = NewWatcher();
using var host = Build(watcher);
Trace(watcher, pid: 9636, parentPid: GoneLauncher);
var s = Assert.Single(Events(Constants.EventTypes.OobeConsoleSpawned));
Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]);
Assert.Equal("true", s.Payload![SignalPayloadKeys.ImmediateUpload]);
Assert.Equal("low", Data(s)["confidence"]);
Assert.Equal("unknown", Data(s)["parentProcessName"]);
}
// ---------------------------------------------------------------- script activity
[Fact]
public void Counted_starts_become_one_info_line_when_the_window_ends()
{
var watcher = NewWatcher();
using var host = Build(watcher);
Trace(watcher, pid: 5001);
_now = At.AddSeconds(1);
Trace(watcher, pid: 5002);
_now = At.AddSeconds(2);
Trace(watcher, pid: 5003);
Assert.Empty(_sink.Posted);
host.WindowElapsedForTest();
Assert.Empty(Events(Constants.EventTypes.OobeConsoleSpawned));
var s = Assert.Single(Events(Constants.EventTypes.ConsoleScriptActivity));
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
Assert.Equal("false", s.Payload![SignalPayloadKeys.ImmediateUpload]);
Assert.Equal(At.AddSeconds(2), s.OccurredAtUtc);
Assert.Equal(
"Script activity: 3 short-lived cmd.exe processes in session 1, started by powershell.exe — counted, not an interactive console",
s.Payload![SignalPayloadKeys.Message]);
var data = Data(s);
Assert.Equal(3, data["count"]);
Assert.Equal(Cmd, data["processName"]);
Assert.Equal(At.ToString("o"), data["firstSeenUtc"]);
Assert.Equal(At.AddSeconds(2).ToString("o"), data["lastSeenUtc"]);
Assert.Equal(new[] { 1 }, (List<int>)data["windowsSessions"]);
Assert.Equal(new[] { OobeAccount }, (List<string>)data["owners"]);
Assert.Equal(1, data["ownersTotal"]);
Assert.Equal(1, data["parentsTotal"]);
var parent = Assert.Single((List<Dictionary<string, object>>)data["parents"]);
Assert.Equal(ScriptHost, parent["pid"]);
Assert.Equal("powershell.exe", parent["name"]);
Assert.Equal(3, parent["children"]);
Assert.False(data.ContainsKey("capped"));
}
[Fact]
public void A_window_without_counted_starts_reports_nothing()
{
using var host = Build(NewWatcher());
host.WindowElapsedForTest();
Assert.Empty(_sink.Posted);
}
[Fact]
public void Each_window_reports_only_its_own_starts()
{
var watcher = NewWatcher();
using var host = Build(watcher);
Trace(watcher, pid: 5001);
host.WindowElapsedForTest();
Trace(watcher, pid: 5002);
Trace(watcher, pid: 5003);
host.WindowElapsedForTest();
Assert.Equal(new object[] { 1, 2 }, Events(Constants.EventTypes.ConsoleScriptActivity).Select(s => Data(s)["count"]));
}
[Fact]
public void After_the_cap_no_window_reports_and_the_line_at_stop_says_so()
{
var watcher = NewWatcher();
var host = Build(watcher);
for (var i = 0; i < ConsoleBypassHost.MaxScriptActivitySummaries; i++)
{
Trace(watcher, pid: 5000 + i);
host.WindowElapsedForTest();
}
Assert.Equal(ConsoleBypassHost.MaxScriptActivitySummaries, Events(Constants.EventTypes.ConsoleScriptActivity).Count);
Trace(watcher, pid: 7001);
Trace(watcher, pid: 7002);
host.WindowElapsedForTest();
Assert.Equal(ConsoleBypassHost.MaxScriptActivitySummaries, Events(Constants.EventTypes.ConsoleScriptActivity).Count);
host.Stop();
var all = Events(Constants.EventTypes.ConsoleScriptActivity);
Assert.Equal(ConsoleBypassHost.MaxScriptActivitySummaries + 1, all.Count);
Assert.All(all.Take(ConsoleBypassHost.MaxScriptActivitySummaries), s => Assert.False(Data(s).ContainsKey("capped")));
var last = Data(all[all.Count - 1]);
Assert.Equal(2, last["count"]);
Assert.Equal(true, last["capped"]);
}
// ---------------------------------------------------------------- stopping
[Fact]
public void Stopping_reports_what_was_counted_exactly_once()
{
var watcher = NewWatcher();
var host = Build(watcher);
Trace(watcher, pid: 5001);
Trace(watcher, pid: 5002);
host.Stop();
host.Stop();
host.Dispose();
Assert.Equal(2, Data(Assert.Single(Events(Constants.EventTypes.ConsoleScriptActivity)))["count"]);
}
[Fact]
public void Desktop_arrival_reports_what_was_counted()
{
var watcher = NewWatcher();
var host = Build(watcher);
Trace(watcher, pid: 5001);
host.StopForDesktopArrival();
Assert.Single(Events(Constants.EventTypes.ConsoleScriptActivity));
}
[Fact]
public void The_stop_signal_on_the_decision_rail_reports_what_was_counted()
{
var clock = new VirtualClock(At);
using var ingress = new SignalIngress(
engine: new DecisionEngine(),
signalLog: new SignalLogWriter(_tmp.File("signal-log.jsonl")),
traceCounter: new SessionTraceOrdinalProvider(),
processor: new FakeDecisionStepProcessor(),
clock: clock);
var postedEventTypes = new List<string>();
ingress.SignalPosted += (kind, payload) =>
{
if (kind == DecisionSignalKind.InformationalEvent && payload != null
&& payload.TryGetValue(SignalPayloadKeys.EventType, out var et))
{
lock (postedEventTypes) postedEventTypes.Add(et);
}
};
var watcher = NewWatcher();
var host = Build(watcher, ingress);
ingress.Start();
host.StartCore(armWatcher: false);
Trace(watcher, pid: 5001);
ingress.Post(
DecisionSignalKind.DesktopArrived, At, "DesktopArrivalDetector",
new Evidence(EvidenceKind.Raw, "desktop", "evidence-desktop"));
Assert.True(SpinWait.SpinUntil(() => { lock (postedEventTypes) return postedEventTypes.Contains(Constants.EventTypes.ConsoleScriptActivity); }, 5000),
"the host stopped on the rail signal without reporting what was counted");
Trace(watcher, pid: 5002); // the watcher is disposed: nothing is counted any more
Assert.Null(watcher.TakeScriptActivity());
}
[Fact]
public void A_stopped_ingress_does_not_break_the_stop()
{
var watcher = NewWatcher();
var host = Build(watcher);
Trace(watcher, pid: 5001);
_sink.ThrowOnPost = new InvalidOperationException("SignalIngress has been stopped.");
Assert.Null(Record.Exception(() => host.Dispose()));
}
// ---------------------------------------------------------------- timeline text
private static ConsoleScriptActivity Activity(int count, int[] sessions, int parentsTotal, params (int pid, string? name, int children)[] parents) =>
new ConsoleScriptActivity(
Cmd, count, At, At, sessions,
new[] { OobeAccount }, 1,
parents.Select(p => new ConsoleScriptParent(p.pid, p.name, p.children)).ToList(), parentsTotal);
[Fact]
public void One_process_from_a_parent_that_is_gone()
{
Assert.Equal(
"Script activity: 1 short-lived cmd.exe process in session 1, started by an unknown program — counted, not an interactive console",
ConsoleBypassHost.DescribeScriptActivity(Activity(1, new[] { 1 }, 1, (6688, null, 1))));
}
[Fact]
public void Several_named_parents_in_two_sessions()
{
Assert.Equal(
"Script activity: 46 short-lived cmd.exe processes in sessions 1, 2, started by 2 parent processes (powershell.exe, cmd.exe) — counted, not an interactive console",
ConsoleBypassHost.DescribeScriptActivity(Activity(46, new[] { 1, 2 }, 2, (2688, "powershell.exe", 25), (9296, "cmd.exe", 21))));
}
[Fact]
public void More_parents_than_names_end_in_an_ellipsis()
{
Assert.Equal(
"Script activity: 9 short-lived cmd.exe processes in session 2, started by 4 parent processes (python.exe, …) — counted, not an interactive console",
ConsoleBypassHost.DescribeScriptActivity(Activity(9, new[] { 2 }, 4, (10, "python.exe", 5), (11, null, 4))));
}
[Fact]
public void Parents_without_any_name_are_only_counted()
{
Assert.Equal(
"Script activity: 3 short-lived cmd.exe processes in session 1, started by 3 parent processes — counted, not an interactive console",
ConsoleBypassHost.DescribeScriptActivity(Activity(3, new[] { 1 }, 3, (10, null, 1), (11, null, 1), (12, null, 1))));
}
}
}
@@ -43,6 +43,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
[InlineData("network_connectivity_check")]
// Bookkeeping of health-script runs that were deliberately not reported.
[InlineData("script_recurrence_summary")]
// Counted short-lived console processes — a spawning script is not enrollment progress.
[InlineData("console_script_activity")]
// Agent health / control / transport — not device/enrollment progress (P2).
[InlineData("collector_degraded")]
[InlineData("telemetry_upload_poisoned")]
@@ -3,8 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
@@ -130,7 +129,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
try { pid = process.Id; } catch { }
try { session = process.SessionId; } catch { }
try { start = process.StartTime.ToUniversalTime(); } catch { }
try { path = QueryImagePath(pid); } catch { }
try { path = ProcessImagePath.Query(pid); } catch { }
if (path == null)
{
try { path = process.MainModule?.FileName; } catch { }
@@ -138,35 +137,5 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
return new ImeProcessCandidate(pid, session, path, start);
}
private const int PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr OpenProcess(int desiredAccess, bool inheritHandle, int processId);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr handle);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool QueryFullProcessImageName(IntPtr process, int flags, StringBuilder exeName, ref int size);
private static string QueryImagePath(int pid)
{
if (pid <= 0) return null;
var handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
if (handle == IntPtr.Zero) return null;
try
{
var sb = new StringBuilder(1024);
int size = sb.Capacity;
return QueryFullProcessImageName(handle, 0, sb, ref size) && size > 0 ? sb.ToString(0, size) : null;
}
finally
{
CloseHandle(handle);
}
}
}
}
@@ -0,0 +1,45 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// Full image path of another process through <c>QueryFullProcessImageName</c>. It needs only
/// <c>PROCESS_QUERY_LIMITED_INFORMATION</c> and resolves across bitness, where
/// <c>Process.MainModule</c> throws.
/// </summary>
internal static class ProcessImagePath
{
private const int PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr OpenProcess(int desiredAccess, bool inheritHandle, int processId);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr handle);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool QueryFullProcessImageName(IntPtr process, int flags, StringBuilder exeName, ref int size);
/// <summary>The path, or <c>null</c> when the process cannot be opened or queried.</summary>
public static string Query(int pid)
{
if (pid <= 0) return null;
var handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
if (handle == IntPtr.Zero) return null;
try
{
var sb = new StringBuilder(1024);
int size = sb.Capacity;
return QueryFullProcessImageName(handle, 0, sb, ref size) && size > 0 ? sb.ToString(0, size) : null;
}
finally
{
CloseHandle(handle);
}
}
}
}
@@ -1,3 +1,4 @@
using System;
using System.Management;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
@@ -121,5 +122,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
return null;
}
/// <summary>
/// Owner SID of a <c>Win32_Process</c> row via <c>GetOwnerSid</c>, or <c>null</c> on failure.
/// The row must have been selected with <c>Handle</c>: without its key property WMI cannot
/// invoke a method on the object and throws.
/// </summary>
public static string OwnerSid(ManagementObject process)
{
try
{
using (var result = process.InvokeMethod("GetOwnerSid", null, null))
{
if (result != null && Convert.ToUInt32(result["ReturnValue"]) == 0)
return result["Sid"]?.ToString();
}
}
catch
{
// Process gone or owner not readable.
}
return null;
}
}
}
@@ -166,12 +166,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
{
var pidObj = e.NewEvent?["ProcessID"];
if (pidObj == null) return;
var pid = Convert.ToInt32(pidObj);
Process? proc = null;
try { proc = Process.GetProcessById(pid); }
catch { /* already gone — ignore */ }
if (proc != null) TrackWorker(proc);
TrackTracedProcess(Convert.ToInt32(pidObj), WorkerProcessNameNoExt);
}
catch (Exception ex)
{
@@ -179,6 +174,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
}
}
/// <summary>
/// Tracks the process a start trace announced (internal for tests). WMI delivers the trace
/// with a delay, so the id may already belong to another process: only a process that still
/// carries the worker's name is tracked. A foreign process would hold the install window
/// open until it exits.
/// </summary>
internal void TrackTracedProcess(int pid, string processNameNoExt)
{
Process? proc = null;
try { proc = Process.GetProcessById(pid); }
catch { /* already gone — ignore */ }
if (proc == null) return;
bool isWorker;
try { isWorker = string.Equals(proc.ProcessName, processNameNoExt, StringComparison.OrdinalIgnoreCase); }
catch { isWorker = false; /* exited between the lookup and the name read */ }
if (isWorker)
{
TrackWorker(proc);
return;
}
try { proc.Dispose(); } catch { }
}
private void TrackWorker(Process proc)
{
bool tracked = false;
@@ -1,10 +1,14 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Management;
using System.Security.Principal;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
{
@@ -26,10 +30,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
/// </list>
/// </para>
/// <para>
/// <b>Instant-close fallback.</b> Reading the command line still races if the process exits before
/// the probe. In that case (<c>SessionID ≠ 0</c> but command line unreadable) the console is still
/// surfaced, tagged <c>confidence:"low"</c> — better than missing it. A truly race-free capture of
/// the command line would require the ETW Kernel-Process provider (deferred).
/// <b>A process id is not an identity.</b> WMI delivers the start trace in batches about once a
/// second, so the probe runs up to a second after the start. By then a scripted <c>cmd /c</c> has
/// usually exited, and Windows may have given its id to another process. A start whose process is
/// gone, or whose id now belongs to a different process (other image name, session 0, other
/// parent), is not a console someone types into: it is counted in
/// <see cref="ConsoleScriptActivityTally"/> and reported as one summary, never as a console.
/// The comparison uses no clock on purpose — trace time and process start time need not follow
/// the same clock corrections during OOBE, and a wrong tolerance would count real consoles away.
/// Counting needs certainty: the process list no longer has the id, or a fact that was read
/// contradicts the trace. A query that fails, or a fact that cannot be read, proves nothing —
/// such a process is reported at low confidence, exactly as a running console nobody could
/// classify.
/// </para>
/// <para>
/// <b>Lifecycle.</b> The host stops this watcher once the real-user desktop has arrived (wired in
@@ -69,9 +81,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
}
private readonly AgentLogger _logger;
private readonly Func<int, ProcessProbe?> _processProbe;
private readonly Func<int, string, ProcessProbe?> _processProbe;
private readonly Func<int, bool> _isAlive;
private readonly Func<int, string?> _parentName;
private readonly Func<DateTime> _utcNow;
private readonly ConsoleScriptActivityTally _tally = new ConsoleScriptActivityTally();
private readonly object _lock = new object();
private readonly HashSet<int> _seen = new HashSet<int>();
private readonly HashSet<string> _seen = new HashSet<string>(StringComparer.Ordinal);
private ManagementEventWatcher? _startWatcher;
private bool _started;
@@ -79,11 +95,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
/// <summary>
/// Raised once per process when a watched console is classified as an interactive console
/// (high confidence) or an unclassifiable interactive-session console (low confidence). The
/// (high confidence) or as one that stays open or cannot be classified (low confidence). The
/// host translates this into a Warning <c>oobe_console_spawned</c> event.
/// </summary>
public event EventHandler<ConsoleSpawnInfo>? BypassConsoleDetected;
/// <summary>
/// Raised when a short-lived start is counted into an empty tally. The host opens its
/// reporting window on it and collects the content with <see cref="TakeScriptActivity"/>.
/// </summary>
internal event EventHandler? ScriptActivityStarted;
/// <summary>
/// Raised when the WMI <c>Win32_ProcessStartTrace</c> watcher fails to arm — the LIVE detector
/// is dead and only the one-shot startup probe ran. The host surfaces this as a
@@ -92,12 +114,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
/// </summary>
public event EventHandler<Exception>? WatcherArmFailed;
/// <param name="processProbe">Resolves the command line + owner for a live PID; returns null
/// when the process has already exited (the instant-close race). Defaults to a WMI query.</param>
public ConsoleBypassWatcher(AgentLogger logger, Func<int, ProcessProbe?>? processProbe = null)
/// <param name="processProbe">Reads a live process by id; the second argument is the image
/// name the trace announced. Returns null only when the process has certainly exited or
/// the id certainly runs another image, and <see cref="ProcessProbe.Unreadable"/> when it
/// could not be read. Defaults to a process-list check followed by one WMI row.</param>
/// <param name="isAlive">False only when a process id certainly no longer runs. Defaults to
/// the process list.</param>
/// <param name="parentName">Image file name of a process, or null when it cannot be read.</param>
/// <param name="utcNow">Clock for the timestamps of counted starts.</param>
public ConsoleBypassWatcher(
AgentLogger logger,
Func<int, string, ProcessProbe?>? processProbe = null,
Func<int, bool>? isAlive = null,
Func<int, string?>? parentName = null,
Func<DateTime>? utcNow = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_processProbe = processProbe ?? DefaultProbe;
_isAlive = isAlive ?? IsRunning;
_parentName = parentName ?? ImageFileName;
_utcNow = utcNow ?? (() => DateTime.UtcNow);
}
public void Start()
@@ -114,7 +150,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
// delayed every host after this one — so they run on a background thread. Exceptions
// are contained in StartCore; a disposal racing the background start is handled by
// the _disposed re-checks inside.
System.Threading.Tasks.Task.Run(() => StartCore());
Task.Run(() => StartCore());
}
private void StartCore()
@@ -147,7 +183,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
watcher.Start();
// Re-check after arming: Dispose() may have run between the lock release above and
// Start() — it then stopped a not-yet-armed watcher and this thread armed it right
// Start() — it then tore down a not-yet-armed watcher and this thread armed it right
// back, leaving a WMI subscription no code path could ever reach again. If disposal
// won that race, tear the watcher down here.
bool disposedDuringArm;
@@ -158,9 +194,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
}
if (disposedDuringArm)
{
try { watcher.EventArrived -= OnProcessStartTrace; } catch { }
try { watcher.Stop(); } catch { }
try { watcher.Dispose(); } catch { }
TearDownStartTrace(watcher);
return;
}
@@ -182,27 +216,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
}
}
// 2) Startup probe — catch a console already open before the watcher armed. Win32_Process
// exposes SessionId directly, so the same classification path is reused. Runs AFTER the
// arm so there is no gap; overlap with the live trace is deduped by _seen.
// 2) Startup probe — catch a console already open before the watcher armed. One query
// carries everything the classification needs; Handle is selected because the owner
// is a WMI method call on the row. Runs AFTER the arm so there is no gap; overlap
// with the live trace is deduped by _seen.
try
{
var nameFilter = string.Join(" OR ",
Array.ConvertAll(WatchedProcessNames, n => $"Name = '{n}'"));
using var searcher = new ManagementObjectSearcher(
$"SELECT Name, ProcessId, ParentProcessId, SessionId FROM Win32_Process WHERE {nameFilter}");
"SELECT Handle, Name, ProcessId, ParentProcessId, SessionId, CommandLine, CreationDate " +
$"FROM Win32_Process WHERE {nameFilter}");
using var results = searcher.Get();
foreach (var mo in results)
foreach (ManagementObject mo in results)
{
using (mo)
{
HandleStart(
ToInt(mo["ProcessId"]),
ToInt(mo["ParentProcessId"]),
ToInt(mo["SessionId"]),
ownerSidHint: null,
detectedVia: "startup_probe",
processName: mo["Name"]?.ToString() ?? WatchedProcessNames[0]);
var sessionId = ToInt(mo["SessionId"]);
var commandLine = mo["CommandLine"]?.ToString();
// The owner costs a method call: only for a row that will be reported.
if (Classify(sessionId, commandLine) == ConsoleVerdict.Ignore) continue;
var name = mo["Name"]?.ToString() ?? WatchedProcessNames[0];
var parentPid = ToInt(mo["ParentProcessId"]);
HandleProbed(
ToInt(mo["ProcessId"]), parentPid, sessionId, ProcessOwnerLookup.OwnerSid(mo), "startup_probe", name,
new ProcessProbe(name, sessionId, parentPid, commandLine, mo["CreationDate"]?.ToString()));
}
}
}
@@ -230,13 +269,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
}
/// <summary>
/// Classification core (internal for tests). Gates race-free on <paramref name="sessionId"/>,
/// then probes the live process for its command line; raises <see cref="BypassConsoleDetected"/>
/// once per PID for an interactive console (bare command line → high confidence) or an
/// unreadable interactive-session console (instant-close → low confidence). Scripted
/// invocations (<c>cmd /c</c>) and session-0 (service) cmd are ignored.
/// One traced start (internal for tests). Gates race-free on <paramref name="sessionId"/>,
/// then probes the process: a start that is gone or whose id belongs to another process is
/// counted, everything else goes through <see cref="HandleProbed"/>.
/// </summary>
internal void HandleStart(int pid, int parentPid, int sessionId, string? ownerSidHint, string detectedVia, string? processName = null)
internal void HandleStart(int pid, int parentPid, int sessionId, string? ownerSid, string detectedVia, string? processName = null)
{
if (pid <= 0) return;
lock (_lock) { if (_disposed) return; }
@@ -245,16 +282,46 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
// bulk of legitimate IME/Intune install cmd (SYSTEM, session 0) without even probing.
if (sessionId == 0) return;
var probe = _processProbe(pid); // null when the process already exited (instant-close)
var verdict = Classify(sessionId, probe?.CommandLine);
var name = processName ?? WatchedProcessNames[0];
var probe = _processProbe(pid, name);
if (probe == null || !IsTracedProcess(probe, name, parentPid))
{
CountShortLived(name, pid, parentPid, sessionId, ownerSid);
return;
}
HandleProbed(pid, parentPid, sessionId, ownerSid, detectedVia, name, probe);
}
/// <summary>
/// Classification of a process that was read while it ran (internal for tests). Raises
/// <see cref="BypassConsoleDetected"/> once per process for an interactive console (bare
/// command line → high confidence), one that stays open after its command (<c>/k</c> → low)
/// or a running one whose command line WMI does not return (low). A scripted <c>cmd /c</c>
/// is ignored.
/// </summary>
internal void HandleProbed(int pid, int parentPid, int sessionId, string? ownerSid, string detectedVia, string processName, ProcessProbe probe)
{
lock (_lock) { if (_disposed) return; }
// A row without a command line is only a console nobody could classify while the
// process still runs; one that ended in the meantime is a short-lived start.
if (probe.CommandLine == null && !_isAlive(pid))
{
CountShortLived(processName, pid, parentPid, sessionId, ownerSid);
return;
}
var verdict = Classify(sessionId, probe.CommandLine);
if (verdict == ConsoleVerdict.Ignore) return; // scripted invocation (cmd /c ...)
// Dedup only on a flagged hit, so the startup probe + live trace observing the same console
// collapse to one detection, and an ignored start never pre-empts a later real one.
// collapse to one detection, and an ignored start never pre-empts a later real one. The
// creation time is part of the key: a later console may be given an id seen before.
lock (_lock)
{
if (_disposed) return;
if (!_seen.Add(pid)) return;
if (!_seen.Add(pid + "|" + probe.CreationDate)) return;
}
bool high = verdict == ConsoleVerdict.InteractiveConsole;
@@ -266,15 +333,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
default: classification = "interactive_session_unclassified"; break;
}
var info = new ConsoleSpawnInfo(
processName: processName ?? WatchedProcessNames[0],
processName: processName,
processId: pid,
parentProcessId: parentPid,
sessionId: sessionId,
owner: probe?.Owner ?? ownerSidHint,
commandLine: probe?.CommandLine,
owner: ownerSid,
commandLine: probe.CommandLine,
confidence: high ? "high" : "low",
classification: classification,
detectedVia: detectedVia);
detectedVia: detectedVia,
parentProcessName: SafeParentName(parentPid));
_logger.Warning($"[ConsoleBypassWatcher] interactive console detected: {info.ProcessName} (PID={pid}) " +
$"session={sessionId}, confidence={info.Confidence}, via={detectedVia} — possible Shift+F10");
@@ -283,14 +351,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
catch (Exception ex) { _logger.Warning($"[ConsoleBypassWatcher] handler threw: {ex.Message}"); }
}
/// <summary>
/// The counted short-lived starts since the last call, or null when there were none. The
/// host calls it when its window ends and when it stops.
/// </summary>
internal ConsoleScriptActivity? TakeScriptActivity() => _tally.Take();
private void CountShortLived(string processName, int pid, int parentPid, int sessionId, string? ownerSid)
{
var opened = _tally.Add(processName, pid, parentPid, sessionId, ownerSid, _utcNow(), SafeParentName);
_logger.Trace($"[ConsoleBypassWatcher] short-lived {processName} counted (PID={pid}, parent={parentPid}, session={sessionId})");
if (!opened) return;
try { ScriptActivityStarted?.Invoke(this, EventArgs.Empty); }
catch (Exception ex) { _logger.Debug($"[ConsoleBypassWatcher] script-activity handler threw: {ex.Message}"); }
}
private string? SafeParentName(int parentPid)
{
if (parentPid <= 0) return null;
try { return _parentName(parentPid); }
catch (Exception) { return null; }
}
internal enum ConsoleVerdict { Ignore, InteractiveConsole, UnclassifiedInteractive, InteractiveWithCommand }
/// <summary>
/// Pure classification (internal for tests): session-0 → Ignore; a scripted <c>cmd /c</c> →
/// Ignore; <c>cmd /k</c> (runs its command, then STAYS interactive — L12) →
/// InteractiveWithCommand (low); a bare command line → InteractiveConsole (high); an
/// unreadable command line in an interactive session → UnclassifiedInteractive (low, the
/// instant-close fallback).
/// InteractiveWithCommand (low); a bare command line → InteractiveConsole (high); a running
/// process without a readable command line in an interactive session →
/// UnclassifiedInteractive (low).
/// </summary>
internal static ConsoleVerdict Classify(int sessionId, string? commandLine)
{
@@ -304,15 +395,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
}
}
/// <summary>True when the FIRST <c>/c</c>-or-<c>/k</c> switch on the command line is <c>/c</c>
/// (a scripted run-and-exit invocation, mirroring cmd.exe precedence), false for a bare
/// interactive shell or a <c>/k</c> run-and-stay shell — even one whose payload contains a
/// later <c>/c</c>. The executable token is stripped first so a path can never spoof a
/// switch, and switches may be combined (e.g. <c>cmd /d/q/c exit 9</c>).</summary>
internal static bool HasScriptArgument(string commandLine)
/// <summary>
/// Whether a probed process is still the one the trace announced (internal for tests). Only
/// a fact that contradicts the trace makes it another process — another image name, the
/// service session, another parent. A fact that could not be read contradicts nothing: the
/// process is then reported, never counted away.
/// </summary>
internal static bool IsTracedProcess(ProcessProbe probe, string processName, int parentPid)
{
if (string.IsNullOrEmpty(commandLine)) return false;
return FirstRunSwitch(StripExecutable(commandLine)) == RunSwitch.RunAndExit;
if (probe.Name != null && !string.Equals(probe.Name, processName, StringComparison.OrdinalIgnoreCase)) return false;
if (probe.SessionId == 0) return false;
return parentPid <= 0 || probe.ParentProcessId == null || probe.ParentProcessId <= 0 || probe.ParentProcessId == parentPid;
}
/// <summary>Returns the command line with the leading executable token (quoted or
@@ -330,44 +423,100 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
return space >= 0 ? s.Substring(space + 1) : string.Empty;
}
private static ProcessProbe? DefaultProbe(int pid)
// The process list first: a WMI query takes a few hundred milliseconds, and in a burst
// almost every traced process has already exited. Queried one after the other on the
// delivery thread they would pile up, and a real console closed during the backlog would
// be counted as a script.
private static ProcessProbe? DefaultProbe(int pid, string processName) =>
Probe(pid, ProcessListState(pid, processName), ReadRow);
/// <summary>
/// The probe's decision (internal for tests). Null only when the process list says so for
/// certain: the id no longer runs, or it runs another image. Everything else comes back as
/// a probe — with the row's facts, or <see cref="ProcessProbe.Unreadable"/> when the query
/// failed or returned nothing. A failed query says nothing about the process, so it must
/// never read as "exited".
/// </summary>
internal static ProcessProbe? Probe(int pid, ListedAs listed, Func<int, ProcessProbe?> readRow)
{
if (listed == ListedAs.NotRunning || listed == ListedAs.OtherImage) return null;
try
{
using var searcher = new ManagementObjectSearcher(
$"SELECT CommandLine FROM Win32_Process WHERE ProcessId = {pid}");
using var results = searcher.Get();
foreach (ManagementObject mo in results)
{
using (mo)
{
var commandLine = mo["CommandLine"]?.ToString();
return new ProcessProbe(commandLine, TryGetOwner(mo));
}
}
return readRow(pid) ?? ProcessProbe.Unreadable;
}
catch (Exception)
{
// Process gone or query failed — treated as the instant-close fallback (low confidence).
return ProcessProbe.Unreadable; // WinMgmt can fail or hang during OOBE
}
}
private static ProcessProbe? ReadRow(int pid)
{
using var searcher = new ManagementObjectSearcher(
$"SELECT Name, SessionId, ParentProcessId, CommandLine, CreationDate FROM Win32_Process WHERE ProcessId = {pid}");
using var results = searcher.Get();
foreach (ManagementObject mo in results)
{
using (mo)
{
return new ProcessProbe(
mo["Name"]?.ToString(),
ToNullableInt(mo["SessionId"]),
ToNullableInt(mo["ParentProcessId"]),
mo["CommandLine"]?.ToString(),
mo["CreationDate"]?.ToString());
}
}
return null;
}
private static string? TryGetOwner(ManagementObject process)
/// <summary>What the process list says about an id and the image name expected under it.</summary>
internal enum ListedAs { NotRunning, OtherImage, Expected, Unknown }
/// <summary>Internal for tests.</summary>
internal static ListedAs ProcessListState(int pid, string processName)
{
try
{
var outParams = process.InvokeMethod("GetOwner", null, null);
if (outParams != null && Convert.ToInt32(outParams["ReturnValue"]) == 0)
{
var domain = outParams["Domain"]?.ToString();
var user = outParams["User"]?.ToString();
if (!string.IsNullOrEmpty(user))
return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}";
}
using var process = Process.GetProcessById(pid);
return string.Equals(process.ProcessName, Path.GetFileNameWithoutExtension(processName), StringComparison.OrdinalIgnoreCase)
? ListedAs.Expected
: ListedAs.OtherImage;
}
catch { /* best-effort enrichment */ }
return null;
catch (ArgumentException)
{
return ListedAs.NotRunning; // what GetProcessById throws for an id that does not run
}
catch (Exception)
{
return ListedAs.Unknown;
}
}
/// <summary>False only when the id certainly does not run; when that cannot be told, true.
/// Internal for tests.</summary>
internal static bool IsRunning(int pid)
{
try
{
using var process = Process.GetProcessById(pid);
return !process.HasExited;
}
catch (ArgumentException)
{
return false;
}
catch (Exception)
{
return true;
}
}
private static string? ImageFileName(int pid)
{
var path = ProcessImagePath.Query(pid);
return string.IsNullOrEmpty(path) ? null : Path.GetFileName(path);
}
private static string? TryReadSid(object? sidValue)
@@ -388,42 +537,77 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
catch { return 0; }
}
private static int? ToNullableInt(object? value)
{
if (value == null) return null;
try { return Convert.ToInt32(value); }
catch { return null; }
}
private void TearDownStartTrace(ManagementEventWatcher watcher)
{
try { watcher.EventArrived -= OnProcessStartTrace; } catch { }
try { watcher.Stop(); } catch { }
try { watcher.Dispose(); } catch { }
}
public void Dispose()
{
ManagementEventWatcher? startWatcher;
lock (_lock)
{
if (_disposed) return;
_disposed = true;
startWatcher = _startWatcher;
_startWatcher = null;
}
if (_startWatcher != null)
if (startWatcher != null)
{
try { _startWatcher.EventArrived -= OnProcessStartTrace; } catch { }
try { _startWatcher.Stop(); } catch { }
try { _startWatcher.Dispose(); } catch { }
_startWatcher = null;
// Cancelling a WMI subscription can take tens of seconds on a busy WinMgmt. The caller
// is the agent's stop path, so it never waits for it.
var armed = startWatcher;
Task.Run(() => TearDownStartTrace(armed));
}
}
}
/// <summary>Command line + owner for a probed PID; either may be null (best-effort).</summary>
/// <summary>
/// What was read about a running process: the facts that tell whether it is still the process
/// a start trace announced, and its command line. Every field is null when it could not be
/// read; null never means "no" or "zero".
/// </summary>
public sealed class ProcessProbe
{
public ProcessProbe(string? commandLine, string? owner)
/// <summary>
/// A process that runs but about which nothing could be read (the query failed or returned
/// no row). It goes on as the traced process without a command line, so it is reported.
/// </summary>
internal static readonly ProcessProbe Unreadable = new ProcessProbe(null, null, null, null, null);
public ProcessProbe(string? name, int? sessionId, int? parentProcessId, string? commandLine, string? creationDate)
{
Name = name;
SessionId = sessionId;
ParentProcessId = parentProcessId;
CommandLine = commandLine;
Owner = owner;
CreationDate = creationDate;
}
public string? Name { get; }
public int? SessionId { get; }
public int? ParentProcessId { get; }
public string? CommandLine { get; }
public string? Owner { get; }
/// <summary>The raw WMI creation time; only compared for equality.</summary>
public string? CreationDate { get; }
}
/// <summary>Immutable detail record for a detected interactive console spawn.</summary>
public sealed class ConsoleSpawnInfo
{
public ConsoleSpawnInfo(string processName, int processId, int parentProcessId, int sessionId,
string? owner, string? commandLine, string confidence, string classification, string detectedVia)
string? owner, string? commandLine, string confidence, string classification, string detectedVia,
string? parentProcessName)
{
ProcessName = processName;
ProcessId = processId;
@@ -434,17 +618,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
Confidence = confidence;
Classification = classification;
DetectedVia = detectedVia;
ParentProcessName = parentProcessName;
}
public string ProcessName { get; }
public int ProcessId { get; }
public int ParentProcessId { get; }
public int SessionId { get; }
/// <summary>Owner SID, or null when it could not be read.</summary>
public string? Owner { get; }
public string? CommandLine { get; }
/// <summary>"high" (bare interactive shell) or "low" (interactive session, command line unreadable).</summary>
/// <summary>"high" (bare interactive shell) or "low" (stays open after a command, or not classifiable).</summary>
public string Confidence { get; }
public string Classification { get; }
public string DetectedVia { get; }
/// <summary>
/// Image name of the parent while it still ran, else null. The launcher behind Shift+F10
/// exits within milliseconds, so null is the expected value for that case.
/// </summary>
public string? ParentProcessName { get; }
}
}
@@ -0,0 +1,182 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
{
/// <summary>
/// Counts console processes that were gone before they could be inspected — script children,
/// reported as one summary per window instead of one event each. Thread-safe. The content
/// leaves through <see cref="Take"/>, which hands out copies and leaves an empty tally behind,
/// so a start is never counted in two summaries.
/// </summary>
internal sealed class ConsoleScriptActivityTally
{
internal const int MaxOwnersListed = 5;
internal const int MaxParentsListed = 10;
internal const int MaxParentLookups = 10;
// A spawner can keep running for hours after the host has stopped opening windows. The
// count grows with it; what is remembered per start does not.
private const int MaxTrackedParents = 256;
private const int MaxTrackedProcessIds = 2048;
private const int MaxTrackedOwners = 64;
private sealed class Parent
{
public string? Name;
public int Children;
}
private readonly object _lock = new object();
private readonly SortedSet<int> _sessions = new SortedSet<int>();
private readonly List<string> _owners = new List<string>();
private readonly Dictionary<int, Parent> _parents = new Dictionary<int, Parent>();
private readonly HashSet<int> _processIds = new HashSet<int>();
private string _processName = string.Empty;
private int _count;
private int _parentLookups;
private DateTime _firstSeenUtc;
private DateTime _lastSeenUtc;
/// <summary>
/// Counts one start. <paramref name="parentName"/> is asked at most once per parent and at
/// most <see cref="MaxParentLookups"/> times per window, outside the lock; a parent that is
/// itself one of the counted processes is named without asking.
/// </summary>
/// <returns><c>true</c> when this start opened an empty tally.</returns>
public bool Add(
string processName, int processId, int parentProcessId, int sessionId, string? ownerSid,
DateTime seenUtc, Func<int, string?> parentName)
{
bool opened;
Parent? toResolve = null;
lock (_lock)
{
opened = _count == 0;
if (opened)
{
_processName = processName;
_firstSeenUtc = seenUtc;
}
_count++;
_lastSeenUtc = seenUtc;
_sessions.Add(sessionId);
if (!string.IsNullOrEmpty(ownerSid) && _owners.Count < MaxTrackedOwners && !_owners.Contains(ownerSid!))
_owners.Add(ownerSid!);
if (parentProcessId > 0)
{
if (_parents.TryGetValue(parentProcessId, out var parent))
{
parent.Children++;
}
else if (_parents.Count < MaxTrackedParents)
{
parent = new Parent { Children = 1 };
_parents[parentProcessId] = parent;
if (_processIds.Contains(parentProcessId))
{
parent.Name = processName;
}
else if (_parentLookups < MaxParentLookups)
{
_parentLookups++;
toResolve = parent;
}
}
}
if (_processIds.Count < MaxTrackedProcessIds) _processIds.Add(processId);
}
if (toResolve != null)
{
var name = parentName(parentProcessId);
lock (_lock) { toResolve.Name = name; }
}
return opened;
}
/// <summary>The counted content, or <c>null</c> when nothing was counted. Empties the tally.</summary>
public ConsoleScriptActivity? Take()
{
lock (_lock)
{
if (_count == 0) return null;
var parents = _parents
.Select(kv => new ConsoleScriptParent(kv.Key, kv.Value.Name, kv.Value.Children))
.OrderByDescending(p => p.Children)
.ThenBy(p => p.ProcessId)
.ToList();
var activity = new ConsoleScriptActivity(
_processName, _count, _firstSeenUtc, _lastSeenUtc,
_sessions.ToList(),
_owners.Take(MaxOwnersListed).ToList(), _owners.Count,
parents.Take(MaxParentsListed).ToList(), parents.Count);
_count = 0;
_parentLookups = 0;
_processName = string.Empty;
_sessions.Clear();
_owners.Clear();
_parents.Clear();
_processIds.Clear();
return activity;
}
}
}
/// <summary>One window of counted console processes. Lists are capped; the totals are not.</summary>
internal sealed class ConsoleScriptActivity
{
public ConsoleScriptActivity(
string processName, int count, DateTime firstSeenUtc, DateTime lastSeenUtc,
IReadOnlyList<int> windowsSessions,
IReadOnlyList<string> owners, int ownersTotal,
IReadOnlyList<ConsoleScriptParent> parents, int parentsTotal)
{
ProcessName = processName;
Count = count;
FirstSeenUtc = firstSeenUtc;
LastSeenUtc = lastSeenUtc;
WindowsSessions = windowsSessions;
Owners = owners;
OwnersTotal = ownersTotal;
Parents = parents;
ParentsTotal = parentsTotal;
}
public string ProcessName { get; }
public int Count { get; }
public DateTime FirstSeenUtc { get; }
public DateTime LastSeenUtc { get; }
public IReadOnlyList<int> WindowsSessions { get; }
public IReadOnlyList<string> Owners { get; }
public int OwnersTotal { get; }
/// <summary>Parents by number of children, most first.</summary>
public IReadOnlyList<ConsoleScriptParent> Parents { get; }
public int ParentsTotal { get; }
}
/// <summary>A parent process of counted console processes. Only its image name is kept, never its command line.</summary>
internal sealed class ConsoleScriptParent
{
public ConsoleScriptParent(int processId, string? name, int children)
{
ProcessId = processId;
Name = name;
Children = children;
}
public int ProcessId { get; }
/// <summary><c>null</c> when the parent had exited or was not looked up.</summary>
public string? Name { get; }
public int Children { get; }
}
}
@@ -1,6 +1,7 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
@@ -21,6 +22,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// only sees consoles spawned after the agent started; the pre-agent OOBE window is covered
/// (coarsely) by the ConsolePrefetchScanner. The composition root calls <see cref="Stop"/> once the
/// real-user desktop arrives (Shift+F10 no longer possible) to avoid post-enrollment false positives.
/// <para>
/// Short-lived console processes (script children) are not events of their own: the watcher
/// counts them and this host reports one Info <c>console_script_activity</c> per window. The
/// first counted start opens a window of <see cref="ScriptActivityWindow"/>; at most
/// <see cref="MaxScriptActivitySummaries"/> windows are reported per agent run, whatever comes
/// after goes into the one line written when the host stops.
/// </para>
/// </summary>
internal sealed class ConsoleBypassHost : ICollectorHost
{
@@ -28,6 +36,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
"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";
internal const int MaxScriptActivitySummaries = 20;
private static readonly TimeSpan ScriptActivityWindow = TimeSpan.FromSeconds(60);
public string Name => "ConsoleBypassWatcher";
private readonly string _sessionId;
@@ -36,7 +47,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly AgentLogger _logger;
private readonly ConsoleBypassWatcher _watcher;
private readonly SignalIngress? _observableIngress;
private readonly object _summaryLock = new object();
private Action<AutopilotMonitor.DecisionCore.Signals.DecisionSignalKind, IReadOnlyDictionary<string, string>?>? _signalPostedHandler;
private Timer? _windowTimer;
private int _summariesEmitted;
private int _disposed;
public ConsoleBypassHost(
@@ -55,11 +69,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_post = new InformationalEventPost(ingress, clock, logger);
_watcher = watcher ?? new ConsoleBypassWatcher(logger);
_watcher.BypassConsoleDetected += OnBypassConsoleDetected;
_watcher.ScriptActivityStarted += OnScriptActivityStarted;
_watcher.WatcherArmFailed += OnWatcherArmFailed;
_observableIngress = ingress as SignalIngress;
}
public void Start()
public void Start() => StartCore(armWatcher: true);
/// <summary>Internal for tests: <paramref name="armWatcher"/> = false leaves the WMI
/// subscription and the startup probe out; everything else is the real start.</summary>
internal void StartCore(bool armWatcher)
{
// L14 fallback: the primary stop path is the DesktopArrivalDetector's real-user-owner
// callback (StopForDesktopArrival). If the DAD misses the owner (no-candidate timeout,
@@ -73,7 +92,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_observableIngress.SignalPosted += _signalPostedHandler;
}
_watcher.Start();
if (armWatcher) _watcher.Start();
}
private void OnSignalPosted(AutopilotMonitor.DecisionCore.Signals.DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
@@ -117,6 +136,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
Phase = EnrollmentPhase.Unknown,
Message = $"Interactive console spawned during enrollment: {info.ProcessName} " +
$"(session {info.SessionId}, {info.Confidence} confidence) — possible Shift+F10",
// At once, whatever the confidence: the confidence follows the command line,
// and that is chosen by whoever opens the console.
ImmediateUpload = true,
Data = new Dictionary<string, object>
{
@@ -124,6 +145,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{ "processName", info.ProcessName },
{ "processId", info.ProcessId },
{ "parentProcessId", info.ParentProcessId },
{ "parentProcessName", info.ParentProcessName ?? "unknown" },
{ "sessionId", info.SessionId },
{ "owner", info.Owner ?? "unknown" },
{ "commandLine", info.CommandLine ?? "unavailable" },
@@ -141,6 +163,132 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
}
private void OnScriptActivityStarted(object? sender, EventArgs e)
{
lock (_summaryLock)
{
// Checked under the lock Dispose takes for the timer, so no timer is created behind it.
if (Interlocked.CompareExchange(ref _disposed, 0, 0) == 1) return;
// Past the cap no window opens any more; the watcher keeps counting for the stop line.
if (_summariesEmitted >= MaxScriptActivitySummaries) return;
try
{
if (_windowTimer == null)
_windowTimer = new Timer(_ => WindowElapsed(), null, ScriptActivityWindow, Timeout.InfiniteTimeSpan);
else
_windowTimer.Change(ScriptActivityWindow, Timeout.InfiniteTimeSpan);
}
catch (ObjectDisposedException)
{
// stopping
}
}
}
/// <summary>Internal for tests: what the window timer runs when it elapses.</summary>
internal void WindowElapsedForTest() => WindowElapsed();
private void WindowElapsed()
{
if (Interlocked.CompareExchange(ref _disposed, 0, 0) == 1) return;
ReportScriptActivity(stopping: false);
}
private void ReportScriptActivity(bool stopping)
{
ConsoleScriptActivity? activity;
bool capped;
lock (_summaryLock)
{
capped = _summariesEmitted >= MaxScriptActivitySummaries;
if (capped && !stopping) return;
activity = _watcher.TakeScriptActivity();
if (activity == null) return;
_summariesEmitted++;
}
// Outside the lock: a post blocks while the ingress is full.
try
{
var data = new Dictionary<string, object>
{
{ "count", activity.Count },
{ "processName", activity.ProcessName },
{ "firstSeenUtc", activity.FirstSeenUtc.ToString("o") },
{ "lastSeenUtc", activity.LastSeenUtc.ToString("o") },
{ "windowsSessions", activity.WindowsSessions.ToList() },
{ "owners", activity.Owners.ToList() },
{ "ownersTotal", activity.OwnersTotal },
{
"parents", activity.Parents.Select(p => new Dictionary<string, object>
{
{ "pid", p.ProcessId },
{ "name", p.Name ?? "unknown" },
{ "children", p.Children },
}).ToList()
},
{ "parentsTotal", activity.ParentsTotal },
};
if (capped) data["capped"] = true;
_post.Emit(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = Constants.EventTypes.ConsoleScriptActivity,
Severity = EventSeverity.Info,
Source = Name,
Phase = EnrollmentPhase.Unknown,
Timestamp = activity.LastSeenUtc,
Message = DescribeScriptActivity(activity),
ImmediateUpload = false,
Data = data,
});
_logger.Debug($"[ConsoleBypassHost] script activity reported: {activity.Count} short-lived {activity.ProcessName} " +
$"from {activity.ParentsTotal} parent(s){(capped ? " (summary cap reached)" : string.Empty)}");
}
catch (Exception ex)
{
_logger.Warning($"[ConsoleBypassHost] failed to emit console_script_activity: {ex.Message}");
}
}
/// <summary>The timeline text of one summary (internal for tests).</summary>
internal static string DescribeScriptActivity(ConsoleScriptActivity activity)
{
var processes = activity.Count == 1
? $"1 short-lived {activity.ProcessName} process"
: $"{activity.Count} short-lived {activity.ProcessName} processes";
var sessions = activity.WindowsSessions.Count == 1
? $"session {activity.WindowsSessions[0]}"
: $"sessions {string.Join(", ", activity.WindowsSessions)}";
string startedBy;
if (activity.ParentsTotal == 1 && activity.Parents.Count == 1)
{
startedBy = activity.Parents[0].Name ?? "an unknown program";
}
else
{
var names = activity.Parents
.Select(p => p.Name)
.Where(n => !string.IsNullOrEmpty(n))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var shown = names.Take(3).ToList();
var more = names.Count > shown.Count || activity.Parents.Any(p => p.Name == null) || activity.ParentsTotal > activity.Parents.Count;
startedBy = $"{activity.ParentsTotal} parent processes";
if (shown.Count > 0)
startedBy += $" ({string.Join(", ", shown)}{(more ? ", …" : string.Empty)})";
}
return $"Script activity: {processes} in {sessions}, started by {startedBy} — counted, not an interactive console";
}
// The WMI live watcher could not arm — surface it as collector_degraded so the backend can
// tell "no console detected" from "live detector never started" (MON-D1 pattern).
private void OnWatcherArmFailed(object? sender, Exception ex)
@@ -151,12 +299,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
// What was counted goes out first. It has to happen here, in the host's stop: the
// handlers below go away, and once the orchestrator has stopped the ingress a post throws.
ReportScriptActivity(stopping: true);
if (_observableIngress != null && _signalPostedHandler != null)
{
try { _observableIngress.SignalPosted -= _signalPostedHandler; } catch { }
}
try { _watcher.BypassConsoleDetected -= OnBypassConsoleDetected; } catch { }
try { _watcher.ScriptActivityStarted -= OnScriptActivityStarted; } catch { }
try { _watcher.WatcherArmFailed -= OnWatcherArmFailed; } catch { }
lock (_summaryLock)
{
try { _windowTimer?.Dispose(); } catch { }
_windowTimer = null;
}
try { _watcher.Dispose(); } catch { }
}
@@ -101,6 +101,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// The OOBE update registry snapshot (D-310) is observation at agent start/stop and at
// the update page — the page's own records (oobe_update_page) stay activity.
SharedConstants.EventTypes.OobeUpdateState,
// The count of short-lived console processes: a script that keeps spawning them for
// hours would otherwise hold the periodic collectors awake and reset the stall clock.
// The work those scripts do arrives as its own app, script or phase event.
SharedConstants.EventTypes.ConsoleScriptActivity,
};
/// <summary>
@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The agent counts short-lived console processes and reports them as one console_script_activity
/// per window. A script that keeps spawning them is not enrollment progress: the count never heals
/// a Stalled session. A console the agent reports as such stays real activity.
/// </summary>
public class ConsoleScriptActivityClassificationTests
{
private static readonly DateTime At = new(2026, 11, 11, 14, 41, 35, DateTimeKind.Utc);
[Fact]
public void A_count_of_script_processes_alone_is_no_real_activity()
{
var classification = EventIngestProcessor.ClassifyEvents(new List<EnrollmentEvent>
{
Event(Constants.EventTypes.ConsoleScriptActivity),
});
Assert.False(classification.HasNonPeriodicRealEvent);
}
[Fact]
public void A_reported_console_is_real_activity()
{
var classification = EventIngestProcessor.ClassifyEvents(new List<EnrollmentEvent>
{
Event(Constants.EventTypes.ConsoleScriptActivity),
Event(Constants.EventTypes.OobeConsoleSpawned),
});
Assert.True(classification.HasNonPeriodicRealEvent);
}
private static EnrollmentEvent Event(string eventType) => new()
{
EventId = Guid.NewGuid().ToString(),
Timestamp = At,
EventType = eventType,
Source = "ConsoleBypassWatcher",
Data = new Dictionary<string, object> { ["count"] = 3 },
};
}
@@ -483,6 +483,9 @@ namespace AutopilotMonitor.Functions.Services
// A platform run corrected after the fact from IME's saved result (D-316): registry
// observation of an earlier run, not progress — a stalled session stays Stalled.
Constants.EventTypes.ScriptOutputReconciliation => true,
// The agent's count of short-lived console processes: a script that keeps spawning
// them is not enrollment progress — a stalled session stays Stalled.
Constants.EventTypes.ConsoleScriptActivity => true,
_ => false
};
@@ -222,6 +222,7 @@ export const EVENT_TYPES_CATALOG = {
'autologon_analysis',
'oobe_console_spawned',
'console_prefetch_detected',
'console_script_activity',
'security_warning',
'security_audit',
'provisioning_package_scan',
@@ -1014,22 +1014,30 @@ namespace AutopilotMonitor.Shared
// 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: an interactive-session cmd.exe (SessionID != 0) with a
// bare, non-scripted command line (no /c|/k) — an interactive
// console a human can type into. confidence=high; an instant-close
// cmd whose command line was unreadable is confidence=low. Stopped
// on real-user desktop arrival (Shift+F10 gone). NOT parent-based:
// the Shift+F10 cmd's parent is a short-lived non-winlogon launcher
// (field 24H2). Misses presses BEFORE the agent installed.
// EnableConsoleBypassDetection, default ON). Two complementary Warning signals and one count:
// oobe_console_spawned — LIVE: an interactive-session cmd.exe (SessionID != 0) that is
// still running when probed, with a bare, non-scripted command
// line (no /c|/k) — an interactive console a human can type
// into. confidence=high; one that stays open after its command
// (/k), or whose command line WMI does not return, is
// confidence=low. Stopped on real-user desktop arrival (Shift+F10
// gone). NOT parent-based: the Shift+F10 cmd's parent is a
// short-lived non-winlogon launcher (field 24H2), so
// parentProcessName is "unknown" there. 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.
// console_script_activity — Info: interactive-session cmd.exe that had already exited when
// probed (script children), counted per 60 s window instead of
// listed: count, Windows sessions, owner SIDs and the parents
// with their image name (never a command line). Not a console.
public const string OobeConsoleSpawned = "oobe_console_spawned";
public const string ConsolePrefetchDetected = "console_prefetch_detected";
public const string ConsoleScriptActivity = "console_script_activity";
// Termination / diagnostics / server actions
public const string EnrollmentSummaryShown = "enrollment_summary_shown";
@@ -193,7 +193,9 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [
{ 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 an interactive console opened during enrollment — the Shift+F10 OOBE bypass. Flags an interactive-session cmd.exe (SessionID != 0) with a bare, non-scripted command line (no /c or /k); confidence=high, or confidence=low when the command line was unreadable (instant-close). Warning severity. On by default, opt-out per tenant (EnableConsoleBypassDetection). Stopped once the real-user desktop arrives (Shift+F10 no longer possible). Best-effort, not parent-based: only consoles spawned after the agent started are observable (coverageComplete=false)." },
description: "LIVE detection of an interactive console opened during enrollment — the Shift+F10 OOBE bypass. Flags an interactive-session cmd.exe (SessionID != 0) that is still running when probed, with a bare, non-scripted command line (no /c or /k): confidence=high. A cmd /k that stays open after its command, or a running cmd whose command line cannot be read, is confidence=low. Short-lived cmd.exe (script children) are counted in console_script_activity instead. Warning severity. On by default, opt-out per tenant (EnableConsoleBypassDetection). Stopped once the real-user desktop arrives (Shift+F10 no longer possible). Best-effort, not parent-based: only consoles spawned after the agent started are observable (coverageComplete=false)." },
{ value: "console_script_activity", label: "console_script_activity", category: "diagnostics",
description: "Short-lived cmd.exe processes in an interactive session that had already exited when probed — script activity, not a console. Counted per 60-second window instead of listed: count, Windows sessions, owner SIDs and the parent processes with their image name (never a command line). Info severity. Part of the OOBE console detection (EnableConsoleBypassDetection)." },
{ 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)." },
];
@@ -1081,6 +1081,7 @@ export const SHARED_MANIFEST = {
"completion_waiting",
"configmgr_client_detected",
"console_prefetch_detected",
"console_script_activity",
"decision_process_completion",
"desktop_arrived",
"desktop_detector_first_poll",
@@ -1077,6 +1077,7 @@
"completion_waiting",
"configmgr_client_detected",
"console_prefetch_detected",
"console_script_activity",
"decision_process_completion",
"desktop_arrived",
"desktop_detector_first_poll",