Compare commits

...

19 Commits

Author SHA1 Message Date
Oliver Kieselbach 456b8f6025 bump pointer: internal (D-438 released: web and MCP 1.6.600 on c3faece)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:12:50 +01:00
Oliver Kieselbach c3faece6d8 bump pointer: internal, .claude/commands (D-438 built: vulnerability visibility)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:55:54 +01:00
Oliver Kieselbach f0fee75b08 Web: Vulnerabilities card on the dashboard, count on the Software tab, analyzer-off notices
- Dashboard: a sixth stat card shows the known exploited CVEs (CISA KEV) of the last
  30 days for the one tenant in view and opens Software > Vulnerabilities; it reads Off
  while the Software Inventory & Vulnerability Analyzer is off. All stat cards share one
  layout (icon in the title row, tighter padding); six cards sit in one row from a row
  width of 1126 px, else 3 + 3 or 2 + 2 + 2, as container queries in globals.css.
- Software hub: a red known-exploited count on the Vulnerabilities tab. With the analyzer
  off, the Vulnerabilities and Inventory tabs say so instead of an empty list; users who
  can save the setting get a link that opens it with the switch highlighted.
- A session's Vulnerability Report and an app's detail page link to the tab; the hub takes
  ?tenantId= for a platform admin, like the app detail page.
- Telemetry: vuln_card_shown, vuln_link_clicked, vuln_enable_clicked,
  software_tab_selected, vuln_exposure_viewed, vuln_exposure_action and
  session_vuln_report_action. No CVE ids, session ids or product names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:55:06 +01:00
