mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-12 12:43:46 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 456b8f6025 | |||
| c3faece6d8 | |||
| f0fee75b08 | |||
| ac381a39ad | |||
| a1936b7cb6 | |||
| ba53b82c00 | |||
| 7f53dcf223 | |||
| cd2ea05878 | |||
| ccdd8c17ce | |||
| e6191831bd | |||
| ccea1dacfe | |||
| 33e614da5d | |||
| a1433141ce | |||
| a7e76f0584 | |||
| 3829b6ce92 | |||
| dabd0d5b7e | |||
| 68dab1e154 | |||
| a1590579f9 | |||
| a558ca5510 |
+1
-1
Submodule .claude/commands updated: 7fee9d0e6e...155a138209
+1
-1
Submodule internal updated: e6a23a2a1f...2e314b1de5
@@ -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
@@ -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",
|
||||
|
||||
+35
@@ -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)]
|
||||
|
||||
+43
-1
@@ -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
|
||||
{
|
||||
|
||||
+27
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+319
-75
@@ -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")]
|
||||
|
||||
+152
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+325
@@ -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))));
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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")]
|
||||
|
||||
+2
-33
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-3
@@ -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)
|
||||
{
|
||||
|
||||
+47
-33
@@ -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
|
||||
|
||||
+27
-6
@@ -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;
|
||||
|
||||
+278
-87
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
+182
@@ -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";
|
||||
|
||||
+4
-4
@@ -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]
|
||||
|
||||
+50
@@ -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]
|
||||
|
||||
+4
-2
@@ -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}");
|
||||
|
||||
+4
-2
@@ -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}");
|
||||
|
||||
+4
-2
@@ -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',
|
||||
|
||||
@@ -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("")}
|
||||
|
||||
+24
-1
@@ -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 & 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>
|
||||
|
||||
+24
@@ -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";
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
Reference in New Issue
Block a user