Oliver Kieselbach ac381a39ad bump pointer: internal (D-437 released: backend 1.5.1333, web on a1936b7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:54:30 +01:00
Oliver Kieselbach a1936b7cb6 bump pointer: internal, .claude/commands (D-437 built: Admin Mode by role, session writes in the session's tenant)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:37:24 +01:00
Oliver Kieselbach ba53b82c00 Web: Admin Mode takes effect only for tenant Admins and Global Admins
The stored Admin Mode flag is shared by every account in a browser. It now
takes effect, and its toggle shows, only for a tenant Admin or a Global
Admin (canUseAdminMode); the stored value stays for the account that set it.
Dashboard rows offer delete and the Select checkbox only where the backend
admits the caller for the row's tenant (canAdministerTenant), the
blocked-device read runs only in the platform view, and a failed
Succeed/Fail shows an error notification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:35:30 +01:00
Oliver Kieselbach 7f53dcf223 Backend: Succeed, Fail and session actions act in the session's tenant
mark-failed, mark-succeeded and actions on sessions/{sessionId} take the
tenant from ?tenantId= (TenantScoping.QueryParam) and resolve it like the
other session write routes (ResolveSessionScopeAsync with
requireGlobalAdmin). Before, they used the caller's token tenant, so a
Global Admin on another tenant's session got 404. A test pins that every
sessions/{sessionId} route is query-scoped; the report keeps the tenant
from its body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:35:21 +01:00
Oliver Kieselbach cd2ea05878 Web: "/" jumps to the event timeline search, Esc clears it
On the session page "/" focuses the timeline search from anywhere: the
controls row scrolls up below the navbar, so the matches fill the screen,
and a previous query is selected for overtyping. It is the web's usual
key for a page's own search and the same on Windows and Mac; Shift stays
allowed, since layouts like German type "/" as Shift+7. A "/" typed into
any field still belongs to that field. An empty box shows a "/" hint.

Esc in the box clears the query and keeps the cursor there for the next
term; Esc on an empty box leaves it.

The "is the user typing in a field" check moves to lib/keyboardTarget.ts
and is shared with the dashboard's Esc handler for select mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:08:34 +01:00
Oliver Kieselbach ccdd8c17ce bump pointer: internal (D-436 released: agent 2.0.1505, backend 1.5.1332, web and MCP 1.6.598 on e619183)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 22:27:39 +01:00
Oliver Kieselbach e6191831bd Rules: remove the AutoLogon allow-list template ANALYZE-SEC-004
The ID stays retired; a test pins that it does not come back and that no rule
matches on the AutoLogon user name.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 22:05:18 +01:00
Oliver Kieselbach ccea1dacfe Agent: leaner payloads for the join-status and AutoLogon events
The domain helper moves next to the join-info reader and is shared by its callers.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 21:47:14 +01:00
Oliver Kieselbach 33e614da5d bump pointer: internal (D-435 released: backend 1.5.1331, agent 2.0.1504, web and MCP 1.6.597 on a143314)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 20:11:32 +01:00
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
71 changed files with 3196 additions and 650 deletions
-94
View File
@@ -1,94 +0,0 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-SEC-004",
"title": "AutoLogon enabled for an unexpected user",
"description": "Optional kiosk allow-list for AutoLogon. Fires when AutoLogon is enabled for a default user that is NOT on your configured list of approved kiosk / shared-device accounts. Disabled until configured because a normal Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (AutoLogon enabled, a default user, no plaintext password) — so AutoLogon-enabled alone is not graded by default; only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) is. Enable this rule with your expected kiosk account name(s) if you want to surface AutoLogon configured for any other user.",
"severity": "warning",
"category": "security",
"version": "1.2.0",
"enabled": false,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 80,
"conditions": [
{
"signal": "autologon_enabled",
"source": "event_type",
"eventType": "autologon_analysis",
"dataField": "checks.autologon_enabled",
"operator": "equals",
"value": "true",
"required": true
},
{
"signal": "unexpected_autologon_user",
"source": "event_data",
"eventType": "autologon_analysis",
"dataField": "checks.default_user_name",
"operator": "not_in",
"value": "EXPECTED-KIOSK-USER-1,EXPECTED-KIOSK-USER-2",
"required": true
}
],
"confidenceThreshold": 40,
"explanation": "AutoLogon is enabled on this device for a user that is **not on your approved kiosk allow-list**. AutoLogon is legitimate for kiosk and shared-device scenarios, so this rule only warns when the configured default user differs from the account(s) you expect.\n\nThis rule is **off by default**. A normal user-driven Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (`AutoAdminLogon=1`, a `DefaultUserName`, an `AutoLogonSID`, and no plaintext password) — registry-indistinguishable from a kiosk AutoLogon — so AutoLogon-enabled alone is not graded. The only AutoLogon state graded by default is a plaintext `DefaultPassword` on disk (**ANALYZE-SEC-003**).\n\nTo use this rule: configure the allow-list with your approved kiosk account name(s) (comma-separated, case-insensitive exact match against the Winlogon `DefaultUserName`) and enable it. AutoLogon for any other user will then warn.\n\nNote: an AutoLogon with no explicit default user (e.g. ForceAutoLogon only) is treated as unexpected and will warn.",
"remediation": [
{
"title": "Confirm the AutoLogon user",
"steps": [
"Open the autologon_analysis event in the session timeline and check the default user/domain",
"If the user is an approved kiosk account, add it to this rule's allow-list",
"If the user is unexpected, treat it as a possible enrollment/OOBE manipulation and investigate what wrote the Winlogon keys"
]
},
{
"title": "Disable AutoLogon if not required",
"steps": [
"Set HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon\\AutoAdminLogon to 0",
"Remove DefaultPassword (and any Sysinternals LSA secret) if present",
"For kiosk scenarios, prefer Assigned Access / kiosk profiles over raw AutoLogon where possible"
]
}
],
"relatedDocs": [
{
"title": "Microsoft: Configure Windows to automatically sign in (AutoLogon)",
"url": "https://learn.microsoft.com/en-us/troubleshoot/windows-server/user-profiles-and-logon/turn-on-automatic-logon"
}
],
"presentation": {
"role": "advisory",
"summary": "AutoLogon is enabled for an unexpected user: {{unexpected_autologon_user.value}}",
"subject": {
"kind": "account",
"key": "{{unexpected_autologon_user.value}}"
},
"facts": [
{
"label": "AutoLogon user",
"value": "{{unexpected_autologon_user.value}}",
"format": "code"
}
]
},
"tags": [
"security",
"autologon",
"winlogon",
"kiosk",
"allow-list"
],
"templateVariables": [
{
"name": "allowed_autologon_users",
"label": "Allowed AutoLogon users (kiosk accounts)",
"description": "Comma-separated list of approved kiosk / shared-device account names that are expected to use AutoLogon (case-insensitive exact match against the Winlogon DefaultUserName, e.g. 'KioskUser' or 'Reception,LobbyPC'). AutoLogon for any other user raises a warning.",
"conditionIndex": 1,
"field": "value",
"placeholder": "EXPECTED-KIOSK-USER-1,EXPECTED-KIOSK-USER-2"
}
]
}
-93
View File
@@ -4926,99 +4926,6 @@
"plaintext-password"
]
},
{
"ruleId": "ANALYZE-SEC-004",
"title": "AutoLogon enabled for an unexpected user",
"description": "Optional kiosk allow-list for AutoLogon. Fires when AutoLogon is enabled for a default user that is NOT on your configured list of approved kiosk / shared-device accounts. Disabled until configured because a normal Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (AutoLogon enabled, a default user, no plaintext password) — so AutoLogon-enabled alone is not graded by default; only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) is. Enable this rule with your expected kiosk account name(s) if you want to surface AutoLogon configured for any other user.",
"severity": "warning",
"category": "security",
"version": "1.2.0",
"enabled": false,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 80,
"conditions": [
{
"signal": "autologon_enabled",
"source": "event_type",
"eventType": "autologon_analysis",
"dataField": "checks.autologon_enabled",
"operator": "equals",
"value": "true",
"required": true
},
{
"signal": "unexpected_autologon_user",
"source": "event_data",
"eventType": "autologon_analysis",
"dataField": "checks.default_user_name",
"operator": "not_in",
"value": "EXPECTED-KIOSK-USER-1,EXPECTED-KIOSK-USER-2",
"required": true
}
],
"confidenceThreshold": 40,
"explanation": "AutoLogon is enabled on this device for a user that is **not on your approved kiosk allow-list**. AutoLogon is legitimate for kiosk and shared-device scenarios, so this rule only warns when the configured default user differs from the account(s) you expect.\n\nThis rule is **off by default**. A normal user-driven Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (`AutoAdminLogon=1`, a `DefaultUserName`, an `AutoLogonSID`, and no plaintext password) — registry-indistinguishable from a kiosk AutoLogon — so AutoLogon-enabled alone is not graded. The only AutoLogon state graded by default is a plaintext `DefaultPassword` on disk (**ANALYZE-SEC-003**).\n\nTo use this rule: configure the allow-list with your approved kiosk account name(s) (comma-separated, case-insensitive exact match against the Winlogon `DefaultUserName`) and enable it. AutoLogon for any other user will then warn.\n\nNote: an AutoLogon with no explicit default user (e.g. ForceAutoLogon only) is treated as unexpected and will warn.",
"remediation": [
{
"title": "Confirm the AutoLogon user",
"steps": [
"Open the autologon_analysis event in the session timeline and check the default user/domain",
"If the user is an approved kiosk account, add it to this rule's allow-list",
"If the user is unexpected, treat it as a possible enrollment/OOBE manipulation and investigate what wrote the Winlogon keys"
]
},
{
"title": "Disable AutoLogon if not required",
"steps": [
"Set HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon\\AutoAdminLogon to 0",
"Remove DefaultPassword (and any Sysinternals LSA secret) if present",
"For kiosk scenarios, prefer Assigned Access / kiosk profiles over raw AutoLogon where possible"
]
}
],
"relatedDocs": [
{
"title": "Microsoft: Configure Windows to automatically sign in (AutoLogon)",
"url": "https://learn.microsoft.com/en-us/troubleshoot/windows-server/user-profiles-and-logon/turn-on-automatic-logon"
}
],
"presentation": {
"role": "advisory",
"summary": "AutoLogon is enabled for an unexpected user: {{unexpected_autologon_user.value}}",
"subject": {
"kind": "account",
"key": "{{unexpected_autologon_user.value}}"
},
"facts": [
{
"label": "AutoLogon user",
"value": "{{unexpected_autologon_user.value}}",
"format": "code"
}
]
},
"tags": [
"security",
"autologon",
"winlogon",
"kiosk",
"allow-list"
],
"templateVariables": [
{
"name": "allowed_autologon_users",
"label": "Allowed AutoLogon users (kiosk accounts)",
"description": "Comma-separated list of approved kiosk / shared-device account names that are expected to use AutoLogon (case-insensitive exact match against the Winlogon DefaultUserName, e.g. 'KioskUser' or 'Reception,LobbyPC'). AutoLogon for any other user raises a warning.",
"conditionIndex": 1,
"field": "value",
"placeholder": "EXPECTED-KIOSK-USER-1,EXPECTED-KIOSK-USER-2"
}
]
},
{
"ruleId": "ANALYZE-SEC-005",
"title": "Unexpected Provisioning Package",
@@ -135,6 +135,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
Assert.Equal("CONTOSO", checks["default_domain_name"]);
}
[Fact]
public void BuildPayload_reports_a_sign_in_address_as_its_domain_only()
{
var snap = new AutoLogonSnapshot
{
WinlogonKeyPresent = true,
AutoAdminLogon = "1",
DefaultUserName = "luke.skywalker@example.net",
AltDefaultUserName = @"AzureAD\luke.skywalker@example.net",
};
var data = AutoLogonAnalyzer.BuildPayload(snap, "shutdown");
var checks = Checks(data);
Assert.Equal("[redacted]@example.net", checks["default_user_name"]);
Assert.Equal("[redacted]@example.net", checks["alt_default_user_name"]);
Assert.DoesNotContain(checks.Values.OfType<string>(), v => v.Contains("skywalker"));
// The reduction is presentation only — the identity is still reported as present.
Assert.True((bool)checks["default_identity_present"]);
}
[Theory]
[InlineData("kioskuser", "kioskuser")]
[InlineData("defaultuser0", "defaultuser0")]
[InlineData("foouser@example.net", "foouser@example.net")]
[InlineData("autopilot@example.net", "autopilot@example.net")]
[InlineData("luke.skywalker@example.net", "[redacted]@example.net")]
[InlineData("luke@skywalker@example.net", "[redacted]@example.net")]
[InlineData("luke.skywalker@", "[redacted]@unknown")]
[InlineData("", "")]
[InlineData(null, "")]
public void ReduceUserName_keeps_local_accounts_and_placeholders_readable(string? raw, string expected)
{
Assert.Equal(expected, AutoLogonAnalyzer.ReduceUserName(raw));
}
[Theory]
[InlineData("1", true)]
[InlineData("true", true)]
@@ -45,7 +45,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.DeviceInfo
{
using var rig = new Rig();
var notJoined = new Dictionary<string, object> { { "joinType", "Not Joined" } };
var joined = new Dictionary<string, object> { { "joinType", "Azure AD Joined" }, { "userEmail", "alice@contoso.com" } };
var joined = DeviceInfoCollector.BuildAadJoinedData("T1", "alice@contoso.com", "AABB", isPlaceholderUser: false);
Assert.True(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, notJoined));
Assert.False(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, notJoined));
@@ -56,6 +56,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.DeviceInfo
Assert.False(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, joined));
}
[Fact]
public void Placeholder_replaced_by_a_user_of_the_same_domain_reemits()
{
// Pre-provisioning hands the device from the placeholder account to the real user
// in the same domain — the two payloads must still differ for the gate.
using var rig = new Rig();
var placeholder = DeviceInfoCollector.BuildAadJoinedData("T1", "foouser@contoso.com", "AABB", isPlaceholderUser: true);
var realUser = DeviceInfoCollector.BuildAadJoinedData("T1", "alice@contoso.com", "AABB", isPlaceholderUser: false);
Assert.True(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, placeholder));
Assert.True(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, realUser));
}
[Fact]
public void Boot_time_and_wifi_signal_are_exempt_from_the_gate()
{
@@ -114,6 +127,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.DeviceInfo
}
}
/// <summary>Payload of <c>aad_join_status</c> for a joined device.</summary>
public sealed class AadJoinStatusPayloadTests
{
[Fact]
public void Joined_payload_carries_the_domain_of_the_signed_in_address_only()
{
var data = DeviceInfoCollector.BuildAadJoinedData("T1", "alice@contoso.com", "AABB", isPlaceholderUser: false);
Assert.Equal("Azure AD Joined", data["joinType"]);
Assert.Equal("T1", data["tenantId"]);
Assert.Equal("AABB", data["thumbprint"]);
Assert.Equal("contoso.com", data["userDomain"]);
Assert.False(data.ContainsKey("isFooUser"));
Assert.DoesNotContain(data.Values, v => v is string s && s.Contains("alice"));
}
[Fact]
public void Joined_payload_flags_the_placeholder_and_omits_the_domain_without_an_address()
{
var placeholder = DeviceInfoCollector.BuildAadJoinedData("T1", "foouser@contoso.com", "AABB", isPlaceholderUser: true);
Assert.Equal(true, placeholder["isFooUser"]);
Assert.Equal("contoso.com", placeholder["userDomain"]);
var deviceOnly = DeviceInfoCollector.BuildAadJoinedData("T1", null!, "AABB", isPlaceholderUser: false);
Assert.False(deviceOnly.ContainsKey("userDomain"));
Assert.False(deviceOnly.ContainsKey("isFooUser"));
}
}
/// <summary>Inventory fingerprint used by the SoftwareInventoryAnalyzer's restart dedup.</summary>
public sealed class SoftwareInventoryFingerprintTests
{
@@ -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);
}
}
}
}
@@ -80,5 +80,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
return email.StartsWith("foouser@", StringComparison.OrdinalIgnoreCase)
|| email.StartsWith("autopilot@", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// The domain part of a sign-in address, or "unknown" when there is none. Telemetry
/// carries this instead of the address: the part before the last '@' stays on the device.
/// </summary>
internal static string ExtractUserDomain(string email)
{
if (string.IsNullOrEmpty(email)) return "unknown";
var at = email.LastIndexOf('@');
return at >= 0 && at + 1 < email.Length ? email.Substring(at + 1) : "unknown";
}
}
}
@@ -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;
}
}
}
@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
@@ -31,7 +32,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
/// This analyzer reports facts ONLY, always at <see cref="EventSeverity.Info"/>. It never reads
/// or emits the <c>DefaultPassword</c> value — only its presence. Whether an AutoLogon is a
/// problem is judged by backend analyze-rules (ANALYZE-SEC-003 = plaintext DefaultPassword on
/// disk → escalated; ANALYZE-SEC-004 = optional kiosk allow-list template).
/// disk → escalated).
/// </para>
/// <para>
/// <b>Trigger</b>: NOT at agent start — the Winlogon keys are written later by a provisioning
@@ -218,9 +219,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
{ "force_auto_logon", snap.ForceAutoLogon ?? string.Empty },
{ "force_auto_logon_enabled", forceAutoLogonEnabled },
{ "autologon_enabled", autologonEnabled },
{ "default_user_name", snap.DefaultUserName ?? string.Empty },
{ "default_user_name", ReduceUserName(snap.DefaultUserName) },
{ "default_domain_name", snap.DefaultDomainName ?? string.Empty },
{ "alt_default_user_name", snap.AltDefaultUserName ?? string.Empty },
{ "alt_default_user_name", ReduceUserName(snap.AltDefaultUserName) },
{ "alt_default_domain_name", snap.AltDefaultDomainName ?? string.Empty },
{ "auto_logon_sid", snap.AutoLogonSid ?? string.Empty },
{ "auto_logon_count", snap.AutoLogonCount.HasValue ? (object)snap.AutoLogonCount.Value : string.Empty },
@@ -244,6 +245,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
// Small fail-soft helpers.
// -----------------------------------------------------------------------
/// <summary>
/// A sign-in address is reported as its domain only. Local account names (kiosk,
/// defaultuser0) and the provisioning placeholders stay readable.
/// </summary>
internal static string ReduceUserName(string? userName)
{
if (userName is not string name || name.Length == 0) return string.Empty;
if (name.IndexOf('@') < 0 || AadJoinInfo.IsPlaceholderUserEmail(name)) return name;
return "[redacted]@" + AadJoinInfo.ExtractUserDomain(name);
}
/// <summary>AutoLogon flags are REG_SZ "0"/"1"; treat "1"/"true" (any case) as enabled.</summary>
private static bool IsTruthy(string? value)
{
@@ -640,53 +640,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
{
try
{
var data = new Dictionary<string, object>();
Dictionary<string, object> data;
// Read AAD join info via shared helper. Placeholder users (foouser@, autopilot@)
// are transient provisioning accounts and must NOT be treated as a real AAD join;
// HasAadJoinedUser is only set for real user emails.
if (AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser))
if (AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser)
&& !string.IsNullOrEmpty(thumbprint))
{
// Still need TenantId — open the subkey directly when we have a thumbprint.
if (!string.IsNullOrEmpty(thumbprint))
// Still need TenantId — open the subkey directly.
string tenantId;
using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath))
using (var subKey = joinInfoKey?.OpenSubKey(thumbprint))
{
using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath))
using (var subKey = joinInfoKey?.OpenSubKey(thumbprint))
{
data["tenantId"] = subKey?.GetValue("TenantId")?.ToString();
}
data["userEmail"] = userEmail;
data["joinType"] = "Azure AD Joined";
data["thumbprint"] = thumbprint;
if (!string.IsNullOrWhiteSpace(userEmail))
{
if (isPlaceholderUser)
{
IsFooUserDetected = true;
data["isFooUser"] = true;
_logger.Info($"AAD join: placeholder user detected ({userEmail}) — pre-provisioning indicator, NOT a real AAD join");
}
else
{
HasAadJoinedUser = true;
}
}
tenantId = subKey?.GetValue("TenantId")?.ToString();
}
else
data = BuildAadJoinedData(tenantId, userEmail, thumbprint, isPlaceholderUser);
if (!string.IsNullOrWhiteSpace(userEmail))
{
data["joinType"] = "Not Joined";
if (isPlaceholderUser)
{
IsFooUserDetected = true;
_logger.Info($"AAD join: placeholder user detected ({userEmail}) — pre-provisioning indicator, NOT a real AAD join");
}
else
{
HasAadJoinedUser = true;
}
}
}
else
{
data["joinType"] = "Not Joined";
data = new Dictionary<string, object> { ["joinType"] = "Not Joined" };
}
object joinTypeValue;
var joinType = data.TryGetValue("joinType", out joinTypeValue) ? joinTypeValue?.ToString() ?? "Unknown" : "Unknown";
EmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, $"AAD join: {joinType}", data);
EmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, $"AAD join: {data["joinType"]}", data);
}
catch (Exception ex)
{
@@ -694,6 +684,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
}
}
/// <summary>
/// The <c>aad_join_status</c> payload of a joined device. The signed-in address is
/// reported as its domain only.
/// </summary>
internal static Dictionary<string, object> BuildAadJoinedData(
string tenantId, string userEmail, string thumbprint, bool isPlaceholderUser)
{
var data = new Dictionary<string, object>
{
["tenantId"] = tenantId,
["joinType"] = "Azure AD Joined",
["thumbprint"] = thumbprint,
};
if (!string.IsNullOrWhiteSpace(userEmail))
{
data["userDomain"] = AadJoinInfo.ExtractUserDomain(userEmail);
if (isPlaceholderUser)
data["isFooUser"] = true;
}
return data;
}
/// <summary>
/// Reads ESP skip configuration from the enrollment FirstSync registry key via the
/// shared <see cref="EspSkipConfigurationProbe"/> (also used by
@@ -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>
@@ -78,7 +78,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
{
// Don't log the full email in decision signals — user PII. Keep domain only
// for audit trail; full email lives only in local agent logs if at all.
["userDomain"] = ExtractDomain(userEmail),
["userDomain"] = AadJoinInfo.ExtractUserDomain(userEmail),
["hasThumbprint"] = string.IsNullOrEmpty(thumbprint) ? "false" : "true",
// The AadJoinWatcher fires AadUserJoined ONLY when isPlaceholderUser==false
// (placeholder accounts go via the separate PlaceholderUserDetected event).
@@ -137,7 +137,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
var placeholderType = ClassifyPlaceholder(userEmail);
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
["userDomain"] = ExtractDomain(userEmail),
["userDomain"] = AadJoinInfo.ExtractUserDomain(userEmail),
["placeholderType"] = placeholderType,
["registryKey"] = @"HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo",
};
@@ -154,13 +154,6 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
occurredAtUtc: _clock.UtcNow);
}
private static string ExtractDomain(string email)
{
if (string.IsNullOrEmpty(email)) return "unknown";
var at = email.IndexOf('@');
return at >= 0 && at + 1 < email.Length ? email.Substring(at + 1) : "unknown";
}
private static string ClassifyPlaceholder(string email)
{
if (string.IsNullOrEmpty(email)) return "unknown";
@@ -27,7 +27,7 @@ public class AnalyzeRulesFunctionAuthorEndpointTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string RuleId = "ANALYZE-CUSTOM-777";
private const string TemplateRuleId = "ANALYZE-SEC-004";
private const string TemplateRuleId = "ANALYZE-SEC-006";
private sealed class FakeHttpResponseData : HttpResponseData
{
@@ -177,7 +177,7 @@ public class AnalyzeRulesFunctionAuthorEndpointTests
var template = new AnalyzeRule
{
RuleId = TemplateRuleId,
Title = "AutoLogon enabled for an unexpected user",
Title = "Unexpected Provisioning Package (Custom Allow-List)",
Description = "Template",
Severity = "warning",
Category = "security",
@@ -186,7 +186,7 @@ public class AnalyzeRulesFunctionAuthorEndpointTests
Author = "Autopilot Monitor",
Conditions = new List<RuleCondition>
{
new() { Signal = "autologon", Source = "event_type", EventType = "autologon_analysis", Operator = "equals", Value = "PLACEHOLDER", Required = true }
new() { Signal = "unexpected_package", Source = "event_type", EventType = "provisioning_package_scan", Operator = "equals", Value = "PLACEHOLDER", Required = true }
},
TemplateVariables = new List<TemplateVariable>
{
@@ -194,7 +194,7 @@ public class AnalyzeRulesFunctionAuthorEndpointTests
},
};
var (function, stored) = BuildFunction(globalRule: template);
var req = BuildRequest(AlicePrincipal(), new { variables = new Dictionary<string, string> { ["allowed"] = "KIOSK-1" } });
var req = BuildRequest(AlicePrincipal(), new { variables = new Dictionary<string, string> { ["allowed"] = "Contoso.Recovery" } });
var response = await function.CreateFromTemplate(req, TemplateRuleId);
@@ -111,17 +111,12 @@ public class BuiltInRulesTests
Assert.Equal("high", sec003.Severity);
Assert.Contains(sec003.Conditions, c => c.DataField == "checks.default_password_present");
// SEC-004: kiosk allow-list TEMPLATE — disabled until configured.
var sec004 = rules.FirstOrDefault(r => r.RuleId == "ANALYZE-SEC-004");
Assert.NotNull(sec004);
Assert.False(sec004!.Enabled, "ANALYZE-SEC-004 is a template and must ship disabled");
Assert.NotNull(sec004.TemplateVariables);
var tv = Assert.Single(sec004.TemplateVariables);
Assert.Equal("allowed_autologon_users", tv.Name);
Assert.Equal(1, tv.ConditionIndex);
Assert.Equal("value", tv.Field);
Assert.Equal("not_in", sec004.Conditions[1].Operator);
Assert.Equal("checks.default_user_name", sec004.Conditions[1].DataField);
// SEC-004 (kiosk allow-list template) was removed. The ID stays retired, and no surviving
// rule may match on the AutoLogon user name: the event reports a sign-in address as its
// domain only, so an exact-match list no longer fits that field.
Assert.DoesNotContain(rules, r => r.RuleId == "ANALYZE-SEC-004");
Assert.DoesNotContain(rules, r =>
r.Conditions != null && r.Conditions.Any(c => c.DataField == "checks.default_user_name"));
}
[Fact]
@@ -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 },
};
}
@@ -68,7 +68,7 @@ public class RuleSubmissionServiceTests
MarkSessionAsFailed = true,
Notify = true,
NotifyChannelIds = new List<string> { "chan-1" },
DerivedFromTemplateRuleId = "ANALYZE-SEC-004",
DerivedFromTemplateRuleId = "ANALYZE-SEC-006",
Explanation = "The PAC file could not be fetched.",
Conditions = new List<RuleCondition>
{
@@ -89,7 +89,7 @@ public class RuleSubmissionServiceTests
Assert.Equal("ANALYZE-CUSTOM-003", node["ruleId"]!.GetValue<string>());
Assert.Equal("Alice Admin", node["author"]!.GetValue<string>());
Assert.Equal("ANALYZE-SEC-004", node["derivedFromTemplateRuleId"]!.GetValue<string>());
Assert.Equal("ANALYZE-SEC-006", node["derivedFromTemplateRuleId"]!.GetValue<string>());
Assert.Single(node["conditions"]!.AsArray());
}
@@ -430,8 +430,8 @@ public class RuleSubmissionServiceTests
Assert.Null(s.ContactEmail);
Assert.Equal("Proxy PAC unreachable", s.Title);
Assert.Equal("network", s.Category);
Assert.Equal("ANALYZE-SEC-004", s.DerivedFromTemplateRuleId);
Assert.Contains(s.ValidationFindings, f => f.Level == "info" && f.Message.Contains("ANALYZE-SEC-004"));
Assert.Equal("ANALYZE-SEC-006", s.DerivedFromTemplateRuleId);
Assert.Contains(s.ValidationFindings, f => f.Level == "info" && f.Message.Contains("ANALYZE-SEC-006"));
Assert.DoesNotContain(s.ValidationFindings, f => f.Level == "error");
Assert.Equal(5, s.SourceFireStats!.FireCount);
Assert.Equal(50, s.SourceFireStats.SessionsEvaluated);
@@ -27,14 +27,14 @@ public class RuleSubmissionsWireParityTests
AttributionName = "Community contribution",
SubmittedAt = T0,
Status = "approved",
ValidationFindings = new List<RuleSubmissionFinding> { new() { Level = "info", Message = "Derived from template ANALYZE-SEC-004" } },
ValidationFindings = new List<RuleSubmissionFinding> { new() { Level = "info", Message = "Derived from template ANALYZE-SEC-006" } },
SourceFireStats = new RuleSubmissionFireStats { Days = 30, FireCount = 42, SessionsEvaluated = 310, EvaluationCount = 312 },
ReviewedBy = "ga@example.com",
ReviewedAt = T0.AddDays(1),
ReviewComment = "Taken.",
WillBeAdapted = true,
PublishedRuleId = "ANALYZE-NET-002",
DerivedFromTemplateRuleId = "ANALYZE-SEC-004",
DerivedFromTemplateRuleId = "ANALYZE-SEC-006",
};
private static object ItemLiteral() => new
@@ -54,14 +54,14 @@ public class RuleSubmissionsWireParityTests
attributionName = "Community contribution",
submittedAt = T0,
status = "approved",
validationFindings = new List<object> { new { level = "info", message = "Derived from template ANALYZE-SEC-004" } },
validationFindings = new List<object> { new { level = "info", message = "Derived from template ANALYZE-SEC-006" } },
sourceFireStats = new { days = 30, fireCount = 42, sessionsEvaluated = 310, evaluationCount = 312 },
reviewedBy = "ga@example.com",
reviewedAt = T0.AddDays(1),
reviewComment = "Taken.",
willBeAdapted = true,
publishedRuleId = "ANALYZE-NET-002",
derivedFromTemplateRuleId = "ANALYZE-SEC-004",
derivedFromTemplateRuleId = "ANALYZE-SEC-006",
};
[Fact]
@@ -0,0 +1,174 @@
using AutopilotMonitor.Functions.Functions.Sessions;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.ApplicationInsights;
using Microsoft.ApplicationInsights.Extensibility;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// A write on one session acts in that session's tenant: the portal sends it as ?tenantId=, the
/// middleware validates it (TenantScoping.QueryParam), and a Global Admin on a foreign tenant's session
/// reaches that tenant. Before, mark-failed/-succeeded and actions took the caller's token tenant and
/// answered 404 for a Global Admin on a foreign session.
/// </summary>
public class SessionWriteTenantScopeTests
{
private const string HomeTenant = "11111111-1111-1111-1111-111111111111";
private const string OtherTenant = "22222222-2222-2222-2222-222222222222";
private const string SessionId = "33333333-3333-3333-3333-333333333333";
/// <summary>
/// Every route on one session takes the tenant from ?tenantId=. The only exception, the report,
/// carries the session's tenant in its body and checks it in the function itself.
/// </summary>
[Fact]
public void Every_session_route_is_query_param_scoped()
{
var exceptions = new HashSet<string> { "POST sessions/{sessionId}/report" };
var routes = EndpointAccessPolicyCatalog.Entries
.Where(e => e.RouteTemplate.StartsWith("sessions/{sessionId}", StringComparison.Ordinal))
.ToList();
Assert.NotEmpty(routes);
var unscoped = routes
.Where(e => e.TenantScoping != TenantScoping.QueryParam)
.Select(e => $"{e.HttpMethod} {e.RouteTemplate}")
.Where(r => !exceptions.Contains(r))
.ToList();
Assert.True(unscoped.Count == 0,
"Session routes must take the session's tenant from ?tenantId= (TenantScoping.QueryParam):\n"
+ string.Join("\n", unscoped.Select(r => $" - {r}")));
foreach (var exception in exceptions)
Assert.Contains(routes, e => $"{e.HttpMethod} {e.RouteTemplate}" == exception);
}
[Theory]
[InlineData("failed")]
[InlineData("succeeded")]
public async Task Mark_by_a_global_admin_acts_in_the_session_tenant(string outcome)
{
var repo = SessionRepo();
var req = Request(GlobalAdmin());
await RunMark(outcome, repo, req);
Assert.Equal(OtherTenant, StatusUpdateTenant(repo));
}
[Theory]
[InlineData("failed")]
[InlineData("succeeded")]
public async Task Mark_by_a_tenant_admin_stays_in_the_validated_tenant(string outcome)
{
var repo = SessionRepo();
var req = Request(TenantAdmin());
await RunMark(outcome, repo, req);
Assert.Equal(HomeTenant, StatusUpdateTenant(repo));
}
[Fact]
public async Task Action_by_a_global_admin_is_queued_in_the_session_tenant()
{
var repo = SessionRepo();
var req = Request(GlobalAdmin(), """{"type":"request_diagnostics"}""");
await ActionFunction(repo).Run(req, SessionId);
repo.Verify(r => r.QueueServerActionAsync(OtherTenant, SessionId, It.IsAny<ServerAction>()), Times.Once);
}
[Fact]
public async Task Action_by_an_operator_stays_in_the_validated_tenant()
{
var repo = SessionRepo();
var operatorContext = new RequestContext
{
TenantId = HomeTenant,
TargetTenantId = HomeTenant,
UserPrincipalName = "operator@contoso.com",
UserRole = Constants.TenantRoles.Operator,
};
var req = Request(operatorContext, """{"type":"request_diagnostics"}""");
await ActionFunction(repo).Run(req, SessionId);
repo.Verify(r => r.QueueServerActionAsync(HomeTenant, SessionId, It.IsAny<ServerAction>()), Times.Once);
}
// The lookup always points at the other tenant, so only the caller's reach decides where the write lands.
private static Mock<ISessionRepository> SessionRepo()
{
var repo = new Mock<ISessionRepository>(MockBehavior.Loose);
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(OtherTenant);
repo.Setup(r => r.GetSessionAsync(OtherTenant, SessionId))
.ReturnsAsync(new SessionSummary { SessionId = SessionId, TenantId = OtherTenant });
return repo;
}
private static RequestContext GlobalAdmin() => new()
{
TenantId = HomeTenant,
TargetTenantId = HomeTenant,
UserPrincipalName = "ga@contoso.com",
IsGlobalAdmin = true,
UserRole = Constants.GlobalRoles.GlobalAdmin,
};
private static RequestContext TenantAdmin() => new()
{
TenantId = HomeTenant,
TargetTenantId = HomeTenant,
UserPrincipalName = "admin@contoso.com",
IsTenantAdmin = true,
UserRole = Constants.TenantRoles.Admin,
};
private static HttpRequestData Request(RequestContext requestContext, string? body = null)
{
var (req, context) = EndpointHarness.Request(HomeTenant, jsonBody: body);
context.Items["RequestContext"] = requestContext;
return req;
}
private static async Task RunMark(string outcome, Mock<ISessionRepository> repo, HttpRequestData req)
{
var maintenance = Mock.Of<IMaintenanceRepository>();
if (outcome == "failed")
await new MarkSessionFailedFunction(NullLogger<MarkSessionFailedFunction>.Instance, repo.Object, maintenance).Run(req, SessionId);
else
await new MarkSessionSucceededFunction(NullLogger<MarkSessionSucceededFunction>.Instance, repo.Object, maintenance).Run(req, SessionId);
}
private static string? StatusUpdateTenant(Mock<ISessionRepository> repo)
{
var call = Assert.Single(repo.Invocations, i => i.Method.Name == nameof(ISessionRepository.UpdateSessionStatusAsync));
return call.Arguments[0] as string;
}
private static QueueSessionActionFunction ActionFunction(Mock<ISessionRepository> repo)
{
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
var opsEvents = new OpsEventService(
Mock.Of<IOpsEventRepository>(), NullLogger<OpsEventService>.Instance,
TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
return new QueueSessionActionFunction(
NullLogger<QueueSessionActionFunction>.Instance, repo.Object,
new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true }), opsEvents);
}
}
@@ -33,7 +33,7 @@ public class TableRuleSubmissionRepositoryTests
Status = RuleSubmissionStatuses.Approved,
ValidationFindings = new List<RuleSubmissionFinding>
{
new() { Level = "info", Message = "Derived from template ANALYZE-SEC-004" },
new() { Level = "info", Message = "Derived from template ANALYZE-SEC-006" },
},
SourceFireStats = new RuleSubmissionFireStats { Days = 30, FireCount = 42, SessionsEvaluated = 310, EvaluationCount = 312 },
ReviewedBy = "ga@example.com",
@@ -41,7 +41,7 @@ public class TableRuleSubmissionRepositoryTests
ReviewComment = "Taken, generalised for the community.",
WillBeAdapted = true,
PublishedRuleId = "ANALYZE-NET-002",
DerivedFromTemplateRuleId = "ANALYZE-SEC-004",
DerivedFromTemplateRuleId = "ANALYZE-SEC-006",
};
[Fact]
@@ -35,8 +35,10 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
try
{
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
string tenantId = TenantHelper.GetTenantId(req);
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware.
// The session's tenant: a Global Admin reaches a foreign tenant's session, every other
// caller stays in the tenant the middleware validated (?tenantId=, TenantScoping.QueryParam).
string tenantId = await req.GetRequestContext().ResolveSessionScopeAsync(_sessionRepo, sessionId, requireGlobalAdmin: true);
string userIdentifier = TenantHelper.GetUserIdentifier(req);
_logger.LogInformation($"Marking session {sessionId} as failed for tenant {tenantId} by user {userIdentifier}");
@@ -35,8 +35,10 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
try
{
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
string tenantId = TenantHelper.GetTenantId(req);
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware.
// The session's tenant: a Global Admin reaches a foreign tenant's session, every other
// caller stays in the tenant the middleware validated (?tenantId=, TenantScoping.QueryParam).
string tenantId = await req.GetRequestContext().ResolveSessionScopeAsync(_sessionRepo, sessionId, requireGlobalAdmin: true);
string userIdentifier = TenantHelper.GetUserIdentifier(req);
_logger.LogInformation($"Marking session {sessionId} as succeeded for tenant {tenantId} by user {userIdentifier}");
@@ -80,9 +80,11 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
{
// Authentication + TenantAdminOrOperator authorization enforced by
// PolicyEnforcementMiddleware; per-type re-gating for Operators happens below.
var tenantId = TenantHelper.GetTenantId(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
// The session's tenant: a Global Admin reaches a foreign tenant's session, every other
// caller stays in the tenant the middleware validated (?tenantId=, TenantScoping.QueryParam).
var requestCtx = req.GetRequestContext();
var tenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId, requireGlobalAdmin: true);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var read = await req.ReadAsync<QueueSessionActionRequest>();
if (read.Error != null) return read.Error;
@@ -404,12 +404,12 @@ public static class EndpointAccessPolicyCatalog
new("POST", "rules/analyze/{ruleId}/create-from-template", EndpointPolicy.TenantAdminOrGA),
new("PUT", "rules/analyze/{ruleId}", EndpointPolicy.TenantAdminOrGA),
new("DELETE", "rules/analyze/{ruleId}", EndpointPolicy.TenantAdminOrGA),
new("POST", "sessions/{sessionId}/mark-failed", EndpointPolicy.TenantAdminOrGA),
new("POST", "sessions/{sessionId}/mark-succeeded", EndpointPolicy.TenantAdminOrGA),
new("POST", "sessions/{sessionId}/mark-failed", EndpointPolicy.TenantAdminOrGA, TenantScoping.QueryParam),
new("POST", "sessions/{sessionId}/mark-succeeded", EndpointPolicy.TenantAdminOrGA, TenantScoping.QueryParam),
// Operator tier so troubleshooting staff can queue request_diagnostics ("Collect Logs" in
// the portal); the function re-gates the admin-only action types (terminate_session,
// rotate_config) on RequestContext.IsTenantAdmin || IsGlobalAdmin.
new("POST", "sessions/{sessionId}/actions", EndpointPolicy.TenantAdminOrOperator),
new("POST", "sessions/{sessionId}/actions", EndpointPolicy.TenantAdminOrOperator, TenantScoping.QueryParam),
// Operator tier so troubleshooting staff can write the operator annotation lane; the
// function re-gates the per-lane matrix (tenantadmin lane → IsTenantAdmin, globaladmin
// lane → IsGlobalAdmin) via UpsertSessionAnnotationFunction.IsLaneWritableByCaller.
@@ -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',
+19 -11
View File
@@ -1009,27 +1009,35 @@ namespace AutopilotMonitor.Shared
// raw facts only (Winlogon registry indicators; DefaultPassword presence-only, never the
// value) at Info severity. AutoLogon-enabled alone is NOT graded — it is the fingerprint
// of Windows' own ESP auto-logon on every normal Autopilot enrollment. Backend analyze-
// rules grade only the unambiguous cases: a plaintext DefaultPassword on disk (ANALYZE-
// SEC-003, escalated) and an optional kiosk allow-list template (ANALYZE-SEC-004, off by
// default). AutoLogon may be legitimate (kiosk) OR an enrollment/OOBE manipulation vector.
// rules grade only the unambiguous case: a plaintext DefaultPassword on disk (ANALYZE-
// SEC-003, escalated). 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";
@@ -8,6 +8,7 @@ import { api } from "@/lib/api";
import type { SoftwareTabScope } from "./types";
import { ApiError, fetchJson } from "@/lib/apiClient";
import { notifyApiError } from "@/contexts/NotificationContext";
import { AnalyzerOffNotice } from "@/components/vulnerability/AnalyzerOffNotice";
interface InventoryItem {
displayName: string;
@@ -33,7 +34,17 @@ interface InventoryResponse {
const PAGE_SIZE = 20;
export default function InventoryTab({ scope }: { scope: SoftwareTabScope }) {
export default function InventoryTab({
scope,
analyzerOn,
canEnable,
}: {
scope: SoftwareTabScope;
/** The tenant's analyzer switch: null while it loads, undefined when unknown. */
analyzerOn?: boolean | null;
/** May turn the analyzer on from here (lib/tenantScope.ts canChangeOwnTenantSettings). */
canEnable: boolean;
}) {
const { tenantId } = useTenant();
const { getAccessToken } = useAuth();
const { addNotification } = useNotifications();
@@ -110,6 +121,11 @@ export default function InventoryTab({ scope }: { scope: SoftwareTabScope }) {
);
}
// Nothing collected because the analyzer is off: say so instead of empty tiles and a search box.
if (!loading && analyzerOn === false && (resp?.total ?? 0) === 0) {
return <AnalyzerOffNotice subject="inventory" canEnable={canEnable} />;
}
return (
<>
{resp && (
@@ -146,7 +162,9 @@ export default function InventoryTab({ scope }: { scope: SoftwareTabScope }) {
<div className="p-8 text-center text-gray-500">
{search
? "No software matches your search."
: "No software inventory collected for this tenant yet. It fills once the Software Inventory & Vulnerability Analyzer is on in the agent settings."}
: analyzerOn
? "No software inventory collected for this tenant yet. It fills with the next enrollment."
: "No software inventory collected for this tenant yet. It fills once the Software Inventory & Vulnerability Analyzer is on in the agent settings."}
</div>
) : (
<>
@@ -5,24 +5,58 @@ import { useAuth } from "../../../contexts/AuthContext";
import { useNotifications } from "../../../contexts/NotificationContext";
import { scopedApi } from "@/lib/scopedApi";
import VulnerabilityExposurePanel from "@/components/VulnerabilityExposurePanel";
import { AnalyzerOffNotice } from "@/components/vulnerability/AnalyzerOffNotice";
import type { CveExposureSummary } from "@/lib/generated/wire-types.generated";
import type { SoftwareTabScope } from "./types";
import { windowProgressKey } from "@/lib/timeWindow";
import { ApiError, fetchJson } from "@/lib/apiClient";
import { notifyApiError } from "@/contexts/NotificationContext";
import { useFetchProgress } from "@/hooks/useFetchProgress";
import { trackEvent } from "@/lib/appInsights";
import type { ExposureAction } from "@/lib/vulnerabilityExposure";
const TOP_N = 20;
/** Per severity/priority band, its top entries outside the lists, so every band filter shows something. */
const PER_BAND = 10;
export default function VulnerabilitiesTab({ scope, days, windowReady }: { scope: SoftwareTabScope; days: number; windowReady: boolean }) {
/** Counts a filter, lens, sort or expand step; never a CVE id or a product name. */
function trackExposureAction({ action, value, active }: ExposureAction) {
trackEvent("vuln_exposure_action", {
action,
...(value !== undefined ? { value } : {}),
...(active !== undefined ? { active } : {}),
});
}
/** A completed load: the summary with the window and scope it was read for. */
interface LoadedExposure {
summary: CveExposureSummary;
days: number;
crossTenant: boolean;
}
export default function VulnerabilitiesTab({
scope,
days,
windowReady,
analyzerOn,
canEnable,
}: {
scope: SoftwareTabScope;
days: number;
windowReady: boolean;
/** The tenant's analyzer switch: null while it loads, undefined in an aggregate view or when unreadable. */
analyzerOn?: boolean | null;
/** May turn the analyzer on from here (lib/tenantScope.ts canChangeOwnTenantSettings). */
canEnable: boolean;
}) {
const { getAccessToken } = useAuth();
const { addNotification } = useNotifications();
const { isGlobalAdmin, selectedTenantId, scopeInitialized, scopeKey } = scope;
const { isGlobalAdmin, selectedTenantId, scopeInitialized, scopeKey } = scope;
const [summary, setSummary] = useState<CveExposureSummary | null>(null);
const [loaded, setLoaded] = useState<LoadedExposure | null>(null);
const [loading, setLoading] = useState(true);
const summary = loaded?.summary ?? null;
// GA without a specific tenant selected sees the cross-tenant aggregate (incl. affected tenants).
const showTenantCount = isGlobalAdmin && !selectedTenantId;
@@ -45,7 +79,7 @@ export default function VulnerabilitiesTab({ scope, days, windowReady }: { scope
const url = scopedApi.vulnerability(scope, days, TOP_N, PER_BAND);
const summary = await fetchJson<CveExposureSummary>(url, getAccessToken);
if (cancelled) return;
setSummary(summary);
setLoaded({ summary, days, crossTenant: showTenantCount });
succeeded = true;
} catch (err) {
if (cancelled) return;
@@ -69,12 +103,41 @@ export default function VulnerabilitiesTab({ scope, days, windowReady }: { scope
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scopeInitialized, windowReady, scopeKey, days]);
const hasFindings = !!summary && summary.distinctCves > 0;
const analyzerOff = analyzerOn === false;
// What the tab showed, once per completed load and only after the analyzer switch is known, so an
// empty tenant is told apart from one whose scanning is off.
const viewState = !loaded || loading || analyzerOn === null
? null
: loaded.summary.distinctCves > 0 ? "findings" : analyzerOff ? "analyzer_off" : "empty";
useEffect(() => {
if (!viewState || !loaded) return;
trackEvent("vuln_exposure_viewed", {
state: viewState,
scope: loaded.crossTenant ? "global" : "tenant",
days: loaded.days,
cves: loaded.summary.distinctCves,
kev: loaded.summary.kevCves,
highRisk: loaded.summary.priorityBreakdown.attend,
});
}, [viewState, loaded]);
return (
<VulnerabilityExposurePanel
summary={summary}
loading={loading}
showTenantCount={showTenantCount}
progress={{ elapsedMs: progress.elapsedMs, estimateMs: progress.estimateMs }}
/>
<div className="space-y-4">
{analyzerOff && hasFindings && (
<p className="text-sm text-gray-500">
Scanning is off for this tenant: these findings are from before, new enrollments are not scanned.
</p>
)}
<VulnerabilityExposurePanel
summary={summary}
loading={loading}
showTenantCount={showTenantCount}
progress={{ elapsedMs: progress.elapsedMs, estimateMs: progress.estimateMs }}
emptyState={analyzerOff ? <AnalyzerOffNotice subject="vulnerabilities" canEnable={canEnable} /> : undefined}
onAction={trackExposureAction}
/>
</div>
);
}
@@ -1,6 +1,7 @@
"use client";
import { appsHubUrl, sessionUrl } from "@/lib/routes";
import Link from "next/link";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import dynamic from "next/dynamic";
@@ -364,16 +365,26 @@ function AppDetailContent() {
<GlobalAdminBanner show={scope.isGlobalAdmin} delegated={scope.isDelegatedScope} subtitle={globalAdminSubtitle(scope)} />
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<button
onClick={() => {
// Tenant scope is carried in sessionStorage, so the list restores it on its own;
// a non-default window travels back as ?days=.
router.push(appsHubUrl({ days: days === APPS_DEFAULT_WINDOW_DAYS ? undefined : days }));
}}
className="text-sm text-green-700 hover:underline mb-2 inline-flex items-center"
>
← Back to all apps
</button>
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
<button
onClick={() => {
// Tenant scope is carried in sessionStorage, so the list restores it on its own;
// a non-default window travels back as ?days=.
router.push(appsHubUrl({ days: days === APPS_DEFAULT_WINDOW_DAYS ? undefined : days }));
}}
className="text-sm text-green-700 hover:underline inline-flex items-center"
>
← Back to all apps
</button>
{/* The hub's exposure view, same scope (sessionStorage) and window; not filtered to this app. */}
<Link
href={appsHubUrl({ tab: "vulnerabilities", days: days === APPS_DEFAULT_WINDOW_DAYS ? undefined : days })}
onClick={() => trackEvent("vuln_link_clicked", { from: "app_detail" })}
className="text-sm text-green-700 hover:underline"
>
Vulnerability exposure →
</Link>
</div>
<div className="flex flex-wrap items-center justify-between gap-y-3">
<div className="min-w-0">
<h1 className="text-2xl font-normal text-gray-900 flex flex-wrap items-center gap-x-3 gap-y-1">
@@ -15,6 +15,11 @@ import InventoryTab from "./components/InventoryTab";
import VulnerabilitiesTab from "./components/VulnerabilitiesTab";
import { DocsLink } from "@/components/DocsLink";
import { DOCS_PATHS } from "@/lib/docsPaths";
import { useAuth } from "@/contexts/AuthContext";
import { useTenant } from "@/contexts/TenantContext";
import { trackEvent } from "@/lib/appInsights";
import { canChangeOwnTenantSettings } from "@/lib/tenantScope";
import { useVulnerabilityHeadline } from "@/hooks/useVulnerabilityHeadline";
const TABS = [
{ id: "installs", label: "Installs" },
@@ -26,14 +31,29 @@ type TabId = (typeof TABS)[number]["id"];
function SoftwareHub() {
const router = useRouter();
const searchParams = useSearchParams();
const scope = useAggregatedAdminScope();
const { user } = useAuth();
const { tenantId: ownTenantId } = useTenant();
// ?tenantId= (dashboard card, session report) points a Global Admin at that tenant on first load.
const scope = useAggregatedAdminScope({ urlTenantId: searchParams.get("tenantId") ?? "" });
// The window lives in the URL (?days=) and survives tab switches (selectTab keeps every parameter).
const { days, ready: windowReady, setDays } = useWindowDays({ defaultDays: APPS_DEFAULT_WINDOW_DAYS, tenantId: scope.effectiveTenantId });
// Analyzer switch and known-exploited count of the tenant in view: the count goes on the
// Vulnerabilities tab label, the switch decides both tabs' empty state. Only the active tab is
// mounted, so the page reads them itself. The aggregated Global Admin view (the slow cross-tenant
// scan) reads nothing.
const singleTenant = scope.scopeInitialized && windowReady && !scope.isAggregatedGlobalView && !!scope.effectiveTenantId;
const headline = useVulnerabilityHeadline(singleTenant ? scope : null, singleTenant ? scope.effectiveTenantId : null, days);
const kev = headline.status === "ready" ? headline.kev : 0;
// undefined = no single tenant or not readable; null = still loading.
const analyzerOn = headline.status === "ready" ? headline.analyzerOn : headline.status === "loading" ? null : undefined;
const canEnable = canChangeOwnTenantSettings(user, scope.effectiveTenantId, ownTenantId);
const rawTab = searchParams.get("tab");
const activeTab: TabId = TABS.some((t) => t.id === rawTab) ? (rawTab as TabId) : "installs";
function selectTab(id: TabId) {
trackEvent("software_tab_selected", { tab: id, badge: kev > 0, kev });
const params = new URLSearchParams(searchParams.toString());
params.set("tab", id);
router.replace(`/apps?${params.toString()}`);
@@ -75,6 +95,12 @@ function SoftwareHub() {
}`}
>
{t.label}
{t.id === "vulnerabilities" && kev > 0 && (
<span className="ml-2 inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-700">
<span aria-hidden="true">{kev.toLocaleString()}</span>
<span className="sr-only">{`${kev.toLocaleString()} known exploited`}</span>
</span>
)}
</button>
))}
</nav>
@@ -83,8 +109,10 @@ function SoftwareHub() {
<main className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{activeTab === "installs" && <InstallsTab scope={scope} days={days} windowReady={windowReady} />}
{activeTab === "inventory" && <InventoryTab scope={scope} />}
{activeTab === "vulnerabilities" && <VulnerabilitiesTab scope={scope} days={days} windowReady={windowReady} />}
{activeTab === "inventory" && <InventoryTab scope={scope} analyzerOn={analyzerOn} canEnable={canEnable} />}
{activeTab === "vulnerabilities" && (
<VulnerabilitiesTab scope={scope} days={days} windowReady={windowReady} analyzerOn={analyzerOn} canEnable={canEnable} />
)}
</main>
</div>
);
@@ -8,6 +8,7 @@ import { useState, useEffect, useRef, useMemo, useDeferredValue } from "react";
import { Session } from "../types";
import { trackEvent } from "@/lib/appInsights";
import { fuzzyContains } from "@/lib/fuzzy";
import { isTypingInField } from "@/lib/keyboardTarget";
import { buildUniqueValuesByField } from "./uniqueValuesByField";
import {
SESSION_SEARCH_FIELDS,
@@ -21,6 +22,7 @@ import { TenantFilterBar } from "./TenantFilterBar";
import type { DeleteTarget } from "../hooks/useDeleteSession";
import type { BlockTarget } from "../hooks/useBlockDevice";
import { BULK_MAX_TARGETS, addSelections } from "../hooks/bulkActions";
import { canAdministerTenant, type TenantScopeUser } from "@/lib/tenantScope";
// Column definition for the session table
interface ColumnDef {
@@ -104,7 +106,9 @@ interface SessionTableProps {
tenantDomainById: Map<string, string>;
blockedDevicesSet: Set<string>;
isActivationPending: boolean;
user: { isGlobalAdmin?: boolean } | null;
user: TenantScopeUser | null;
/** The caller's own tenant: a tenant Admin may delete only its sessions (canAdministerTenant). */
ownTenantId: string;
columnFilters: Record<string, Set<string>>;
onColumnFiltersChange: (filters: Record<string, Set<string>>) => void;
/** Opens the delete confirm for one session (row icon) or many (Select mode). */
@@ -160,6 +164,7 @@ export function SessionTable({
blockedDevicesSet,
isActivationPending,
user,
ownTenantId,
columnFilters,
onColumnFiltersChange,
onDeleteSessions,
@@ -333,7 +338,10 @@ export function SessionTable({
const canBlock = globalAdminMode && !!user?.isGlobalAdmin;
const colSpan = activeColumns.length + (selecting ? 1 : 0);
const isSelectable = (s: Session) => !pendingDeletions.has(s.sessionId);
// A delegated admin or Global Reader with an own Admin role sees foreign tenants' rows too; the
// backend refuses every write there, so those rows get no delete icon and no checkbox.
const canAdminister = (s: Session) => canAdministerTenant(user, s.tenantId, ownTenantId);
const isSelectable = (s: Session) => !pendingDeletions.has(s.sessionId) && canAdminister(s);
// Effective selection = selected ∩ currently visible (filtered) ∩ not already queued for
// deletion, cut to BULK_MAX_TARGETS. Derived at read time so rows that vanish (deleted,
@@ -398,9 +406,7 @@ export function SessionTable({
useEffect(() => {
if (!selecting) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
const tag = (e.target as HTMLElement | null)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (e.key !== "Escape" || isTypingInField(e.target)) return;
setSelectMode(false);
setSelectedIds(new Set());
lastToggledIdRef.current = null;
@@ -943,11 +949,11 @@ export function SessionTable({
columnKey={col.key}
session={session}
href={selecting ? null : linkFor(session)}
adminMode={adminMode}
globalAdminMode={globalAdminMode}
blockedDevicesSet={blockedDevicesSet}
user={user}
selectMode={selecting}
canDelete={canAdminister(session)}
onDeleteSessions={onDeleteSessions}
isDeletionPending={pendingDeletions.has(session.sessionId)}
onBlockDevices={onBlockDevices}
@@ -1053,6 +1059,7 @@ function SessionCell({
blockedDevicesSet,
user,
selectMode,
canDelete,
onDeleteSessions,
isDeletionPending,
onBlockDevices,
@@ -1062,12 +1069,13 @@ function SessionCell({
session: Session;
/** Null while selecting — cells then render no navigation overlay. */
href: Route | null;
adminMode: boolean;
globalAdminMode: boolean;
blockedDevicesSet: Set<string>;
user: { isGlobalAdmin?: boolean } | null;
/** Row-level action icons are hidden while selecting; the bulk bar owns the actions then. */
selectMode: boolean;
/** The caller may delete this session (canAdministerTenant); otherwise the row shows no delete icon. */
canDelete: boolean;
onDeleteSessions: (targets: DeleteTarget[]) => void;
/** True when the V2 cascade has been queued for this session and we're awaiting `sessionDeleted`. */
isDeletionPending: boolean;
@@ -1339,7 +1347,7 @@ function SessionCell({
<path d="M4 12a8 8 0 018-8" strokeWidth="3" className="opacity-75" />
</svg>
</span>
) : selectMode ? null : (
) : selectMode || !canDelete ? null : (
<button
onClick={(e) => {
e.stopPropagation();
@@ -1,8 +1,12 @@
"use client";
import type { ReactNode } from "react";
import type { Route } from "next";
import Link from "next/link";
export type StatsCardIcon = "activity" | "success" | "duration" | "today" | "failed";
export type StatsCardIcon = "activity" | "success" | "duration" | "today" | "failed" | "shield" | "shieldAlert";
const SHIELD = "M12 21.5s7.5-3.6 7.5-9.4V5.4L12 2.5 4.5 5.4v6.7c0 5.8 7.5 9.4 7.5 9.4z";
// Outline glyphs on a 24px grid, one per metric.
const iconShapes: Record<StatsCardIcon, ReactNode> = {
@@ -35,45 +39,139 @@ const iconShapes: Record<StatsCardIcon, ReactNode> = {
<path d="m9 9 6 6" />
</>
),
shield: <path d={SHIELD} />,
shieldAlert: (
<>
<path d={SHIELD} />
<path d="M12 8v4.5M12 15.8h.01" />
</>
),
};
/**
* Columns of the stat row; the wrapper around it carries `stats-row` (the size container). Six cards
* follow the width the row itself gets, set in app/globals.css (`stats-grid-6`: six columns from
* 1126 px, which a 1440 px window with the full sidebar reaches, three from 553 px, two from 362 px).
* Five cards (the cross-tenant views) keep the window breakpoints.
*/
export function statsGridClassName(count: number): string {
return count >= 6 ? "stats-grid-6" : "grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-5";
}
/** The link stretched over the whole card; the card is its positioning context. */
const STRETCHED_LINK = "after:absolute after:inset-0 after:rounded-lg focus:outline-none";
export function StatsCard({
title,
value,
description,
icon,
alert = false,
muted = false,
href,
linkOn = "title",
onClick,
}: {
title: string;
value: string;
description: string;
/** One line, or parts that wrap as whole units, separated by a dot that never starts a line. */
description: string | readonly string[];
icon: StatsCardIcon;
/** Colors value and icon red — the only color on the row, reserved for a metric that needs attention. */
alert?: boolean;
/** Gray value: the metric is off or not available. */
muted?: boolean;
/** Makes the whole card a link. */
href?: Route;
/** "description": the description is the visible call to action (green); otherwise the title carries the link. */
linkOn?: "title" | "description";
onClick?: () => void;
}) {
const linked = !!href;
const titleLink = linked && linkOn === "title";
const descriptionLink = linked && linkOn === "description";
const parts = typeof description === "string" ? [description] : description;
return (
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-start justify-between gap-3">
<dl className="min-w-0">
<dt className="text-sm font-medium text-gray-500 truncate">{title}</dt>
<dd className={`text-2xl font-semibold ${alert ? "text-red-600" : "text-gray-900"}`}>{value}</dd>
</dl>
<svg
className={`h-5 w-5 flex-shrink-0 ${alert ? "text-red-600" : "text-gray-500"}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{iconShapes[icon]}
</svg>
</div>
<div className="mt-2">
<div className="text-sm text-gray-500">{description}</div>
<div
className={`relative bg-white overflow-hidden shadow rounded-lg${
linked
? " group transition-shadow hover:shadow-md hover:ring-1 hover:ring-gray-300 dark:hover:ring-gray-600 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-green-600 motion-reduce:transition-none"
: ""
}`}
>
<div className="p-4">
<dl className="min-w-0">
<dt className="flex min-w-0 items-center gap-2 text-sm font-medium text-gray-500">
<span
className={`flex min-w-0 flex-1 items-center gap-1${
titleLink ? " group-hover:text-gray-900 dark:group-hover:text-gray-100" : ""
}`}
>
{titleLink && href ? (
<>
<Link href={href} onClick={onClick} className={`truncate ${STRETCHED_LINK}`}>
{title}
</Link>
<svg
className="h-3.5 w-3.5 shrink-0 -translate-x-1 opacity-0 transition group-hover:translate-x-0 group-hover:opacity-100 group-focus-within:translate-x-0 group-focus-within:opacity-100 motion-reduce:transition-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</>
) : (
<span className="truncate">{title}</span>
)}
</span>
<svg
className={`h-4 w-4 shrink-0 ${alert ? "text-red-600" : "text-gray-500"}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{iconShapes[icon]}
</svg>
</dt>
<dd className={`text-2xl font-semibold ${alert ? "text-red-600" : muted ? "text-gray-500" : "text-gray-900"}`}>
{value}
</dd>
</dl>
<div className="mt-2 text-sm text-gray-500">
{descriptionLink && href ? (
<Link
href={href}
onClick={onClick}
className={`font-medium text-green-700 hover:text-green-800 dark:text-green-400 ${STRETCHED_LINK}`}
>
{parts.join(" · ")}
</Link>
) : parts.length === 1 ? (
parts[0]
) : (
// Each part wraps as a whole; the dot before a part that starts a line sits in the clipped margin.
<div className="overflow-hidden">
<div className="-ml-[15px] flex flex-wrap">
{parts.map((part, i) => (
<span key={`${i}-${part}`} className="flex min-w-0">
<span aria-hidden="true" className="w-[15px] shrink-0 text-center">·</span>
{i > 0 && <span className="sr-only">, </span>}
<span className="min-w-0">{part}</span>
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { Route } from "next";
import { StatsCard, statsGridClassName } from "../StatsCards";
type CardProps = Parameters<typeof StatsCard>[0];
const render = (props: Partial<CardProps> = {}) =>
renderToStaticMarkup(
createElement(StatsCard, { title: "Vulnerabilities", value: "3", description: "Known exploited", icon: "shield", ...props }),
);
describe("StatsCard", () => {
it("is no link without an href", () => {
const html = render();
expect(html).not.toContain("<a ");
expect(html).toContain("Vulnerabilities");
expect(html).not.toContain("group");
});
it("stretches the title link over the card and shows the arrow on hover", () => {
const html = render({ href: "/apps?tab=vulnerabilities" as Route });
expect(html).toContain('href="/apps?tab=vulnerabilities"');
expect(html).toContain("after:absolute after:inset-0");
expect(html).toContain("group-hover:opacity-100");
expect(html).toMatch(/<a [^>]*>Vulnerabilities<\/a>/);
});
it("makes the description the call to action when the link sits there", () => {
const html = render({ href: "/settings/agent/analyzers" as Route, linkOn: "description", value: "Off", description: ["Turn on scanning →"], muted: true });
expect(html).toMatch(/<a [^>]*text-green-700[^>]*>Turn on scanning →<\/a>/);
expect(html).not.toContain("group-hover:opacity-100");
});
it("colors only an alert red and a muted value gray", () => {
expect(render({ alert: true })).toContain("text-2xl font-semibold text-red-600");
expect(render({ muted: true })).toContain("text-2xl font-semibold text-gray-500");
expect(render()).toContain("text-2xl font-semibold text-gray-900");
});
it("renders description parts as wrapping units with a hidden separator for screen readers", () => {
const html = render({ description: ["Known exploited", "41 devices"] });
expect(html).toContain("-ml-[15px] flex flex-wrap");
expect(html).toContain("Known exploited");
expect(html).toContain("41 devices");
expect(html).toContain('<span class="sr-only">, </span>');
});
});
describe("statsGridClassName", () => {
it("lays six cards out by the row's own width (globals.css) and five by the window", () => {
expect(statsGridClassName(6)).toBe("stats-grid-6");
expect(statsGridClassName(5)).toBe("grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-5");
});
it("keeps the container rules of the six-card row in ascending order, the cascade they rely on", () => {
const css = readFileSync(join(__dirname, "..", "..", "..", "globals.css"), "utf8");
const widths = [...css.matchAll(/@container \(min-width: (\d+)px\) \{\s*\.stats-grid-6 \{ grid-template-columns: repeat\((\d), /g)]
.map((m) => [Number(m[1]), Number(m[2])]);
expect(widths).toEqual([[362, 2], [553, 3], [1126, 6]]);
expect(css).toMatch(/\.stats-row \{\s*container-type: inline-size;/);
});
});
@@ -143,6 +143,7 @@ export function useDashboardSessions({
// Refs for fetch closures (refetch is called from various effects/handlers and should
// always see current filter values without forcing dependency-driven recreation)
const adminModeRef = useLatest(adminMode);
const joinGlobalAdminsRef = useLatest(joinGlobalAdmins);
const continuationRef = useLatest(continuation);
// Synchronous lock: set true BEFORE the first await so two triggers in the same
@@ -185,15 +186,15 @@ export function useDashboardSessions({
const hasJoinedGroup = useRef(false);
const fetchBlockedDevices = useCallback(async (currentSessions: Session[]) => {
if (!adminModeRef.current || !globalAdminModeRef.current) {
// The block list is a platform read (GlobalReadOrAdmin): a delegated admin's cross-tenant view
// would only collect 403s.
if (!adminModeRef.current || !joinGlobalAdminsRef.current) {
setBlockedDevicesSet(new Set());
return;
}
try {
const tenantIds = globalAdminModeRef.current
? [...new Set(currentSessions.map((s) => s.tenantId))]
: tenantIdRef.current ? [tenantIdRef.current] : [];
const tenantIds = [...new Set(currentSessions.map((s) => s.tenantId))];
if (tenantIds.length === 0) {
setBlockedDevicesSet(new Set());
@@ -219,7 +220,7 @@ export function useDashboardSessions({
} catch (error) {
console.error("Failed to fetch blocked devices:", error);
}
}, [getAccessToken, setBlockedDevicesSet, adminModeRef, globalAdminModeRef, tenantIdRef]);
}, [getAccessToken, setBlockedDevicesSet, adminModeRef, joinGlobalAdminsRef]);
// Internal batch fetcher — returns data without touching state so callers can
// decide how to apply it (single append vs. progressive loop). First-page calls use
@@ -1,6 +1,6 @@
"use client";
import { sessionUrl } from "@/lib/routes";
import { appsHubUrl, sessionUrl } from "@/lib/routes";
import Link from "next/link";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
@@ -10,7 +10,7 @@ import { useSignalR } from "../../contexts/SignalRContext";
import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
import { useNotifications } from "../../contexts/NotificationContext";
import { StatsCard } from "./components/StatsCards";
import { StatsCard, statsGridClassName } from "./components/StatsCards";
import { FirstEnrollmentCard, NoSessionsNote, SetupChecklistCard } from "./components/SetupChecklistCard";
import { ActivelyDevelopedBanner } from "./components/ActivelyDevelopedBanner";
import { AppHomingBanner } from "./components/AppHomingBanner";
@@ -35,10 +35,16 @@ import { useDashboardSessions } from "./hooks/useDashboardSessions";
import { useDashboardStats } from "./hooks/useDashboardStats";
import { api } from "@/lib/api";
import { formatDuration } from "@/lib/formatting";
import { hasTenantReadScope } from "@/lib/tenantScope";
import { canChangeOwnTenantSettings, hasTenantReadScope } from "@/lib/tenantScope";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
import type { GetRuleHitSessionsResponse } from "@/lib/generated/wire-types.generated";
import { fetchJson } from "@/lib/apiClient";
import { trackEvent } from "@/lib/appInsights";
import type { TenantScopeSelection } from "@/lib/scopedApi";
import { vulnerabilityCardState } from "@/lib/vulnerabilityHeadline";
import { useVulnerabilityHeadline } from "@/hooks/useVulnerabilityHeadline";
import { ANALYZER_SETTING_PATH, onAnalyzerEnableClick } from "@/components/vulnerability/AnalyzerOffNotice";
import { APPS_DEFAULT_WINDOW_DAYS } from "../apps/components/types";
export default function Home() {
// useSearchParams() in HomeContent requires a Suspense boundary for static prerender.
@@ -144,7 +150,7 @@ function HomeContent() {
const {
showBlockConfirm, blockTargets, blockingDevice, blockedDevicesSet, setBlockedDevicesSet,
blockDevices, confirmBlock, cancelBlock,
} = useBlockDevice(getAccessToken, addNotification, adminMode, crossTenant);
} = useBlockDevice(getAccessToken, addNotification, adminMode, joinGlobalAdmins);
const {
sessions, loading, hasMore, loadingMore, loadingAll,
@@ -252,6 +258,32 @@ function HomeContent() {
disabled: isRegularUser,
});
// Vulnerability card: one tenant in view — the own tenant, or the drill-in of a Global Admin / Global
// Reader in global mode. Aggregates and delegated views keep five cards: the Software hub seeds its
// scope from a link only for platform admins, so a delegated click would land on another tenant.
const drillInTenantId = submittedTenantIdFilter.trim();
const vulnTenantId = !user || isRegularUser
? null
: !crossTenant
? tenantId || null
: globalAdminMode && hasGlobalScope && isGuid(drillInTenantId)
? drillInTenantId
: null;
const vulnSelection: TenantScopeSelection | null = !vulnTenantId
? null
: crossTenant
? { routeGlobal: true, selectedTenantId: vulnTenantId, effectiveTenantId: vulnTenantId }
: { routeGlobal: false, selectedTenantId: "", effectiveTenantId: vulnTenantId };
const vulnHeadline = useVulnerabilityHeadline(vulnSelection, vulnTenantId, APPS_DEFAULT_WINDOW_DAYS);
const vulnCard = vulnerabilityCardState(vulnHeadline, {
canEnable: canChangeOwnTenantSettings(user, vulnTenantId, tenantId),
days: APPS_DEFAULT_WINDOW_DAYS,
});
const vulnCardKind = vulnCard?.kind;
useEffect(() => {
if (vulnCardKind && vulnCardKind !== "loading") trackEvent("vuln_card_shown", { state: vulnCardKind });
}, [vulnCardKind]);
// Redirect users without own-tenant/platform/delegated scope away from the session list to /progress. A
// delegated ("MSP") admin now STAYS on the dashboard (cross-tenant bounded session browser); their /fleet
// card grid is the landing overview but they may browse sessions here. A read-only Global Reader stays too.
@@ -485,50 +517,76 @@ function HomeContent() {
`dashboardStats === null` covers both initial load and post-scope-change
reset; show a non-zero placeholder so a fetch error doesn't masquerade
as legitimate zeros. */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-5 mb-2">
<StatsCard
title="Active Sessions"
value={dashboardStats ? dashboardStats.activeCount.toString() : "..."}
description="Currently enrolling"
icon="activity"
/>
<StatsCard
title="Success Rate"
value={dashboardStats ? `${dashboardStats.successRatePct}%` : "..."}
description="Last 7 days"
icon="success"
/>
{/* Median, not mean — a few overnight/WhiteGlove outliers dominate the average
of the right-skewed duration distribution; P90 keeps the tail visible. */}
<StatsCard
title="Median Duration"
value={
dashboardStats
? dashboardStats.medianDurationMinutes > 0
? `${dashboardStats.medianDurationMinutes} min`
: "—"
: "..."
}
description={
dashboardStats && dashboardStats.p90DurationMinutes > 0
? `P90 ${formatDuration(dashboardStats.p90DurationMinutes * 60)} · last 7 days`
: "Last 7 days"
}
icon="duration"
/>
<StatsCard
title="Total Today"
value={dashboardStats ? dashboardStats.totalToday.toString() : "..."}
description="Started today"
icon="today"
/>
<StatsCard
title="Failed Today"
value={dashboardStats ? dashboardStats.failedToday.toString() : "..."}
description={dashboardStats && dashboardStats.failedToday === 0 ? "No failures" : "Needs attention"}
icon="failed"
alert={!!dashboardStats && dashboardStats.failedToday > 0}
/>
{/* The wrapper is the size container the six-card columns follow (statsGridClassName). */}
<div className="stats-row mb-2">
<div className={statsGridClassName(vulnCard ? 6 : 5)}>
<StatsCard
title="Active Sessions"
value={dashboardStats ? dashboardStats.activeCount.toString() : "..."}
description="Currently enrolling"
icon="activity"
/>
<StatsCard
title="Success Rate"
value={dashboardStats ? `${dashboardStats.successRatePct}%` : "..."}
description="Last 7 days"
icon="success"
/>
{/* Median, not mean — a few overnight/WhiteGlove outliers dominate the average
of the right-skewed duration distribution; P90 keeps the tail visible. */}
<StatsCard
title="Median Duration"
value={
dashboardStats
? dashboardStats.medianDurationMinutes > 0
? `${dashboardStats.medianDurationMinutes} min`
: "—"
: "..."
}
description={
dashboardStats && dashboardStats.p90DurationMinutes > 0
? [`P90 ${formatDuration(dashboardStats.p90DurationMinutes * 60)}`, "last 7 days"]
: "Last 7 days"
}
icon="duration"
/>
<StatsCard
title="Total Today"
value={dashboardStats ? dashboardStats.totalToday.toString() : "..."}
description="Started today"
icon="today"
/>
<StatsCard
title="Failed Today"
value={dashboardStats ? dashboardStats.failedToday.toString() : "..."}
description={dashboardStats && dashboardStats.failedToday === 0 ? "No failures" : "Needs attention"}
icon="failed"
alert={!!dashboardStats && dashboardStats.failedToday > 0}
/>
{vulnCard && (
<StatsCard
title="Vulnerabilities"
value={vulnCard.value}
description={vulnCard.parts}
icon={vulnCard.alert ? "shieldAlert" : "shield"}
alert={vulnCard.alert}
muted={vulnCard.muted}
href={
vulnCard.link === "tab"
? appsHubUrl({ tab: "vulnerabilities", tenantId: crossTenant ? vulnTenantId ?? undefined : undefined })
: vulnCard.link === "settings"
? ANALYZER_SETTING_PATH
: undefined
}
linkOn={vulnCard.link === "settings" ? "description" : "title"}
onClick={() =>
vulnCard.link === "settings"
? onAnalyzerEnableClick("dashboard_card")
: trackEvent("vuln_link_clicked", { from: "dashboard_card", state: vulnCard.kind })
}
/>
)}
</div>
</div>
<TipOfTheDay />
@@ -627,6 +685,7 @@ function HomeContent() {
blockedDevicesSet={blockedDevicesSet}
isActivationPending={isActivationPending}
user={user}
ownTenantId={tenantId}
columnFilters={columnFilters}
onColumnFiltersChange={setColumnFilters}
onDeleteSessions={deleteSessions}
@@ -187,13 +187,15 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [
{ value: "local_admin_analysis", label: "local_admin_analysis", category: "diagnostics",
description: "Local administrator / user-profile audit detecting pre-enrollment admin account creation (an Autopilot bypass technique); emitted at startup and shutdown for delta detection." },
{ value: "autologon_analysis", label: "autologon_analysis", category: "diagnostics",
description: "Winlogon AutoLogon facts (raw, Info-only): AutoAdminLogon/ForceAutoLogon, default user/domain, AutoLogon count, and presence-only of a plaintext DefaultPassword. AutoLogon-enabled alone is not graded (Windows' own ESP auto-logon looks identical on every normal enrollment); backend analyze-rules grade only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) plus an optional kiosk allow-list template (ANALYZE-SEC-004). AutoLogon can be a legitimate kiosk or an enrollment/OOBE manipulation vector." },
description: "Winlogon AutoLogon facts (raw, Info-only): AutoAdminLogon/ForceAutoLogon, default user/domain (a sign-in address as its domain only), AutoLogon count, and presence-only of a plaintext DefaultPassword. AutoLogon-enabled alone is not graded (Windows' own ESP auto-logon looks identical on every normal enrollment); backend analyze-rules grade only a plaintext DefaultPassword on disk (ANALYZE-SEC-003). AutoLogon can be a legitimate kiosk or an enrollment/OOBE manipulation vector." },
{ value: "gather_result", label: "gather_result", category: "diagnostics",
description: "Output of another gather rule (can be chained)." },
{ value: "provisioning_package_scan", label: "provisioning_package_scan", category: "diagnostics",
description: "Single per-device provisioning-package (PPKG) scan at DeviceSetup-phase start. Carries an artifacts[] array — one entry per registry package, .ppkg file, or Recovery\\Customizations residue (each with a scalar identity) — plus .ppkg file metadata, registry package metadata, and best-effort content indicators. The PPKG analyze rules (ANALYZE-SEC-005/006) iterate the array against an allow-list regex. PPKGs can be legitimate bulk enrollment or a manipulation vector." },
{ value: "oobe_console_spawned", label: "oobe_console_spawned", category: "diagnostics",
description: "LIVE detection of 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)." },
];
@@ -1027,3 +1027,27 @@ html.auth-pending .landing-v2::after {
.dark .setup-tour-spot {
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.72);
}
/* Dashboard stat row with six cards (app/dashboard/components/StatsCards.tsx, statsGridClassName).
The columns follow the width the row itself gets, so the sidebar state and the full-width layout
count: six from 1126px (a 1440px window with the full sidebar), three from 553px, two from 362px,
below those a card title would be cut off. Plain CSS because the ascending order is the cascade:
Tailwind sorts arbitrary @container variants as text (1126 before 362), and the three-column rule
then won at every width. StatsCards.test.ts holds the order. */
.stats-row {
container-type: inline-size;
}
.stats-grid-6 {
display: grid;
gap: 1.25rem;
grid-template-columns: repeat(1, minmax(0, 1fr));
}
@container (min-width: 362px) {
.stats-grid-6 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@container (min-width: 553px) {
.stats-grid-6 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@container (min-width: 1126px) {
.stats-grid-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
}
@@ -1,6 +1,6 @@
"use client";
import { createContext, useContext, useState, useMemo, useDeferredValue } from "react";
import { createContext, useContext, useState, useMemo, useDeferredValue, useEffect, useRef } from "react";
import { EnrollmentEvent, Session } from "@/types";
import { normalizeEventDataForDisplay, shortenBuildHashInMessage } from "../lib/eventHelpers";
import { buildEventSearchMatcher, formatEventSearchTerm, parseEventSearchQuery } from "../lib/eventSearchQuery";
@@ -10,6 +10,7 @@ import { readTimeProvenance, classifyTimeJump, readClockChangeDeltaMs } from "@/
import { isRebootOrRetryClass } from "@/lib/installProgress";
import { formatDuration, formatUtcOffset } from "@/lib/formatting";
import { SCRIPT_OUTPUT_RECONCILIATION } from "@/lib/scriptExecutions";
import { isTypingInField } from "@/lib/keyboardTarget";
// runId → outcome of every platform run that IME's saved result corrected later (D-316): the
// run's own row says so, so its emitted output is never read as the script's.
@@ -19,7 +20,8 @@ const SEARCH_SYNTAX_HINT =
"Searches event type, message, source and the details JSON (e.g. gather output). " +
"Several terms are combined with AND; quote a phrase, e.g. \"Installation completed\". " +
"A leading minus hides matches, e.g. -app_install_progress. " +
"Restrict a term to one field with type=, message=, source= or data=, e.g. data=hpia-log-collect.";
"Restrict a term to one field with type=, message=, source= or data=, e.g. data=hpia-log-collect. " +
"Press / to jump here from anywhere on the page; Esc clears the search.";
interface EventTimelineProps {
filteredEvents: EnrollmentEvent[];
@@ -76,6 +78,26 @@ export default function EventTimeline({
// The input stays on the immediate value; everything derived from the query (matcher, counter,
// visible rows) follows the deferred one, so a keystroke never waits for the per-phase filter pass.
const deferredSearchQuery = useDeferredValue(searchQuery);
const searchRowRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// "/" jumps to the search from anywhere on the page, the web's usual key for a page's own search,
// the same on Windows and Mac: the controls row scrolls up below the navbar, so the matches fill
// the screen, and the previous query is selected for overtyping. Shift stays allowed, layouts
// like German type "/" as Shift+7; a "/" typed into a field belongs to that field.
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key !== "/" || e.ctrlKey || e.metaKey || e.altKey || e.isComposing || isTypingInField(e.target)) return;
const input = searchInputRef.current;
if (!input) return;
e.preventDefault();
input.focus({ preventScroll: true });
input.select();
searchRowRef.current?.scrollIntoView({ block: "start" });
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
// Terms are AND-ed, a leading minus excludes, key=value restricts — see lib/eventSearchQuery.ts.
const matchesSearch = useMemo(() => buildEventSearchMatcher(deferredSearchQuery), [deferredSearchQuery]);
@@ -126,20 +148,34 @@ export default function EventTimeline({
const timeline = (
<div className="space-y-6">
{/* Search + Severity filters + Expand/Collapse — shared controls above the timeline(s) */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<div ref={searchRowRef} className="flex flex-col sm:flex-row sm:items-center gap-2 scroll-mt-20">
{/* Search bar — full width on mobile, fixed width on desktop */}
<div className="relative w-full sm:w-48 flex-shrink-0">
<svg className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
// Esc clears the query and keeps the cursor in the box for the next term; Esc on an
// empty box leaves it. During IME composition Esc cancels the composition instead.
onKeyDown={(e) => {
if (e.key !== "Escape" || e.nativeEvent.isComposing) return;
if (searchQuery) setSearchQuery("");
else e.currentTarget.blur();
}}
placeholder="Search or -exclude..."
title={SEARCH_SYNTAX_HINT}
className="w-full pl-7 pr-7 py-1 text-xs border border-gray-300 rounded-full focus:outline-none focus:ring-1 focus:ring-green-500 focus:border-green-500"
/>
{/* Shortcut hint while the box is empty; phones have no hardware keyboard, so it stays off there. */}
{!searchQuery && (
<kbd className="hidden sm:inline absolute right-2.5 top-1/2 -translate-y-1/2 px-1.5 rounded border border-gray-200 bg-gray-50 font-sans text-[10px] leading-4 text-gray-400 pointer-events-none">
/
</kbd>
)}
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
@@ -1,7 +1,10 @@
"use client";
import { useState, useMemo } from "react";
import type { Route } from "next";
import Link from "next/link";
import type { EnrollmentEvent } from "@/types";
import { trackEvent } from "@/lib/appInsights";
import { CONTEXT_PILL_CLASS, formatEpss, epssTitle, priorityLabel, PRIORITY_TITLE } from "@/lib/vulnerabilitySeverity";
interface VulnerabilityReportSectionProps {
@@ -10,6 +13,8 @@ interface VulnerabilityReportSectionProps {
expanded: boolean;
setExpanded: (v: boolean) => void;
onRescan?: () => Promise<void> | void;
/** Software › Vulnerabilities in the session's tenant; absent where the hub cannot open that tenant. */
fleetHref?: Route;
}
type TabKey = "findings" | "inventory" | "delta";
@@ -128,6 +133,7 @@ export default function VulnerabilityReportSection({
expanded,
setExpanded,
onRescan,
fleetHref,
}: VulnerabilityReportSectionProps) {
const [activeTab, setActiveTab] = useState<TabKey>("findings");
const [rescanning, setRescanning] = useState(false);
@@ -446,7 +452,10 @@ export default function VulnerabilityReportSection({
<div className="bg-white shadow rounded-lg p-6 mb-6">
{/* Section Header */}
<div
onClick={() => setExpanded(!expanded)}
onClick={() => {
trackEvent("session_vuln_report_action", { action: expanded ? "collapse" : "expand" });
setExpanded(!expanded);
}}
className="flex items-start justify-between gap-2 w-full text-left mb-4 cursor-pointer"
>
<div className="flex items-center flex-wrap gap-x-2 gap-y-1 min-w-0">
@@ -490,10 +499,24 @@ export default function VulnerabilityReportSection({
)}
</div>
<div className="flex items-center space-x-3 shrink-0">
{fleetHref && (
<Link
href={fleetHref}
onClick={(e) => {
// The header toggles the section; the link only navigates.
e.stopPropagation();
trackEvent("vuln_link_clicked", { from: "session_report" });
}}
className="hidden sm:inline text-sm font-medium text-green-700 hover:underline"
>
Fleet-wide exposure →
</Link>
)}
{onRescan && (
<button
onClick={async (e) => {
e.stopPropagation();
trackEvent("session_vuln_report_action", { action: "rescan" });
setRescanning(true);
try { await onRescan(); }
finally { setRescanning(false); }
@@ -1,6 +1,6 @@
"use client";
import { diagnosisUrl } from "@/lib/routes";
import { appsHubUrl, diagnosisUrl } from "@/lib/routes";
import { Suspense, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
@@ -82,7 +82,7 @@ function SessionDetailForCurrentId() {
function SessionDetailContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { getAccessToken, user } = useAuth();
const { getAccessToken, user, hasGlobalScope } = useAuth();
// Query-string identity (`?id=`) — path params are gone with the static export;
// legacy /sessions/{id} URLs are rewritten here by LegacyPathRedirect.
// The raw value is attacker-deliverable (deep link) and useSearchParams returns it
@@ -167,14 +167,22 @@ function SessionDetailContent() {
// Cross-tenant read-only view: a delegated ("MSP") admin viewing a MANAGED tenant's session. The backend
// permits the reads (MemberRead + ?tenantId=, rescued by the delegated scope) but rejects the mutations
// (mark-failed/succeeded/report are TenantAdminOrGA). A Global Admin keeps write rights cross-tenant, and
// own-tenant viewers are unaffected. `tenantIdOverride` seeds this before the session object has loaded so
// the write controls never flash in.
// (mark-failed/succeeded are TenantAdminOrGA, report and actions TenantAdminOrOperator). A Global Admin keeps
// write rights cross-tenant, and own-tenant viewers are unaffected. `tenantIdOverride` seeds this before the
// session object has loaded so the write controls never flash in. Succeed/Fail additionally need Admin Mode,
// which takes effect only for a tenant Admin or Global Admin (useAdminMode).
const viewedTenantId = (session?.tenantId ?? sessionTenantId ?? tenantIdOverride ?? "").toLowerCase();
const isCrossTenantView = !!viewedTenantId && !!tenantId && viewedTenantId !== tenantId.toLowerCase();
const isReadOnlyView = isCrossTenantView && !user?.isGlobalAdmin;
// Report Session is an Admin/Operator route (Global Admins too); Viewers get no button that ends in a 403.
const canReportSession = (user?.isTenantAdmin ?? false) || user?.role === "Operator" || (user?.isGlobalAdmin ?? false);
// The Vulnerability Report's way to the fleet view: the own tenant, or a platform admin's jump into the
// session's tenant. None for a delegated view — the Software hub takes no tenant from a link for them.
const vulnFleetHref = !isCrossTenantView
? appsHubUrl({ tab: "vulnerabilities" })
: hasGlobalScope
? appsHubUrl({ tab: "vulnerabilities", tenantId: viewedTenantId })
: undefined;
// Browser-tab title: lead with the device identifier so multiple session tabs stay distinguishable when
// compared side by side (tabs truncate to the first chars). Pipe separator matches the root layout
@@ -285,27 +293,24 @@ function SessionDetailContent() {
const cancelMarkFailed = () => setShowMarkFailedConfirm(false);
const cancelMarkSucceeded = () => setShowMarkSucceededConfirm(false);
const confirmMarkFailed = async () => {
const markSession = async (outcome: 'Failed' | 'Succeeded') => {
const effectiveTenantId = sessionTenantId || tenantId;
const url = outcome === 'Failed'
? api.sessions.markFailed(sessionId, effectiveTenantId)
: api.sessions.markSucceeded(sessionId, effectiveTenantId);
try {
await fetchOk(api.sessions.markFailed(sessionId, effectiveTenantId), getAccessToken, { method: 'POST' });
setShowMarkFailedConfirm(false);
if (session) setSession({ ...session, status: 'Failed' });
await fetchOk(url, getAccessToken, { method: 'POST' });
if (session) setSession({ ...session, status: outcome });
} catch (error) {
console.error('Error marking session as failed:', error);
}
};
const confirmMarkSucceeded = async () => {
const effectiveTenantId = sessionTenantId || tenantId;
try {
await fetchOk(api.sessions.markSucceeded(sessionId, effectiveTenantId), getAccessToken, { method: 'POST' });
setShowMarkSucceededConfirm(false);
if (session) setSession({ ...session, status: 'Succeeded' });
} catch (error) {
console.error('Error marking session as succeeded:', error);
const verb = outcome.toLowerCase();
notifyError(`Mark as ${verb}`, error, 'mark-session-error', `The session could not be marked as ${verb}.`);
} finally {
if (outcome === 'Failed') setShowMarkFailedConfirm(false);
else setShowMarkSucceededConfirm(false);
}
};
const confirmMarkFailed = () => markSession('Failed');
const confirmMarkSucceeded = () => markSession('Succeeded');
const handleSubmitReport = async (submission: ReportSubmission) => {
const effectiveTenantId = sessionTenantId || tenantId;
@@ -751,6 +756,7 @@ function SessionDetailContent() {
expanded={vulnerabilityReportExpanded}
setExpanded={setVulnerabilityReportExpanded}
onRescan={isReadOnlyView ? undefined : () => fetchVulnerabilityReport(true)}
fleetHref={vulnFleetHref}
/>
</div>
)}
@@ -44,24 +44,38 @@ function CloseButton() {
);
}
const SHOW_ME: Record<Exclude<SetupTour, "validation">, { anchors: readonly string[]; title: string; crumb: string; text: string }> = {
/** `fromGuide`: opened by the guide's "Show me where", so the card says so and leads back to it. */
const SHOW_ME: Record<
Exclude<SetupTour, "validation">,
{ anchors: readonly string[]; title: string; crumb: string; text: string; fromGuide: boolean }
> = {
"showme:timezone": {
anchors: ["timezone", "geo-location"],
title: "Set Timezone Automatically",
crumb: "Agent › Agent Settings › Agent Parameters",
text: "The guide turns it on if you keep it checked. You can switch it here at any time. It appears once Geo-Location Detection is on.",
fromGuide: true,
},
"showme:diagnostics": {
anchors: ["diagnostics-upload"],
title: "Upload destination",
crumb: "Agent › Diagnostics Package",
text: "The guide picks hosted storage here, or keeps your own storage account. Upload Mode, further down on this page, sets when a package is uploaded. You can change both at any time.",
fromGuide: true,
},
"showme:inventory": {
anchors: ["software-inventory"],
title: "Software Inventory & Vulnerability Analyzer",
crumb: "Agent › Agent Analyzers",
text: "The guide turns it on. Its findings show up in each session and under Software.",
fromGuide: true,
},
"spot:inventory": {
anchors: ["software-inventory"],
title: "Software Inventory & Vulnerability Analyzer",
crumb: "Agent › Agent Analyzers",
text: "Turn it on here and save. Enrollments are scanned from then on; findings appear in each session and under Software › Vulnerabilities.",
fromGuide: false,
},
};
@@ -72,14 +86,18 @@ function ShowMeTour({ tour }: { tour: Exclude<SetupTour, "validation"> }) {
openSetupGuide("showme", "settings");
};
return (
<TourSpotlight anchors={text.anchors} label="Setup guide hint" onClose={endSetupTour}>
<TourSpotlight anchors={text.anchors} label={text.fromGuide ? "Setup guide hint" : "Settings hint"} onClose={endSetupTour}>
<div className="space-y-2">
<p className={eyebrow}>Setup guide · Where to find it<CloseButton /></p>
<p className={eyebrow}>{text.fromGuide ? "Setup guide · Where to find it" : "Where to find it"}<CloseButton /></p>
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">{text.title}</h3>
<p className="text-xs text-gray-400">{text.crumb}</p>
<p className="text-sm text-gray-600 dark:text-gray-300">{text.text}</p>
<div className="pt-1">
<button type="button" className={primaryButton} onClick={back}>Back to the guide</button>
{text.fromGuide ? (
<button type="button" className={primaryButton} onClick={back}>Back to the guide</button>
) : (
<button type="button" className={primaryButton} onClick={endSetupTour}>Got it</button>
)}
</div>
</div>
</TourSpotlight>
@@ -15,6 +15,7 @@ import { UserAvatar } from './UserAvatar';
import { trackEvent } from '@/lib/appInsights';
import { useAdminMode } from '@/hooks/useAdminMode';
import { useEditionInfo } from '@/hooks/useEditionInfo';
import { canUseAdminMode } from '@/lib/tenantScope';
import GlobalSearch from './GlobalSearch';
import { DOCS_URL } from "@/lib/config";
import { DOCS_PATHS } from "@/lib/docsPaths";
@@ -137,10 +138,12 @@ export default function Navbar() {
const isOperator = user?.role === 'Operator';
const isViewer = user?.role === 'Viewer';
const isAdminOrOperator = isTenantAdmin || isOperator;
// The Admin Mode toggle shows for exactly the users in whose hands it takes effect (useAdminMode).
const canToggleAdminMode = canUseAdminMode(user);
// Everything the settings menu can hold. With no toggle applicable — an Operator/Viewer, or a
// Global Admin presenting in demo mode without an own tenant-admin role — the menu would open
// onto an empty panel, so the gear (and the mobile "Administration" block) stays hidden.
const showAdminToggles = isTenantAdmin || (hasGlobalScope && !demoMode);
// Global Reader presenting in demo mode — the menu would open onto an empty panel, so the gear
// (and the mobile "Administration" block) stays hidden.
const showAdminToggles = canToggleAdminMode || (hasGlobalScope && !demoMode);
// Tenant notification dismiss is currently tenant-shared (clearing for one user clears for
// all). Only Admins / Global Admins are permitted to dismiss; Operators and Viewers see the
// bell as read-only. See TenantNotificationContext + EndpointAccessPolicyCatalog.
@@ -442,7 +445,7 @@ export default function Navbar() {
<div className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 z-50 max-h-[32rem] overflow-y-auto">
<div className="p-3">
{/* MODE TOGGLES */}
{isTenantAdmin && (
{canToggleAdminMode && (
<>
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-400 mb-2">Administration</p>
@@ -695,7 +698,7 @@ export default function Navbar() {
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-400 mb-2">Administration</p>
{/* Admin Mode Toggle */}
{isTenantAdmin && (
{canToggleAdminMode && (
<div className="flex items-center justify-between py-2 px-2.5 rounded-md bg-gray-50 dark:bg-gray-700 mb-1">
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { useState, type ReactNode } from "react";
import type { CveExposureSummary } from "@/lib/generated/wire-types.generated";
import { toggleFilter, type ExposureFilter } from "@/lib/vulnerabilityExposure";
import { isFilterActive, toggleFilter, type ExposureAction, type ExposureFilter } from "@/lib/vulnerabilityExposure";
import { CalculatingDialog } from "@/components/CalculatingCard";
import ExposureKpiRow from "@/components/vulnerability/ExposureKpiRow";
import SeverityPriorityCard from "@/components/vulnerability/SeverityPriorityCard";
@@ -22,6 +22,10 @@ interface Props {
title?: string;
/** Elapsed/estimate of the running fetch (useFetchProgress); without it the loading state is plain text. */
progress?: { elapsedMs: number; estimateMs: number };
/** Replaces the default "no findings" text, e.g. when scanning is off for the tenant. */
emptyState?: ReactNode;
/** Each filter, lens, sort or expand step the viewer takes (telemetry of the tenant-facing tab). */
onAction?: (action: ExposureAction) => void;
}
/**
@@ -41,8 +45,18 @@ export default function VulnerabilityExposurePanel({
refreshing = false,
title,
progress,
emptyState,
onAction,
}: Props) {
const [filter, setFilter] = useState<ExposureFilter>(null);
const toggle = (kind: "severity" | "priority", value: string) => {
onAction?.({ action: "filter", value: `${kind}:${value}`, active: !isFilterActive(filter, kind, value) });
setFilter((prev) => toggleFilter(prev, kind, value));
};
const clearFilter = () => {
onAction?.({ action: "clear_filter" });
setFilter(null);
};
const hasFindings = summary != null && summary.distinctCves > 0;
const showHeader = title != null || onDaysChange != null || onRefresh != null;
const progressPercent = progress ? Math.min(95, (progress.elapsedMs / progress.estimateMs) * 100) : 0;
@@ -69,7 +83,7 @@ export default function VulnerabilityExposurePanel({
</div>
);
} else if (!hasFindings) {
body = (
body = emptyState ?? (
<div className="bg-white rounded-lg shadow p-8 text-center text-sm text-gray-500">
No vulnerability findings in this window. An empty result means no detected CVEs; it does not by itself confirm
the fleet is unaffected, because vulnerability scanning must be enabled.
@@ -99,12 +113,8 @@ export default function VulnerabilityExposurePanel({
<ExposureKpiRow summary={summary!} showTenantCount={showTenantCount} />
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-5">
<SeverityPriorityCard
summary={summary!}
filter={filter}
onToggle={(kind, value) => setFilter((prev) => toggleFilter(prev, kind, value))}
/>
<MostExposedCard summary={summary!} filter={filter} onClearFilter={() => setFilter(null)} />
<SeverityPriorityCard summary={summary!} filter={filter} onToggle={toggle} />
<MostExposedCard summary={summary!} filter={filter} onClearFilter={clearFilter} onAction={onAction} />
</div>
<p className="text-xs text-gray-500">
@@ -0,0 +1,73 @@
"use client";
import Link from "next/link";
import { trackEvent } from "@/lib/appInsights";
import { SETUP_TOUR_TARGETS } from "@/lib/setupGuide";
import { startSetupTour } from "@/lib/setupGuideStore";
/** Where a "turn on" link was clicked — the `from` of `vuln_enable_clicked`. */
export type AnalyzerEnableSource = "dashboard_card" | "vulnerabilities_tab" | "inventory_tab";
/** The analyzer setting with its spotlight (app/settings/SetupGuideTour.tsx, tour `spot:inventory`). */
export const ANALYZER_SETTING_PATH = SETUP_TOUR_TARGETS["spot:inventory"].path;
/** Starts the spotlight on the analyzer switch and counts the click; the link itself navigates. */
export function onAnalyzerEnableClick(from: AnalyzerEnableSource): void {
startSetupTour("spot:inventory");
trackEvent("vuln_enable_clicked", { from });
}
const TITLES = {
vulnerabilities: "Vulnerability scanning is off for this tenant",
inventory: "Software inventory is off for this tenant",
} as const;
/**
* Empty Software › Vulnerabilities or Inventory tab of a tenant whose Software Inventory & Vulnerability
* Analyzer is off: says so and leads to the switch. Only `canEnable` users get the link (the Settings
* pages save the own tenant through TenantAdminOrGA); everyone else learns who can turn it on.
*/
export function AnalyzerOffNotice({
subject,
canEnable,
}: {
subject: keyof typeof TITLES;
canEnable: boolean;
}) {
const from: AnalyzerEnableSource = subject === "inventory" ? "inventory_tab" : "vulnerabilities_tab";
return (
<div className="bg-white rounded-lg shadow px-6 py-10 text-center">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="mx-auto h-8 w-8 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 21.5s7.5-3.6 7.5-9.4V5.4L12 2.5 4.5 5.4v6.7c0 5.8 7.5 9.4 7.5 9.4z" />
</svg>
<h3 className="mt-3 text-base font-semibold text-gray-900">{TITLES[subject]}</h3>
<p className="mx-auto mt-1 max-w-xl text-sm text-gray-500">
The Software Inventory &amp; Vulnerability Analyzer lists the software installed at enrollment start and end
and matches it against known vulnerabilities (NVD, CISA KEV, MSRC). Enrollments are scanned from the moment
it is on.
</p>
{canEnable ? (
<Link
href={ANALYZER_SETTING_PATH}
onClick={() => onAnalyzerEnableClick(from)}
className="mt-5 inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-green-500 focus-visible:ring-offset-2"
>
Turn on in Settings
</Link>
) : (
<p className="mt-4 text-sm text-gray-500">
A tenant admin can turn it on under Settings › Agent › Agent Analyzers.
</p>
)}
</div>
);
}
@@ -11,6 +11,7 @@ import {
listPool,
matchesFilter,
sortExposure,
type ExposureAction,
type ExposureFilter,
type ExposureSort,
} from "@/lib/vulnerabilityExposure";
@@ -54,10 +55,13 @@ export default function MostExposedCard({
summary,
filter,
onClearFilter,
onAction,
}: {
summary: CveExposureSummary;
filter: ExposureFilter;
onClearFilter: () => void;
/** Lens, sort and expand steps, for telemetry (an expand reports the product's priority, never its name). */
onAction?: (action: ExposureAction) => void;
}) {
const [lens, setLens] = useState<Lens>("software");
const [sort, setSort] = useState<ExposureSort>("priority");
@@ -83,13 +87,15 @@ export default function MostExposedCard({
const label = filterLabel(filter);
const inBand = bandCount(summary, filter);
const toggle = (product: string) =>
const toggle = (item: TopSoftware) => {
if (!expanded.has(item.product)) onAction?.({ action: "expand", value: (item.priority || "unknown").toLowerCase() });
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(product)) next.delete(product);
else next.add(product);
if (next.has(item.product)) next.delete(item.product);
else next.add(item.product);
return next;
});
};
return (
<section aria-label="Most exposed" className="bg-white rounded-lg shadow min-w-0 lg:col-span-3">
@@ -107,9 +113,19 @@ export default function MostExposedCard({
{ value: "cves", label: `CVEs (${cves.length})` },
]}
value={lens}
onChange={(v) => setLens(v as Lens)}
onChange={(v) => {
onAction?.({ action: "lens", value: v });
setLens(v as Lens);
}}
/>
<SegmentedControl
options={SORT_OPTIONS}
value={sort}
onChange={(v) => {
onAction?.({ action: "sort", value: v });
setSort(v as ExposureSort);
}}
/>
<SegmentedControl options={SORT_OPTIONS} value={sort} onChange={(v) => setSort(v as ExposureSort)} />
</div>
</div>
@@ -148,7 +164,7 @@ export default function MostExposedCard({
item={s}
max={maxSoftware}
open={expanded.has(s.product)}
onToggle={() => toggle(s.product)}
onToggle={() => toggle(s)}
/>
))}
</ul>
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { AnalyzerOffNotice, ANALYZER_SETTING_PATH } from "../AnalyzerOffNotice";
const render = (subject: "vulnerabilities" | "inventory", canEnable: boolean) =>
renderToStaticMarkup(createElement(AnalyzerOffNotice, { subject, canEnable }));
describe("AnalyzerOffNotice", () => {
it("names what is off for each tab", () => {
expect(render("vulnerabilities", false)).toContain("Vulnerability scanning is off for this tenant");
expect(render("inventory", false)).toContain("Software inventory is off for this tenant");
});
it("leads to the analyzer setting only who can turn it on", () => {
expect(ANALYZER_SETTING_PATH).toBe("/settings/agent/analyzers");
const admin = render("vulnerabilities", true);
expect(admin).toContain(`href="${ANALYZER_SETTING_PATH}"`);
expect(admin).toContain("Turn on in Settings");
const member = render("vulnerabilities", false);
expect(member).not.toContain("<a ");
expect(member).toContain("A tenant admin can turn it on");
});
});
@@ -1,12 +1,14 @@
"use client";
import { useState, useEffect } from "react";
import { useAuth } from "@/contexts/AuthContext";
import {
DEMO_MODE_STORAGE_KEY,
effectiveGlobalAdminMode,
readDemoParam,
stripDemoParam,
} from "@/lib/demoMode";
import { canUseAdminMode } from "@/lib/tenantScope";
import { readViewAs } from "@/lib/viewAs";
interface UseAdminModeReturn {
@@ -144,9 +146,15 @@ export function useAdminMode(): UseAdminModeReturn {
// write would otherwise reach every other tab through the storage event. Fixed for the page's life.
const forcedOff = demoMode || readViewAs() !== null;
// The stored flag is shared by every account in this browser. It takes effect only for a role whose
// backend tier admits the Admin Mode actions; the stored value stays, so the admin account that set it
// finds it unchanged.
const { user } = useAuth();
const adminModeAllowed = canUseAdminMode(user);
return {
adminMode,
setAdminMode: setAdminModeState,
adminMode: adminMode && adminModeAllowed,
setAdminMode: adminModeAllowed ? setAdminModeState : () => {},
globalAdminMode: effectiveGlobalAdminMode(globalAdminMode, forcedOff),
// No-op while presenting or viewing as a tenant. The toggle is hidden then anyway; this keeps any
// other caller from re-arming the global view behind the operator's back.
@@ -0,0 +1,58 @@
"use client";
import { useEffect, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import { fetchJson } from "@/lib/apiClient";
import { cachedAuthFetchJson, FEATURE_FLAGS_TTL_MS } from "@/lib/cachedAuthFetch";
import type { CveExposureSummary, TenantFeatureFlagsResponse } from "@/lib/generated/wire-types.generated";
import { scopedApi, type TenantScopeSelection } from "@/lib/scopedApi";
import { headlineFromSummary, type VulnerabilityHeadline } from "@/lib/vulnerabilityHeadline";
/**
* One tenant's analyzer switch and vulnerability headline counts, for the dashboard card and the
* Software hub's tab badge. `sel` routes the counts like every Software-hub read; `tenantId` names the
* tenant whose feature flags say whether scanning is on. Pass null for either while no single tenant
* is in view (an aggregate, or a scope still resolving): nothing is read, the cross-tenant scan is the
* slow path and a per-tenant switch means nothing there.
*
* The counts come uncached (the tenant route answers in ~16 ms; lib/cachedAuthFetch is for /api/config
* reads only), the flags through the shared feature-flags cache like every other reader of them.
*/
export function useVulnerabilityHeadline(
sel: TenantScopeSelection | null,
tenantId: string | null,
days: number,
): VulnerabilityHeadline {
const { getAccessToken } = useAuth();
const routeGlobal = sel?.routeGlobal ?? false;
const selectedTenantId = sel?.selectedTenantId ?? "";
const effectiveTenantId = sel?.effectiveTenantId ?? "";
const key = sel && tenantId ? `${routeGlobal ? "g" : "t"}|${selectedTenantId}|${effectiveTenantId}|${tenantId}|${days}` : null;
const [result, setResult] = useState<{ key: string; headline: VulnerabilityHeadline } | null>(null);
useEffect(() => {
if (!key || !tenantId) return;
let cancelled = false;
const selection: TenantScopeSelection = { routeGlobal, selectedTenantId, effectiveTenantId };
const run = async () => {
let headline: VulnerabilityHeadline;
try {
const [flags, summary] = await Promise.all([
cachedAuthFetchJson<TenantFeatureFlagsResponse>(api.config.featureFlags(tenantId), getAccessToken, { ttlMs: FEATURE_FLAGS_TTL_MS }),
fetchJson<CveExposureSummary>(scopedApi.vulnerability(selection, days, 1), getAccessToken),
]);
headline = headlineFromSummary(flags.enableSoftwareInventoryAnalyzer ?? false, summary);
} catch {
// Fail soft: the card shows "not available", the badge stays away; the pages say why they failed.
headline = { status: "error" };
}
if (!cancelled) setResult({ key, headline });
};
void run();
return () => { cancelled = true; };
}, [key, routeGlobal, selectedTenantId, effectiveTenantId, tenantId, days, getAccessToken]);
if (!key) return { status: "skipped" };
return result?.key === key ? result.headline : { status: "loading" };
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { appsHubUrl } from "../routes";
describe("appsHubUrl", () => {
it("is the bare hub by default", () => {
expect(appsHubUrl()).toBe("/apps");
});
it("carries a tab, a window and a tenant", () => {
expect(appsHubUrl({ tab: "vulnerabilities" })).toBe("/apps?tab=vulnerabilities");
expect(appsHubUrl({ days: 90 })).toBe("/apps?days=90");
expect(appsHubUrl({ tab: "vulnerabilities", days: 7, tenantId: "11111111-1111-1111-1111-111111111111" })).toBe(
"/apps?tab=vulnerabilities&days=7&tenantId=11111111-1111-1111-1111-111111111111",
);
});
it("leaves out an empty tenant", () => {
expect(appsHubUrl({ tab: "inventory", tenantId: "" })).toBe("/apps?tab=inventory");
});
});
@@ -135,7 +135,9 @@ describe("parseSetupTour", () => {
it("accepts the known tours only", () => {
expect(parseSetupTour("validation")).toBe("validation");
expect(parseSetupTour("showme:diagnostics")).toBe("showme:diagnostics");
expect(parseSetupTour("spot:inventory")).toBe("spot:inventory");
expect(parseSetupTour("showme:elsewhere")).toBeNull();
expect(parseSetupTour("spot:elsewhere")).toBeNull();
expect(parseSetupTour(null)).toBeNull();
});
});
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import {
canAdministerTenant,
canChangeOwnTenantSettings,
canManageBootstrapSessions,
canUseAdminMode,
hasOwnTenantOrPlatformRole,
hasTenantReadScope,
type TenantScopeUser,
@@ -83,6 +86,95 @@ describe("hasTenantReadScope", () => {
});
});
describe("canUseAdminMode (mirror of TenantAdminOrGA)", () => {
it("is false without a user (auth still loading) and for a roleless member", () => {
expect(canUseAdminMode(null)).toBe(false);
expect(canUseAdminMode(undefined)).toBe(false);
expect(canUseAdminMode(user())).toBe(false);
});
it("is true for the tenant Admin and a Global Admin without a tenant role", () => {
expect(canUseAdminMode(user({ isTenantAdmin: true, role: "Admin" }))).toBe(true);
expect(canUseAdminMode(user({ role: "Admin" }))).toBe(true);
expect(canUseAdminMode(user({ isGlobalAdmin: true }))).toBe(true);
});
it("is false for Operator, Viewer, Global Reader and a delegated admin", () => {
expect(canUseAdminMode(user({ role: "Operator" }))).toBe(false);
expect(canUseAdminMode(user({ role: "Operator", canManageBootstrapTokens: true }))).toBe(false);
expect(canUseAdminMode(user({ role: "Viewer" }))).toBe(false);
expect(canUseAdminMode(user({ isGlobalReader: true }))).toBe(false);
expect(canUseAdminMode(user({ isDelegated: true }))).toBe(false);
});
it("follows the emulated role in a view-as tab (no platform role there)", () => {
expect(canUseAdminMode(user({ isTenantAdmin: true, role: "Admin" }))).toBe(true);
expect(canUseAdminMode(user({ role: "Operator" }))).toBe(false);
expect(canUseAdminMode(user({ role: "Viewer" }))).toBe(false);
});
});
describe("canAdministerTenant (TenantAdminOrGA with the session's tenant)", () => {
const own = "11111111-1111-1111-1111-111111111111";
const other = "22222222-2222-2222-2222-222222222222";
it("lets a Global Admin act on any tenant", () => {
expect(canAdministerTenant(user({ isGlobalAdmin: true }), other, own)).toBe(true);
expect(canAdministerTenant(user({ isGlobalAdmin: true }), own, own)).toBe(true);
});
it("lets the tenant Admin act on the own tenant only, case-insensitively", () => {
const admin = user({ isTenantAdmin: true, role: "Admin" });
expect(canAdministerTenant(admin, own.toUpperCase(), own)).toBe(true);
expect(canAdministerTenant(admin, other, own)).toBe(false);
});
it("refuses a delegated admin or Global Reader on a foreign tenant even with an own Admin role", () => {
expect(canAdministerTenant(user({ isDelegated: true, isTenantAdmin: true, role: "Admin" }), other, own)).toBe(false);
expect(canAdministerTenant(user({ isGlobalReader: true, isTenantAdmin: true, role: "Admin" }), other, own)).toBe(false);
});
it("refuses Operator, Viewer, a missing user and unknown tenants", () => {
expect(canAdministerTenant(user({ role: "Operator" }), own, own)).toBe(false);
expect(canAdministerTenant(user({ role: "Viewer" }), own, own)).toBe(false);
expect(canAdministerTenant(null, own, own)).toBe(false);
expect(canAdministerTenant(user({ isTenantAdmin: true }), "", own)).toBe(false);
expect(canAdministerTenant(user({ isTenantAdmin: true }), own, null)).toBe(false);
});
});
describe("canChangeOwnTenantSettings (Settings pages: own tenant, TenantAdminOrGA, no view-as)", () => {
const own = "11111111-1111-1111-1111-111111111111";
const other = "22222222-2222-2222-2222-222222222222";
it("lets the tenant Admin and a Global Admin change their own tenant, case-insensitively", () => {
expect(canChangeOwnTenantSettings(user({ isTenantAdmin: true, role: "Admin" }), own.toUpperCase(), own)).toBe(true);
expect(canChangeOwnTenantSettings(user({ role: "Admin" }), own, own)).toBe(true);
expect(canChangeOwnTenantSettings(user({ isGlobalAdmin: true }), own, own)).toBe(true);
});
it("refuses every foreign tenant, a Global Admin's too: the Settings pages would edit the home tenant", () => {
expect(canChangeOwnTenantSettings(user({ isGlobalAdmin: true }), other, own)).toBe(false);
expect(canChangeOwnTenantSettings(user({ isTenantAdmin: true, role: "Admin" }), other, own)).toBe(false);
expect(canChangeOwnTenantSettings(user({ isDelegated: true, isTenantAdmin: true, role: "Admin" }), other, own)).toBe(false);
});
it("lets a delegated admin with an own Admin role change the own tenant", () => {
expect(canChangeOwnTenantSettings(user({ isDelegated: true, isTenantAdmin: true, role: "Admin" }), own, own)).toBe(true);
});
it("refuses Operator, Viewer, Global Reader, a view-as tab and missing input", () => {
expect(canChangeOwnTenantSettings(user({ role: "Operator" }), own, own)).toBe(false);
expect(canChangeOwnTenantSettings(user({ role: "Viewer" }), own, own)).toBe(false);
expect(canChangeOwnTenantSettings(user({ isGlobalReader: true }), own, own)).toBe(false);
expect(canChangeOwnTenantSettings(user({ isTenantAdmin: true, role: "Admin", viewAs: { homeTenantId: other } }), own, own)).toBe(false);
expect(canChangeOwnTenantSettings(user({ role: "Operator", viewAs: { homeTenantId: other } }), own, own)).toBe(false);
expect(canChangeOwnTenantSettings(null, own, own)).toBe(false);
expect(canChangeOwnTenantSettings(user({ isTenantAdmin: true }), null, own)).toBe(false);
expect(canChangeOwnTenantSettings(user({ isTenantAdmin: true }), own, "")).toBe(false);
});
});
describe("canManageBootstrapSessions (mirror of BootstrapManagerOrGA)", () => {
it("is false for null/undefined and a roleless user", () => {
expect(canManageBootstrapSessions(null)).toBe(false);
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import type { CveExposureSummary } from "@/lib/generated/wire-types.generated";
import { headlineFromSummary, vulnerabilityCardState, type VulnerabilityHeadline } from "../vulnerabilityHeadline";
const ready = (overrides: Partial<Extract<VulnerabilityHeadline, { status: "ready" }>> = {}): VulnerabilityHeadline => ({
status: "ready",
analyzerOn: true,
kev: 0,
kevDevices: 0,
highRisk: 0,
cves: 0,
...overrides,
});
const card = (headline: VulnerabilityHeadline, canEnable = false) => vulnerabilityCardState(headline, { canEnable, days: 30 });
describe("headlineFromSummary", () => {
it("takes the tab's known-exploited count, not the act band", () => {
const summary = {
kevCves: 3,
kevAffectedSessions: 41,
distinctCves: 283,
priorityBreakdown: { act: 2, attend: 12, track: 269 },
} as unknown as CveExposureSummary;
expect(headlineFromSummary(false, summary)).toEqual({
status: "ready",
analyzerOn: false,
kev: 3,
kevDevices: 41,
highRisk: 12,
cves: 283,
});
});
});
describe("vulnerabilityCardState", () => {
it("shows nothing while no single tenant is in view", () => {
expect(card({ status: "skipped" })).toBeNull();
});
it("holds the place while loading and says when the numbers are unavailable", () => {
expect(card({ status: "loading" })).toMatchObject({ kind: "loading", value: "...", link: null, alert: false });
expect(card({ status: "error" })).toMatchObject({ kind: "error", value: "—", parts: ["Not available"], muted: true, link: null });
});
it("turns red only for known exploited CVEs and leads to the tab", () => {
expect(card(ready({ kev: 3, kevDevices: 41, highRisk: 12, cves: 283 }))).toEqual({
kind: "kev",
value: "3",
parts: ["Known exploited", "41 devices"],
alert: true,
muted: false,
link: "tab",
});
expect(card(ready({ kev: 1, kevDevices: 1, cves: 1 }))?.parts).toEqual(["Known exploited", "1 device"]);
});
it("names the high-risk band when nothing is known exploited", () => {
expect(card(ready({ highRisk: 12, cves: 40 }))).toEqual({
kind: "high_risk",
value: "0",
parts: ["Known exploited", "12 high risk"],
alert: false,
muted: false,
link: "tab",
});
});
it("counts the CVEs when only the track band is left", () => {
expect(card(ready({ cves: 7 }))).toMatchObject({ kind: "tracked", value: "0", parts: ["Known exploited", "7 CVEs"], link: "tab" });
});
it("says no CVEs were detected in the window without calling the tenant safe", () => {
expect(card(ready())).toEqual({
kind: "none",
value: "0",
parts: ["No CVEs detected", "30 days"],
alert: false,
muted: false,
link: "tab",
});
});
it("shows Off with the way to the setting only to who can turn it on", () => {
expect(card(ready({ analyzerOn: false }), true)).toEqual({
kind: "off_admin",
value: "Off",
parts: ["Turn on scanning →"],
alert: false,
muted: true,
link: "settings",
});
expect(card(ready({ analyzerOn: false }), false)).toEqual({
kind: "off_member",
value: "Off",
parts: ["Ask a tenant admin"],
alert: false,
muted: true,
link: null,
});
});
it("keeps showing findings from before the analyzer was turned off", () => {
expect(card(ready({ analyzerOn: false, kev: 2, kevDevices: 5, cves: 9 }), true)).toMatchObject({ kind: "kev", link: "tab" });
});
});
@@ -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",
@@ -0,0 +1,6 @@
// Page-wide key handlers stay out of the way while the user types: a key pressed in a text field
// belongs to that field, not to a shortcut.
export function isTypingInField(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return target.isContentEditable || target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT";
}
+9 -3
View File
@@ -62,9 +62,15 @@ export function appDetailUrl(
});
}
/** The Software hub; `days` carries a window other than the hub's default. */
export function appsHubUrl(opts?: { days?: number }): Route {
return withQuery("/apps", { days: opts?.days?.toString() });
/** Software hub tabs a link can open; Installs is the default and never in the URL. */
export type AppsHubTab = "inventory" | "vulnerabilities";
/**
* The Software hub. `days` carries a window other than the hub's default. `tenantId` opens a Global
* Admin on that tenant (the hub seeds its scope from it on first load); everyone else keeps their scope.
*/
export function appsHubUrl(opts?: { tab?: AppsHubTab; days?: number; tenantId?: string }): Route {
return withQuery("/apps", { tab: opts?.tab, days: opts?.days?.toString(), tenantId: opts?.tenantId });
}
export function dashboardUrl(opts?: {
@@ -150,7 +150,11 @@ export const AUTOPILOT_DEVICE_GROUP_RULE = '(device.devicePhysicalIds -any _ -st
// ── Spotlight tours on settings pages ────────────────────────────────────────────────────────
export const SETUP_TOURS = ["validation", "showme:timezone", "showme:diagnostics", "showme:inventory"] as const;
/**
* `showme:*` come from the setup guide and lead back to it; `spot:inventory` is the same spotlight
* opened from outside the guide (the dashboard's vulnerability card, the Software hub's notices).
*/
export const SETUP_TOURS = ["validation", "showme:timezone", "showme:diagnostics", "showme:inventory", "spot:inventory"] as const;
export type SetupTour = (typeof SETUP_TOURS)[number];
/**
@@ -162,6 +166,7 @@ export const SETUP_TOUR_TARGETS: Record<SetupTour, { path: Route }> = {
"showme:timezone": { path: route("/settings/agent/settings") },
"showme:diagnostics": { path: route("/settings/agent/diagnostics") },
"showme:inventory": { path: route("/settings/agent/analyzers") },
"spot:inventory": { path: route("/settings/agent/analyzers") },
};
export const SHOW_ME_TOUR: Record<RecommendedItem, SetupTour> = {
@@ -12,6 +12,8 @@ export interface TenantScopeUser {
isDelegated?: boolean;
role?: string | null;
canManageBootstrapTokens?: boolean;
/** Set while a Global Admin views a tenant as its member (lib/viewAs.ts); every write is refused there. */
viewAs?: object | null;
}
/** The three assignable tenant roles (mirrors Constants.TenantRoles in the backend). */
@@ -52,6 +54,48 @@ export function hasTenantReadScope(
return hasOwnTenantOrPlatformRole(user) || !!user.isDelegated;
}
/**
* True when Admin Mode may take effect for the user: the tenant Admin or a Global Admin, the roles
* the backend's TenantAdminOrGA tier admits for the actions behind it (delete, mark succeeded or
* failed). The toggle shows for exactly these users, and a stored "on" from another account in the
* same browser stays inert for everyone else.
*/
export function canUseAdminMode(user: TenantScopeUser | null | undefined): boolean {
if (!user) return false;
return !!(user.isGlobalAdmin || user.isTenantAdmin || user.role === "Admin");
}
/**
* True when the user may run Admin Mode actions on a session of `sessionTenantId`: a Global Admin
* on any tenant, the tenant Admin only on their own (`ownTenantId`). A delegated or Global Reader
* view of a foreign tenant gets no write control, because the backend refuses it there.
*/
export function canAdministerTenant(
user: TenantScopeUser | null | undefined,
sessionTenantId: string | null | undefined,
ownTenantId: string | null | undefined
): boolean {
if (!user) return false;
if (user.isGlobalAdmin) return true;
if (!canUseAdminMode(user) || !sessionTenantId || !ownTenantId) return false;
return sessionTenantId.toLowerCase() === ownTenantId.toLowerCase();
}
/**
* True when the user may change an agent setting of `viewedTenantId` from the portal: the Settings pages
* always edit the caller's own tenant and save through TenantAdminOrGA, and the Global Admin tenant
* editor has no agent-analyzer fields. So: the own tenant, Admin Mode roles, and never a view-as tab
* (it shows an Admin role, but the backend refuses every write there).
*/
export function canChangeOwnTenantSettings(
user: TenantScopeUser | null | undefined,
viewedTenantId: string | null | undefined,
ownTenantId: string | null | undefined
): boolean {
if (!user || user.viewAs || !viewedTenantId || !ownTenantId) return false;
return canUseAdminMode(user) && viewedTenantId.toLowerCase() === ownTenantId.toLowerCase();
}
/**
* True when the user may list and manage their OWN tenant's bootstrap sessions — the client
* mirror of the backend's BootstrapManagerOrGA tier: Global Admin, the tenant Admin, or an
@@ -12,6 +12,17 @@ export type ExposureFilter = { kind: "severity" | "priority"; value: string } |
export type ExposureSort = "priority" | "devices";
/**
* A step a viewer takes in the exposure panel, reported as `vuln_exposure_action`. `value` is a band,
* lens, sort or priority — never a CVE id or a product name.
*/
export interface ExposureAction {
action: "filter" | "clear_filter" | "lens" | "sort" | "expand";
value?: string;
/** Filter only: whether the toggle switched the band on. */
active?: boolean;
}
/** The fields a filter reads; a TopCve, a TopSoftware entry and a SoftwareCve all carry them. */
interface Banded {
cvssSeverity: string;
@@ -0,0 +1,95 @@
import type { CveExposureSummary } from "@/lib/generated/wire-types.generated";
/**
* Whether one tenant scans for vulnerabilities and its headline counts — what the dashboard card and
* the Software hub's tab badge show. `skipped` = no single tenant in view (an aggregate), nothing read.
*/
export type VulnerabilityHeadline =
| { status: "skipped" }
| { status: "loading" }
| { status: "error" }
| {
status: "ready";
analyzerOn: boolean;
/** CVEs on the CISA KEV list, the number the Vulnerabilities tab shows as "Known exploited". */
kev: number;
/** Devices (enrollments) carrying at least one of them. */
kevDevices: number;
/** The tab's "Attend" band: EPSS ≥ 10 % or CVSS ≥ 9.0. */
highRisk: number;
cves: number;
};
export function headlineFromSummary(analyzerOn: boolean, summary: CveExposureSummary): VulnerabilityHeadline {
return {
status: "ready",
analyzerOn,
kev: summary.kevCves,
kevDevices: summary.kevAffectedSessions,
highRisk: summary.priorityBreakdown.attend,
cves: summary.distinctCves,
};
}
/** Telemetry and test name of each card state (the event property `state`). */
export type VulnerabilityCardKind =
| "kev"
| "high_risk"
| "tracked"
| "none"
| "off_admin"
| "off_member"
| "loading"
| "error";
export interface VulnerabilityCardState {
kind: VulnerabilityCardKind;
value: string;
/** Description parts; the card joins them with " · " and wraps each as a whole. */
parts: string[];
/** Red value and icon: only when a CVE in the window is known to be exploited. */
alert: boolean;
/** Gray value: scanning is off, or the numbers could not be read. */
muted: boolean;
/** Where the whole card leads: the Vulnerabilities tab, the analyzer setting, or nowhere. */
link: "tab" | "settings" | null;
}
const plural = (n: number, one: string, many: string) => `${n.toLocaleString()} ${n === 1 ? one : many}`;
/**
* The dashboard card for a headline. Scanning that is off shows "Off" only while the window holds no
* findings; findings from before it was turned off are still real and lead to the tab. The link to the
* setting is for `canEnable` users only (lib/tenantScope.ts canChangeOwnTenantSettings).
*/
export function vulnerabilityCardState(
headline: VulnerabilityHeadline,
opts: { canEnable: boolean; days: number },
): VulnerabilityCardState | null {
switch (headline.status) {
case "skipped":
return null;
case "loading":
return { kind: "loading", value: "...", parts: ["Known exploited"], alert: false, muted: false, link: null };
case "error":
return { kind: "error", value: "—", parts: ["Not available"], alert: false, muted: true, link: null };
}
if (headline.cves === 0) {
if (!headline.analyzerOn) {
return opts.canEnable
? { kind: "off_admin", value: "Off", parts: ["Turn on scanning →"], alert: false, muted: true, link: "settings" }
: { kind: "off_member", value: "Off", parts: ["Ask a tenant admin"], alert: false, muted: true, link: null };
}
return { kind: "none", value: "0", parts: ["No CVEs detected", plural(opts.days, "day", "days")], alert: false, muted: false, link: "tab" };
}
const value = headline.kev.toLocaleString();
if (headline.kev > 0) {
return { kind: "kev", value, parts: ["Known exploited", plural(headline.kevDevices, "device", "devices")], alert: true, muted: false, link: "tab" };
}
if (headline.highRisk > 0) {
return { kind: "high_risk", value, parts: ["Known exploited", `${headline.highRisk.toLocaleString()} high risk`], alert: false, muted: false, link: "tab" };
}
return { kind: "tracked", value, parts: ["Known exploited", plural(headline.cves, "CVE", "CVEs")], alert: false, muted: false, link: "tab" };
}