mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-12 04:35:01 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccdd8c17ce | |||
| e6191831bd | |||
| ccea1dacfe | |||
| 33e614da5d | |||
| a1433141ce | |||
| a7e76f0584 | |||
| 3829b6ce92 | |||
| dabd0d5b7e | |||
| 68dab1e154 | |||
| a1590579f9 | |||
| a558ca5510 | |||
| e01e7abc2b | |||
| 512b145484 | |||
| 8ece30631a | |||
| c9078d00b6 | |||
| 1ff68dc0af | |||
| 7c8204357b | |||
| dfcf7ad8ba | |||
| 54d4c0bd4c | |||
| 11bc0ec2ab | |||
| 7e9b0cea54 | |||
| c115cb354e | |||
| 9cfe3fb74d | |||
| 892ff4c1e1 | |||
| 2cb181734a | |||
| 7055a6f0fc | |||
| 587de860dd | |||
| 409794c037 | |||
| 337b3b012a | |||
| c2ae5ddd83 | |||
| 60baf5317c | |||
| a5d5a37dc1 | |||
| d4b56c417d | |||
| a5bbdf9f80 | |||
| 389d4f27dc | |||
| ce5f22bd19 | |||
| 85fadc8ac4 |
+1
-1
Submodule .claude/commands updated: 9e518b0caf...9fb9fe058b
+1
-1
Submodule internal updated: c4c3655cd0...97fae4280f
@@ -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",
|
||||
|
||||
@@ -44,7 +44,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
IReadOnlyCollection<string>? whiteGloveSealingPatternIds = null,
|
||||
TimeSpan? drainInterval = null,
|
||||
TimeSpan? terminalDrainTimeout = null,
|
||||
TimeSpan? agentMaxLifetime = null) =>
|
||||
TimeSpan? agentMaxLifetime = null,
|
||||
TimeSpan? collectorHostStopTimeout = null) =>
|
||||
new EnrollmentOrchestrator(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
@@ -58,7 +59,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
whiteGloveSealingPatternIds: whiteGloveSealingPatternIds,
|
||||
drainInterval: drainInterval ?? TimeSpan.FromDays(1),
|
||||
terminalDrainTimeout: terminalDrainTimeout ?? TimeSpan.FromSeconds(2),
|
||||
agentMaxLifetime: agentMaxLifetime);
|
||||
agentMaxLifetime: agentMaxLifetime,
|
||||
collectorHostStopTimeout: collectorHostStopTimeout);
|
||||
|
||||
public void Dispose() => Tmp.Dispose();
|
||||
}
|
||||
|
||||
+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
|
||||
{
|
||||
|
||||
+51
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
@@ -13,6 +14,8 @@ using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
|
||||
#pragma warning disable xUnit1031 // one test waits on a task on purpose: a blocked call is the failure it looks for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
@@ -137,6 +140,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
public void Dispose()
|
||||
{
|
||||
Watcher.Dispose();
|
||||
ReleaseProcessesAndFiles();
|
||||
}
|
||||
|
||||
/// <summary>Teardown that leaves the watcher alone — for a test that found its Dispose blocked.</summary>
|
||||
public void ReleaseProcessesAndFiles()
|
||||
{
|
||||
foreach (var p in Running)
|
||||
{
|
||||
try { if (!p.HasExited) p.Kill(); } catch { }
|
||||
@@ -210,6 +219,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
Assert.Equal(new object[] { first.Id, second.Id }, pids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_returns_while_the_exit_of_the_attached_process_is_being_delivered()
|
||||
{
|
||||
var f = new Fixture();
|
||||
var child = f.Spawn(trusted: true);
|
||||
|
||||
// The watcher gets a Process object that already carries a handler. .NET runs that one
|
||||
// first and holds its own lock on the Process object for as long as it delivers Exited.
|
||||
var delivering = new ManualResetEventSlim(false);
|
||||
var release = new ManualResetEventSlim(false);
|
||||
f.Watcher.ProcessSource = () =>
|
||||
{
|
||||
var handle = Process.GetProcessById(child.Id);
|
||||
handle.EnableRaisingEvents = true;
|
||||
handle.Exited += (_, __) => { delivering.Set(); release.Wait(TimeSpan.FromSeconds(10)); };
|
||||
return new[] { handle };
|
||||
};
|
||||
f.Watcher.TryAttachNow();
|
||||
Assert.Equal(child.Id, f.Watcher.AttachedProcessId);
|
||||
|
||||
Task dispose;
|
||||
try
|
||||
{
|
||||
child.Kill();
|
||||
Assert.True(delivering.Wait(TimeSpan.FromSeconds(10)), "the exit was not delivered");
|
||||
|
||||
dispose = Task.Run(() => f.Watcher.Dispose());
|
||||
Thread.Sleep(300); // let Dispose reach the Process object
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
|
||||
var returned = dispose.Wait(TimeSpan.FromSeconds(10));
|
||||
// A blocked Dispose holds the watcher's lock for good; disposing the watcher again would
|
||||
// hang the test run instead of failing this test.
|
||||
f.ReleaseProcessesAndFiles();
|
||||
Assert.True(returned,
|
||||
"Dispose and the exit delivery blocked each other: the Process is disposed under the watcher's lock");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_spoof_and_service_both_running_the_service_wins_regardless_of_order()
|
||||
{
|
||||
|
||||
+32
@@ -2,11 +2,14 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // one test waits on tasks on purpose: a blocked call is the failure it looks for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
{
|
||||
/// <summary>
|
||||
@@ -148,6 +151,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
Assert.Equal(afterDispose, Volatile.Read(ref raises)); // no raises after dispose
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_from_another_thread_returns_while_the_initial_scan_handler_is_running()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
LayDownCoreBinary(tmp.Path); // the initial scan in Start() finds it and raises
|
||||
var watcher = new OfficeBinaryWatcher(tmp.Path, CoreBinaries, NewLogger(tmp.Path));
|
||||
|
||||
using var entered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
watcher.BinaryAppeared += (_, __) => { entered.Set(); release.Wait(TimeSpan.FromSeconds(10)); };
|
||||
|
||||
var start = Task.Run(() => watcher.Start());
|
||||
try
|
||||
{
|
||||
Assert.True(entered.Wait(TimeSpan.FromSeconds(5)), "initial scan did not raise");
|
||||
|
||||
var dispose = Task.Run(() => watcher.Dispose());
|
||||
|
||||
Assert.True(dispose.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Dispose waited for the running handler: the watcher's lock is held while BinaryAppeared is raised");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
start.Wait(TimeSpan.FromSeconds(10));
|
||||
watcher.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SpinUntil(Func<bool> condition, int timeoutMs)
|
||||
{
|
||||
var deadline = Environment.TickCount + timeoutMs;
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a blocked call is the failure they look for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="OfficeProcessWatcher"/> with stand-in worker processes handed in through the
|
||||
/// startup probe (the WMI start trace is never armed here, as in the sibling watchers' tests).
|
||||
/// Covers the worker window (started / stopped) and the two ways a worker's exit used to go
|
||||
/// wrong: disposing the watcher while the exit is being delivered, and a worker that was
|
||||
/// already gone when the watcher subscribed.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // Process.Exited is delivered on the shared ThreadPool
|
||||
public sealed class OfficeProcessWatcherTests : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly List<Process> _spawned = new List<Process>();
|
||||
|
||||
public OfficeProcessWatcherTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var p in _spawned)
|
||||
{
|
||||
try { if (!p.HasExited) p.Kill(); } catch { }
|
||||
try { p.Dispose(); } catch { }
|
||||
}
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>A stand-in worker that blocks on stdin until killed.</summary>
|
||||
private Process Spawn()
|
||||
{
|
||||
var p = Process.Start(new ProcessStartInfo("cmd.exe", "/c pause")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
Assert.NotNull(p);
|
||||
_spawned.Add(p!);
|
||||
return p!;
|
||||
}
|
||||
|
||||
/// <summary>A second handle on the same process — what the watcher would have opened itself.</summary>
|
||||
private static Process HandleOn(Process child)
|
||||
{
|
||||
var handle = Process.GetProcessById(child.Id);
|
||||
handle.EnableRaisingEvents = true;
|
||||
return handle;
|
||||
}
|
||||
|
||||
/// <summary>Starts the watcher with <paramref name="handle"/> as the one running worker.</summary>
|
||||
private static void StartWith(OfficeProcessWatcher watcher, Process handle)
|
||||
{
|
||||
watcher.WorkerSource = () => new[] { handle };
|
||||
watcher.Start(armWmi: false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_running_worker_is_reported_before_Start_returns_and_its_exit_closes_the_window()
|
||||
{
|
||||
using var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
|
||||
using var started = new ManualResetEventSlim(false);
|
||||
using var stopped = new ManualResetEventSlim(false);
|
||||
watcher.Started += (_, __) => started.Set();
|
||||
watcher.Stopped += (_, __) => stopped.Set();
|
||||
var child = Spawn();
|
||||
|
||||
StartWith(watcher, HandleOn(child));
|
||||
|
||||
Assert.True(started.IsSet, "Started was not raised by the startup probe");
|
||||
Assert.True(watcher.IsActive);
|
||||
|
||||
child.Kill();
|
||||
|
||||
Assert.True(stopped.Wait(Timeout), "Stopped was not raised after the last worker exited");
|
||||
Assert.False(watcher.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_returns_while_the_exit_of_a_tracked_worker_is_being_delivered()
|
||||
{
|
||||
var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
|
||||
var child = Spawn();
|
||||
var handle = HandleOn(child);
|
||||
|
||||
// Subscribed before the watcher, so .NET runs it first — and .NET holds its own lock on
|
||||
// the Process object for as long as it delivers Exited.
|
||||
using var delivering = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
handle.Exited += (_, __) => { delivering.Set(); release.Wait(Timeout); };
|
||||
StartWith(watcher, handle);
|
||||
|
||||
Task dispose;
|
||||
try
|
||||
{
|
||||
child.Kill();
|
||||
Assert.True(delivering.Wait(Timeout), "the worker's exit was not delivered");
|
||||
|
||||
dispose = Task.Run(() => watcher.Dispose());
|
||||
Thread.Sleep(300); // let Dispose reach the Process object
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
|
||||
Assert.True(dispose.Wait(Timeout),
|
||||
"Dispose and the exit delivery blocked each other: the Process is disposed under the watcher's lock");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_worker_that_was_already_gone_when_the_watcher_subscribed_still_closes_the_window()
|
||||
{
|
||||
using var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
|
||||
using var stopped = new ManualResetEventSlim(false);
|
||||
watcher.Stopped += (_, __) => stopped.Set();
|
||||
|
||||
var child = Spawn();
|
||||
var handle = HandleOn(child);
|
||||
using var exitDelivered = new ManualResetEventSlim(false);
|
||||
handle.Exited += (_, __) => exitDelivered.Set();
|
||||
child.Kill();
|
||||
// .NET raises Exited once per Process object. After this, a later subscriber never hears it.
|
||||
Assert.True(exitDelivered.Wait(Timeout), "the stand-in's exit was not delivered");
|
||||
|
||||
StartWith(watcher, handle);
|
||||
|
||||
Assert.True(stopped.Wait(Timeout),
|
||||
"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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Microsoft.Win32;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a blocked call is the failure they look for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="RegistryChangeWatcher"/> against a real key under HKCU (no elevation needed).
|
||||
/// The watcher's lock is a leaf lock: <c>Changed</c> is raised with the lock free, so a
|
||||
/// handler can take other locks or dispose the watcher, and <c>Dispose</c> never waits for a
|
||||
/// handler. One handler runs at a time, and a change that arrives while it runs is reported
|
||||
/// after it returns.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // callbacks arrive on the shared ThreadPool
|
||||
public sealed class RegistryChangeWatcherTests : IDisposable
|
||||
{
|
||||
private const string RootSubKey = @"SOFTWARE\AutopilotMonitor.Tests\OfficeRegistryChangeWatcher";
|
||||
private const string TargetSubKey = RootSubKey + @"\Target";
|
||||
private const uint RegNotifyThreadAgnostic = 0x10000000;
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public RegistryChangeWatcherTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
DeleteTestKeys();
|
||||
using (var root = Registry.CurrentUser.CreateSubKey(RootSubKey)) Assert.NotNull(root);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DeleteTestKeys();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private static void DeleteTestKeys()
|
||||
{
|
||||
try { Registry.CurrentUser.DeleteSubKeyTree(RootSubKey, throwOnMissingSubKey: false); }
|
||||
catch { /* teardown noise must not fail a test */ }
|
||||
}
|
||||
|
||||
private static void CreateTarget()
|
||||
{
|
||||
using (var key = Registry.CurrentUser.CreateSubKey(TargetSubKey)) Assert.NotNull(key);
|
||||
}
|
||||
|
||||
private static void WriteValue(string name)
|
||||
{
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(TargetSubKey, writable: true))
|
||||
{
|
||||
Assert.NotNull(key);
|
||||
key!.SetValue(name, Guid.NewGuid().ToString("N"));
|
||||
}
|
||||
}
|
||||
|
||||
private RegistryChangeWatcher NewWatcher() =>
|
||||
new RegistryChangeWatcher(RegistryHive.CurrentUser, TargetSubKey, _logger);
|
||||
|
||||
[Fact]
|
||||
public void Changed_fires_when_a_value_is_written_under_the_target()
|
||||
{
|
||||
CreateTarget();
|
||||
using var watcher = NewWatcher();
|
||||
using var fired = new ManualResetEventSlim(false);
|
||||
watcher.Changed += (_, __) => fired.Set();
|
||||
|
||||
watcher.Start();
|
||||
WriteValue("Probe");
|
||||
|
||||
Assert.True(fired.Wait(Timeout), "Changed did not fire for a value write under the target key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Changed_fires_once_a_missing_target_key_appears()
|
||||
{
|
||||
using var watcher = NewWatcher();
|
||||
using var fired = new ManualResetEventSlim(false);
|
||||
watcher.Changed += (_, __) => fired.Set();
|
||||
|
||||
watcher.Start(); // target absent: watches the parent for a new child key
|
||||
CreateTarget();
|
||||
|
||||
Assert.True(fired.Wait(Timeout), "Changed did not fire when the target key appeared");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_from_another_thread_returns_while_a_handler_is_running()
|
||||
{
|
||||
CreateTarget();
|
||||
var watcher = NewWatcher();
|
||||
using var entered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
watcher.Changed += (_, __) => { entered.Set(); release.Wait(Timeout); };
|
||||
|
||||
try
|
||||
{
|
||||
watcher.Start();
|
||||
WriteValue("Probe");
|
||||
Assert.True(entered.Wait(Timeout), "handler was not invoked");
|
||||
|
||||
var dispose = Task.Run(() => watcher.Dispose());
|
||||
|
||||
Assert.True(dispose.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Dispose waited for the running handler: the watcher's lock is held while Changed is raised");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
watcher.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_handler_may_dispose_its_own_watcher()
|
||||
{
|
||||
CreateTarget();
|
||||
var watcher = NewWatcher();
|
||||
using var disposedInHandler = new ManualResetEventSlim(false);
|
||||
int calls = 0;
|
||||
watcher.Changed += (_, __) =>
|
||||
{
|
||||
Interlocked.Increment(ref calls);
|
||||
watcher.Dispose();
|
||||
disposedInHandler.Set();
|
||||
};
|
||||
|
||||
watcher.Start();
|
||||
WriteValue("First");
|
||||
Assert.True(disposedInHandler.Wait(Timeout), "handler did not return from disposing its watcher");
|
||||
|
||||
WriteValue("Second");
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(1, Volatile.Read(ref calls)); // a disposed watcher stays quiet
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handlers_never_overlap_and_a_change_during_a_handler_is_reported_after_it()
|
||||
{
|
||||
CreateTarget();
|
||||
using var watcher = NewWatcher();
|
||||
using var firstEntered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
using var second = new ManualResetEventSlim(false);
|
||||
int calls = 0, running = 0, maxRunning = 0;
|
||||
watcher.Changed += (_, __) =>
|
||||
{
|
||||
var now = Interlocked.Increment(ref running);
|
||||
int seen;
|
||||
while (now > (seen = Volatile.Read(ref maxRunning)))
|
||||
Interlocked.CompareExchange(ref maxRunning, now, seen);
|
||||
|
||||
if (Interlocked.Increment(ref calls) == 1)
|
||||
{
|
||||
firstEntered.Set();
|
||||
release.Wait(Timeout);
|
||||
}
|
||||
else
|
||||
{
|
||||
second.Set();
|
||||
}
|
||||
Interlocked.Decrement(ref running);
|
||||
};
|
||||
|
||||
watcher.Start();
|
||||
WriteValue("First");
|
||||
Assert.True(firstEntered.Wait(Timeout), "handler was not invoked");
|
||||
|
||||
WriteValue("DuringHandler");
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(1, Volatile.Read(ref calls)); // no second handler while the first runs
|
||||
|
||||
release.Set();
|
||||
|
||||
Assert.True(second.Wait(Timeout), "the change that arrived during the handler was never reported");
|
||||
Assert.Equal(1, Volatile.Read(ref maxRunning));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Target_watch_is_not_tied_to_the_arming_thread()
|
||||
{
|
||||
using var watcher = NewWatcher();
|
||||
|
||||
// Re-arming happens on rotating ThreadPool threads. Without this flag Windows signals
|
||||
// the event when such a thread ends, which looks like a change that never happened.
|
||||
Assert.True((watcher.EffectivePrimaryFilter & RegNotifyThreadAgnostic) != 0,
|
||||
"the target watch must carry REG_NOTIFY_THREAD_AGNOSTIC");
|
||||
}
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Microsoft.Win32;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the test waits on a task on purpose: a blocked call is the failure it looks for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="RealmJoinWatcher.Stop"/> while the "RealmJoin key appeared" callback is in
|
||||
/// flight. The callback runs on a real registry watcher over the signed-in user's
|
||||
/// <c>HKU\<sid>\SOFTWARE</c> (writable without elevation); the key probes are injected.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // registry callbacks arrive on the shared ThreadPool
|
||||
public sealed class RealmJoinWatcherStopTests : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
// A new direct child of HKCU\SOFTWARE is what the parent watch reacts to.
|
||||
private readonly string _probeKey = @"SOFTWARE\AutopilotMonitor.Tests.RealmJoinStop." + Guid.NewGuid().ToString("N");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Registry.CurrentUser.DeleteSubKeyTree(_probeKey, throwOnMissingSubKey: false); }
|
||||
catch { /* teardown noise must not fail a test */ }
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stop_returns_while_the_key_appeared_callback_is_running()
|
||||
{
|
||||
var sid = WindowsIdentity.GetCurrent().User!.Value;
|
||||
var userRealmJoinPath = sid + "\\" + RealmJoinInfo.UserRealmJoinSubPath;
|
||||
|
||||
var appearedProbe = new ManualResetEventSlim(false);
|
||||
var release = new ManualResetEventSlim(false);
|
||||
int armed = 0;
|
||||
bool KeyExists(RegistryHive hive, string subPath)
|
||||
{
|
||||
// Everything is "absent" while the watcher arms. Afterwards the user-scope
|
||||
// RealmJoin key "exists": the callback sees it and goes on to switch watchers.
|
||||
if (hive != RegistryHive.Users || subPath != userRealmJoinPath || Volatile.Read(ref armed) == 0)
|
||||
return false;
|
||||
appearedProbe.Set();
|
||||
release.Wait(Timeout);
|
||||
return true;
|
||||
}
|
||||
|
||||
var watcher = new RealmJoinWatcher(
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
enumeratePackageIds: (_, __) => Array.Empty<string>(),
|
||||
tryReadPackage: (RegistryHive _, string __, string ___, out RealmJoinPackageSnapshot snapshot) =>
|
||||
{
|
||||
snapshot = default!;
|
||||
return false;
|
||||
},
|
||||
keyExists: KeyExists,
|
||||
readVersionInfo: () => default,
|
||||
readUninstallDisplayVersion: () => null,
|
||||
readDeploymentPhase: () => null);
|
||||
|
||||
// Production order: the SID is recorded, then RealmJoin reaches the running phase and
|
||||
// the watcher arms its parent watch on HKU\<sid>\SOFTWARE.
|
||||
watcher.ArmHku(sid);
|
||||
watcher.TriggerNotifyRealmJoinPresenceFromTest(RealmJoinInfo.PhaseRunningThresholdMin);
|
||||
Volatile.Write(ref armed, 1);
|
||||
|
||||
Task stop;
|
||||
try
|
||||
{
|
||||
using (var key = Registry.CurrentUser.CreateSubKey(_probeKey)) Assert.NotNull(key);
|
||||
Assert.True(appearedProbe.Wait(Timeout), "the parent watch did not report the new key");
|
||||
|
||||
stop = Task.Run(() => watcher.Stop());
|
||||
Thread.Sleep(300); // let Stop reach the registry watcher whose callback is parked
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
|
||||
var returned = stop.Wait(Timeout);
|
||||
// A blocked Stop holds the watcher's lock for good; disposing it would hang the run.
|
||||
if (returned) watcher.Dispose();
|
||||
Assert.True(returned,
|
||||
"Stop and the key-appeared callback blocked each other: watchers are disposed under the state lock");
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -80,6 +80,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
|
||||
Assert.Equal(2, client.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Registration_session_gone_report_is_exempt_from_the_cooldown_like_the_telemetry_one()
|
||||
{
|
||||
// The integrity report goes out post-config, seconds before the registration is refused.
|
||||
var clock = Clock();
|
||||
var client = new FakeApiClient();
|
||||
client.Results.Enqueue(true);
|
||||
client.Results.Enqueue(true);
|
||||
var reporter = Reporter(client, clock);
|
||||
|
||||
await reporter.TrySendAsync(AgentErrorType.IntegrityCheckFailed, "m");
|
||||
clock.Advance(TimeSpan.FromSeconds(5));
|
||||
await reporter.TrySendAsync(AgentErrorType.RegistrationRejectedSessionGone, "m", httpStatusCode: 410);
|
||||
|
||||
Assert.Equal(2, client.Calls);
|
||||
Assert.Equal(AgentErrorType.RegistrationRejectedSessionGone, client.Reports[1].ErrorType);
|
||||
Assert.Equal(410, client.Reports[1].HttpStatusCode);
|
||||
|
||||
// Key dedup still applies: a second one is held back.
|
||||
await reporter.TrySendAsync(AgentErrorType.RegistrationRejectedSessionGone, "m", httpStatusCode: 410);
|
||||
Assert.Equal(2, client.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Second_session_unknown_report_within_the_window_is_still_suppressed()
|
||||
{
|
||||
|
||||
+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))));
|
||||
}
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a call that never returns is the failure they look for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Stopping the collector hosts is bounded per host: termination reaches the diagnostics
|
||||
/// upload and the cleanup whatever a collector does. A host that hangs is left behind, named
|
||||
/// in one <c>agent_trace</c>, and never called again; every other host is stopped and disposed
|
||||
/// as before.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")]
|
||||
public sealed class EnrollmentOrchestratorCollectorStopTests
|
||||
{
|
||||
private static readonly TimeSpan Bound = TimeSpan.FromMilliseconds(400);
|
||||
private static readonly TimeSpan Wait = TimeSpan.FromSeconds(10);
|
||||
|
||||
private sealed class Host : ICollectorHost
|
||||
{
|
||||
private readonly List<string> _order;
|
||||
private readonly ManualResetEventSlim? _hangUntil;
|
||||
private readonly Exception? _throwOnStop;
|
||||
private int _stopCalls;
|
||||
private int _disposeCalls;
|
||||
|
||||
public Host(string name, List<string> order, ManualResetEventSlim? hangUntil = null, Exception? throwOnStop = null)
|
||||
{
|
||||
Name = name;
|
||||
_order = order;
|
||||
_hangUntil = hangUntil;
|
||||
_throwOnStop = throwOnStop;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public int StopCalls => Volatile.Read(ref _stopCalls);
|
||||
public int DisposeCalls => Volatile.Read(ref _disposeCalls);
|
||||
|
||||
public void Start() { }
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
Interlocked.Increment(ref _stopCalls);
|
||||
lock (_order) _order.Add(Name);
|
||||
if (_throwOnStop != null) throw _throwOnStop;
|
||||
_hangUntil?.Wait();
|
||||
}
|
||||
|
||||
public void Dispose() => Interlocked.Increment(ref _disposeCalls);
|
||||
}
|
||||
|
||||
private sealed class Factory : IComponentFactory
|
||||
{
|
||||
private readonly IReadOnlyList<ICollectorHost> _hosts;
|
||||
public Factory(IReadOnlyList<ICollectorHost> hosts) { _hosts = hosts; }
|
||||
|
||||
public CollectorSurfaces CreateCollectorHosts(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
AgentLogger logger,
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null,
|
||||
Func<DecisionState?>? decisionStateProbe = null) => new CollectorSurfaces(_hosts);
|
||||
}
|
||||
|
||||
private static JObject? StopTimeoutEvent(EnrollmentOrchestrator sut) =>
|
||||
sut.SpoolForTests!.Peek(1000)
|
||||
.Where(i => i.Kind == TelemetryItemKind.Event && i.PayloadJson.Contains("collector_host_stop_timeout"))
|
||||
.Select(i => JObject.Parse(i.PayloadJson))
|
||||
.FirstOrDefault();
|
||||
|
||||
[Fact]
|
||||
public void A_host_that_hangs_in_Stop_is_left_behind_and_the_others_are_still_stopped()
|
||||
{
|
||||
using var rig = new EnrollmentOrchestratorRig();
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
var order = new List<string>();
|
||||
var before = new Host("Before", order);
|
||||
var hanging = new Host("Hanging", order, hangUntil: release);
|
||||
var after = new Host("After", order);
|
||||
var sut = rig.Build(
|
||||
componentFactory: new Factory(new ICollectorHost[] { before, hanging, after }),
|
||||
collectorHostStopTimeout: Bound);
|
||||
sut.Start();
|
||||
|
||||
try
|
||||
{
|
||||
var stop = Task.Run(() => sut.StopCollectorHosts());
|
||||
|
||||
Assert.True(stop.Wait(Wait), "StopCollectorHosts never returned although one host hangs");
|
||||
Assert.Equal(new[] { "Before", "Hanging", "After" }, order.ToArray());
|
||||
|
||||
JObject? evt = null;
|
||||
Assert.True(SpinWait.SpinUntil(() => (evt = StopTimeoutEvent(sut)) != null, Wait),
|
||||
"the hanging host was not reported");
|
||||
Assert.Equal("agent_trace", (string?)evt!["EventType"]);
|
||||
var data = (JObject)evt["Data"]!;
|
||||
Assert.Equal("collector_host_stop_timeout", (string?)data["decision"]);
|
||||
Assert.Equal("Hanging", (string?)data["host"]);
|
||||
Assert.Equal(1, (int)data["timeoutSeconds"]!);
|
||||
|
||||
// Stop() afterwards: the stuck host is not touched again, the others are disposed.
|
||||
var fullStop = Task.Run(() => sut.Stop());
|
||||
Assert.True(fullStop.Wait(Wait), "Stop never returned");
|
||||
Assert.Equal(1, before.DisposeCalls);
|
||||
Assert.Equal(0, hanging.DisposeCalls);
|
||||
Assert.Equal(1, after.DisposeCalls);
|
||||
Assert.Equal(1, hanging.StopCalls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Healthy_hosts_are_stopped_and_disposed_in_order_without_a_report()
|
||||
{
|
||||
using var rig = new EnrollmentOrchestratorRig();
|
||||
var order = new List<string>();
|
||||
var first = new Host("First", order);
|
||||
var throwing = new Host("Throwing", order, throwOnStop: new InvalidOperationException("boom"));
|
||||
var last = new Host("Last", order);
|
||||
var hosts = new[] { first, throwing, last };
|
||||
var sut = rig.Build(componentFactory: new Factory(hosts)); // default bound: nothing here comes near it
|
||||
sut.Start();
|
||||
|
||||
sut.StopCollectorHosts();
|
||||
sut.StopCollectorHosts(); // idempotent
|
||||
|
||||
Assert.Equal(new[] { "First", "Throwing", "Last" }, order.ToArray());
|
||||
Assert.All(hosts, h => Assert.Equal(1, h.StopCalls));
|
||||
|
||||
Thread.Sleep(200);
|
||||
Assert.Null(StopTimeoutEvent(sut));
|
||||
|
||||
sut.Stop();
|
||||
Assert.All(hosts, h => Assert.Equal(1, h.DisposeCalls));
|
||||
}
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Win32;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a blocked call is the failure they look for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="OfficeInstallDetectorHost"/> with its real watchers: the registry watcher on a
|
||||
/// key under HKCU, the binary watcher on a temp folder, snapshots injected. Stopping the host
|
||||
/// must never wait for a watcher callback, and a completion that coincides with a registry
|
||||
/// callback must not leave the host unable to stop.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // registry + filesystem callbacks arrive on the shared ThreadPool
|
||||
public sealed class OfficeInstallDetectorHostTests : IDisposable
|
||||
{
|
||||
private const string RegistrySubKey = @"SOFTWARE\AutopilotMonitor.Tests\OfficeInstallDetectorHost";
|
||||
private static readonly DateTime At = new DateTime(2026, 11, 10, 9, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private readonly string _installPath;
|
||||
private Func<OfficeC2RSnapshot> _snapshot;
|
||||
|
||||
public OfficeInstallDetectorHostTests()
|
||||
{
|
||||
DeleteTestKey();
|
||||
using (var key = Registry.CurrentUser.CreateSubKey(RegistrySubKey)) Assert.NotNull(key);
|
||||
_installPath = Path.Combine(_tmp.Path, "Office");
|
||||
Directory.CreateDirectory(_installPath);
|
||||
_snapshot = Idle;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DeleteTestKey();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private static void DeleteTestKey()
|
||||
{
|
||||
try { Registry.CurrentUser.DeleteSubKeyTree(RegistrySubKey, throwOnMissingSubKey: false); }
|
||||
catch { /* teardown noise must not fail a test */ }
|
||||
}
|
||||
|
||||
private static void TouchRegistry()
|
||||
{
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(RegistrySubKey, writable: true))
|
||||
{
|
||||
Assert.NotNull(key);
|
||||
key!.SetValue("Probe", Guid.NewGuid().ToString("N"));
|
||||
}
|
||||
}
|
||||
|
||||
private OfficeInstallDetectorHost NewHost() =>
|
||||
new OfficeInstallDetectorHost(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
ingress: _sink,
|
||||
clock: new VirtualClock(At),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
settleSeconds: 0,
|
||||
statePersistence: null,
|
||||
resumeState: null,
|
||||
registryHive: RegistryHive.CurrentUser,
|
||||
registrySubKey: RegistrySubKey,
|
||||
snapshotProvider: () => _snapshot());
|
||||
|
||||
// No install scenario, no install path: the detector stays idle and the host keeps
|
||||
// raising OfficeExpected on registry changes.
|
||||
private static OfficeC2RSnapshot Idle() => new OfficeC2RSnapshot { ConfigurationKeyPresent = true };
|
||||
|
||||
private OfficeC2RSnapshot Installing()
|
||||
{
|
||||
var s = new OfficeC2RSnapshot
|
||||
{
|
||||
ConfigurationKeyPresent = true,
|
||||
ActiveScenarioPresent = true,
|
||||
ActiveScenarioName = "INSTALL",
|
||||
InstallScenarioPresent = true,
|
||||
InstallScenarioName = "INSTALL",
|
||||
InstallationPath = _installPath,
|
||||
VersionToReport = "16.0.1.1",
|
||||
};
|
||||
s.Products.Add("O365ProPlusRetail");
|
||||
return s;
|
||||
}
|
||||
|
||||
private int OfficeEvents(string eventType) => _sink.Posted.Count(p =>
|
||||
p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType);
|
||||
|
||||
[Fact]
|
||||
public void Stop_returns_while_a_registry_handler_is_running()
|
||||
{
|
||||
var host = NewHost();
|
||||
using var entered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
host.OfficeExpected += (_, __) => { entered.Set(); release.Wait(Timeout); };
|
||||
|
||||
try
|
||||
{
|
||||
host.Start();
|
||||
TouchRegistry();
|
||||
Assert.True(entered.Wait(Timeout), "the registry change did not reach the host");
|
||||
|
||||
var stop = Task.Run(() => host.Stop());
|
||||
|
||||
Assert.True(stop.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Stop waited for the running registry handler");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_completion_during_a_running_registry_handler_does_not_block_Stop()
|
||||
{
|
||||
var host = NewHost();
|
||||
using var entered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
host.OfficeExpected += (_, __) => { entered.Set(); release.Wait(Timeout); };
|
||||
|
||||
try
|
||||
{
|
||||
// 1) A registry callback is in flight (parked in the host's OfficeExpected handler).
|
||||
host.Start();
|
||||
TouchRegistry();
|
||||
Assert.True(entered.Wait(Timeout), "the registry change did not reach the host");
|
||||
|
||||
// 2) The install starts and completes meanwhile: the binary watcher sees the core
|
||||
// binary, the detector reports completed, the host tears its watchers down.
|
||||
_snapshot = Installing;
|
||||
host.SubmitDoSample(new OfficeDoSample { JobCount = 1, FileSize = 100, TotalBytesDownloaded = 10 });
|
||||
Assert.Equal(1, OfficeEvents(Constants.EventTypes.OfficeInstallStarted));
|
||||
|
||||
var versionDir = Path.Combine(_installPath, "root", "Office16");
|
||||
Directory.CreateDirectory(versionDir);
|
||||
File.WriteAllText(Path.Combine(versionDir, "EXCEL.EXE"), "stub");
|
||||
Assert.True(
|
||||
SpinWait.SpinUntil(() => OfficeEvents(Constants.EventTypes.OfficeInstallCompleted) == 1, Timeout),
|
||||
"the install was not reported as completed");
|
||||
|
||||
// 3) The agent stops while that registry callback is still running.
|
||||
var stop = Task.Run(() => host.Stop());
|
||||
|
||||
Assert.True(stop.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Stop waited behind the completion's teardown, which waited for the running registry handler");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
|
||||
Assert.Equal(1, OfficeEvents(Constants.EventTypes.OfficeInstallCompleted));
|
||||
}
|
||||
}
|
||||
}
|
||||
+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")]
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Runtime;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
using ProgramAlias = AutopilotMonitor.Agent.V2.Program;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
{
|
||||
/// <summary>
|
||||
/// A registration the backend answered with HTTP 410 at process start: the session was deleted.
|
||||
/// Exactly one <see cref="AgentErrorType.RegistrationRejectedSessionGone"/> report, then the
|
||||
/// start-time end of monitoring with a forced cleanup — the same end state as the emergency
|
||||
/// break and the kill signal. The WhiteGlove Part 2 start that registers a new session instead
|
||||
/// is covered by <c>SessionRegistrationHelperTests</c>; here only its trace on <c>agent_started</c>.
|
||||
/// </summary>
|
||||
public sealed class SessionGoneAtRegistrationTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly SessionIdPersistence _persistence;
|
||||
private readonly string _stateDir;
|
||||
private readonly List<string> _sequence = new List<string>();
|
||||
|
||||
public SessionGoneAtRegistrationTests()
|
||||
{
|
||||
_logger = new AgentLogger(Path.Combine(_tmp.Path, "logs"), AgentLogLevel.Info);
|
||||
_persistence = new SessionIdPersistence(_tmp.Path);
|
||||
_persistence.GetOrCreate(_logger);
|
||||
_stateDir = Path.Combine(_tmp.Path, "State");
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private sealed class RecordingEmergencyReporter : EmergencyReporter
|
||||
{
|
||||
private readonly List<string> _sequence;
|
||||
public List<(AgentErrorType ErrorType, string Message, int? HttpStatusCode, int Attempts)> Reports { get; } =
|
||||
new List<(AgentErrorType, string, int?, int)>();
|
||||
|
||||
public RecordingEmergencyReporter(List<string> sequence)
|
||||
: base(apiClient: null!, sessionId: "S1", tenantId: "T1", agentVersion: "0.0.0", logger: null!)
|
||||
{
|
||||
_sequence = sequence;
|
||||
}
|
||||
|
||||
public override Task TrySendAsync(
|
||||
AgentErrorType errorType,
|
||||
string message,
|
||||
int? httpStatusCode = null,
|
||||
long? sequenceNumber = null,
|
||||
int attempts = 1,
|
||||
TimeSpan? perAttemptTimeout = null,
|
||||
TimeSpan? retryDelay = null,
|
||||
double? sessionAgeHours = null,
|
||||
RegistrationFailureSummary? priorRegistrationFailure = null)
|
||||
{
|
||||
Reports.Add((errorType, message, httpStatusCode, attempts));
|
||||
_sequence.Add("report");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private CleanupService CleanupFactory()
|
||||
{
|
||||
_sequence.Add("cleanup");
|
||||
// The real factory spawns a PowerShell cleanup script; throwing makes the cleanup retry
|
||||
// swallow and log it, so the call is counted without a side effect (as in ProgramGuardsTests).
|
||||
throw new InvalidOperationException("test-harness: cleanup factory must not spawn real PowerShell in unit tests");
|
||||
}
|
||||
|
||||
private bool MarkerExists => File.Exists(Path.Combine(_stateDir, "enrollment-complete.marker"));
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)] // forced like the kill signal: SelfDestructOnComplete does not gate it
|
||||
public void Reports_once_then_ends_monitoring_with_a_forced_cleanup(bool selfDestructOnComplete)
|
||||
{
|
||||
_persistence.SaveWhiteGloveComplete(_logger);
|
||||
var config = new AgentConfiguration { SessionId = "S1", TenantId = "T1", SelfDestructOnComplete = selfDestructOnComplete };
|
||||
var reporter = new RecordingEmergencyReporter(_sequence);
|
||||
|
||||
var exitCode = BackendSessionRegistration.ConcludeSessionGone(
|
||||
config, reporter, _persistence, _stateDir, CleanupFactory, consoleMode: false, _logger);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
// The report leaves before the cleanup deletes the agent.
|
||||
Assert.Equal(new[] { "report", "cleanup" }, _sequence);
|
||||
|
||||
var report = Assert.Single(reporter.Reports);
|
||||
Assert.Equal(AgentErrorType.RegistrationRejectedSessionGone, report.ErrorType);
|
||||
Assert.Equal(410, report.HttpStatusCode);
|
||||
Assert.Contains("410", report.Message);
|
||||
Assert.True(report.Attempts > 1, "the process ends right after: the one report gets a retry");
|
||||
|
||||
Assert.True(MarkerExists);
|
||||
Assert.False(_persistence.SessionExists());
|
||||
Assert.False(_persistence.IsWhiteGloveResume());
|
||||
Assert.Null(_persistence.LoadSessionCreatedAt());
|
||||
// The next start ends at the bootstrap guard, exactly as after the emergency break.
|
||||
Assert.True(ProgramAlias.CheckEnrollmentCompleteMarker(
|
||||
_stateDir, selfDestructOnComplete: false, cleanupServiceFactory: null, _logger, consoleMode: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ends_monitoring_without_a_reporter()
|
||||
{
|
||||
var config = new AgentConfiguration { SessionId = "S1", TenantId = "T1" };
|
||||
|
||||
var exitCode = BackendSessionRegistration.ConcludeSessionGone(
|
||||
config, emergencyReporter: null!, _persistence, _stateDir, CleanupFactory, consoleMode: false, _logger);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Equal(new[] { "cleanup" }, _sequence);
|
||||
Assert.True(MarkerExists);
|
||||
Assert.False(_persistence.SessionExists());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SessionRotationReasons.SessionGone)]
|
||||
[InlineData(SessionRotationReasons.OwnerMismatch)]
|
||||
public void Agent_started_names_why_the_session_was_rotated(string reason)
|
||||
{
|
||||
var data = EmitAgentStarted(rotatedFromSessionId: "11111111-1111-1111-1111-111111111111", reason);
|
||||
|
||||
Assert.Equal(true, data["sessionRotated"]);
|
||||
Assert.Equal("11111111-1111-1111-1111-111111111111", data["previousSessionId"]);
|
||||
Assert.Equal(reason, data["sessionRotationReason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Agent_started_carries_no_rotation_reason_on_the_normal_path()
|
||||
{
|
||||
var data = EmitAgentStarted(rotatedFromSessionId: null, sessionRotationReason: null);
|
||||
|
||||
Assert.Equal(false, data["sessionRotated"]);
|
||||
Assert.False(data.ContainsKey("sessionRotationReason"));
|
||||
}
|
||||
|
||||
private Dictionary<string, object> EmitAgentStarted(string? rotatedFromSessionId, string? sessionRotationReason)
|
||||
{
|
||||
var sink = new FakeSignalIngressSink();
|
||||
LifecycleEmitters.EmitAgentStarted(
|
||||
new InformationalEventPost(sink, SystemClock.Instance, _logger),
|
||||
new AgentConfiguration { SessionId = "S1", TenantId = "00000000-0000-0000-0000-000000000001", CommandLineArgs = string.Empty },
|
||||
new ProgramAlias.PreviousExitSummary { ExitType = "reboot_kill" },
|
||||
"2.0.test",
|
||||
remoteConfigService: null,
|
||||
_logger,
|
||||
rotatedFromSessionId: rotatedFromSessionId,
|
||||
sessionRotationReason: sessionRotationReason);
|
||||
return (Dictionary<string, object>)Assert.Single(sink.Posted).TypedPayload!;
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
@@ -166,6 +166,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.Equal(0, tracker.ConsecutiveFailures);
|
||||
Assert.Equal(new[] { originalSessionId, rotatedTo }, seenSessionIds);
|
||||
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
|
||||
Assert.Equal(SessionRotationReasons.OwnerMismatch, result.RotationReason);
|
||||
}
|
||||
|
||||
private static BackendRefusalException TenantConflict()
|
||||
@@ -313,6 +314,136 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.Equal(1, tracker.ConsecutiveFailures);
|
||||
}
|
||||
|
||||
// ── 410 at registration: the backend deleted the session ────────────
|
||||
|
||||
private static BackendSessionGoneException SessionGone()
|
||||
=> new BackendSessionGoneException("Backend returned 410 Gone: the session was deleted or is being deleted.", 410);
|
||||
|
||||
[Fact]
|
||||
public async Task Session_gone_ends_the_run_at_once_without_retry_rotation_or_auth_failure()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
var rotations = 0;
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw SessionGone());
|
||||
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
|
||||
clock: SystemClock.Instance, logger: logger);
|
||||
|
||||
// The rotation hook is wired on every start; without the Part 2 flag it must stay unused.
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
authFailureTracker: tracker,
|
||||
backoffDelay: _ => throw new InvalidOperationException("a 410 must not be retried"),
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => { rotations++; return Guid.NewGuid().ToString(); });
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
|
||||
Assert.Equal(410, result.HttpStatusCode);
|
||||
Assert.Equal(1, apiClient.CallCount);
|
||||
Assert.Equal(0, rotations);
|
||||
Assert.Equal(0, tracker.ConsecutiveFailures);
|
||||
Assert.Null(result.RotatedFromSessionId);
|
||||
Assert.Null(result.RotationReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_gone_at_a_whiteglove_part2_start_rotates_once_and_registers_the_new_session()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
var originalSessionId = config.SessionId;
|
||||
var rotatedTo = Guid.NewGuid().ToString();
|
||||
var seenSessionIds = new System.Collections.Generic.List<string>();
|
||||
var rotations = 0;
|
||||
|
||||
var apiClient = new FakeApiClient(
|
||||
attempt => attempt == 1
|
||||
? throw SessionGone()
|
||||
: new RegisterSessionResponse { Success = true, SessionId = rotatedTo, ValidatedBy = ValidatorType.AutopilotV1 },
|
||||
onRegister: r => seenSessionIds.Add(r.SessionId));
|
||||
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
|
||||
clock: SystemClock.Instance, logger: logger);
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
authFailureTracker: tracker,
|
||||
backoffDelay: _ => throw new InvalidOperationException("rotation must retry without backoff"),
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => { rotations++; config.SessionId = rotatedTo; return rotatedTo; },
|
||||
rotateOnSessionGone: true);
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
|
||||
Assert.Equal(2, apiClient.CallCount);
|
||||
Assert.Equal(1, rotations);
|
||||
Assert.Equal(0, tracker.ConsecutiveFailures);
|
||||
Assert.Equal(new[] { originalSessionId, rotatedTo }, seenSessionIds);
|
||||
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
|
||||
Assert.Equal(SessionRotationReasons.SessionGone, result.RotationReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Second_session_gone_after_rotation_ends_the_run()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
var rotations = 0;
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw SessionGone());
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
backoffDelay: _ => throw new InvalidOperationException("a 410 must not be retried"),
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => { rotations++; config.SessionId = Guid.NewGuid().ToString(); return config.SessionId; },
|
||||
rotateOnSessionGone: true);
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
|
||||
Assert.Equal(2, apiClient.CallCount);
|
||||
Assert.Equal(1, rotations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_gone_with_a_failing_rotation_ends_the_run()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw SessionGone());
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => throw new IOException("disk full"),
|
||||
rotateOnSessionGone: true);
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
|
||||
Assert.Equal(1, apiClient.CallCount);
|
||||
Assert.Null(result.RotatedFromSessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_gone_at_a_whiteglove_part2_start_without_rotation_hook_ends_the_run()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw SessionGone());
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateOnSessionGone: true);
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
|
||||
Assert.Equal(1, apiClient.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_after_six_exceptions_without_auth_error()
|
||||
{
|
||||
|
||||
+31
@@ -138,6 +138,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
() => client.GetDiagnosticsUploadUrlAsync("tenant-1", "session-1", "package.zip"));
|
||||
}
|
||||
|
||||
// ── 410: session gone, only when the backend said so ────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Backend_410_on_registration_is_session_gone()
|
||||
{
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Gone,
|
||||
"{\"success\":false,\"message\":\"Session is being deleted by an administrator (state=Tombstoned); registration is rejected.\"}",
|
||||
"application/json");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<BackendSessionGoneException>(
|
||||
() => client.RegisterSessionAsync(new AutopilotMonitor.Shared.Models.SessionRegistration()));
|
||||
|
||||
Assert.Equal(410, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_410_without_the_backends_echo_stays_a_plain_http_error()
|
||||
{
|
||||
// Answered on the network path: the caller would end monitoring on a session-gone
|
||||
// signal, so it must stay an ordinary, retried failure.
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Gone,
|
||||
"<html><head><title>Gone</title></head><body>Gone</body></html>",
|
||||
"text/html",
|
||||
fromBackend: false);
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => client.RegisterSessionAsync(new AutopilotMonitor.Shared.Models.SessionRegistration()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", null)]
|
||||
|
||||
+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
-6
@@ -200,6 +200,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Process attached;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
@@ -208,12 +209,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
_discoveryTimer?.Dispose();
|
||||
_discoveryTimer = null;
|
||||
|
||||
if (_imeProcess != null)
|
||||
{
|
||||
try { _imeProcess.Exited -= OnImeExited; } catch { }
|
||||
_imeProcess.Dispose();
|
||||
_imeProcess = null;
|
||||
}
|
||||
attached = _imeProcess;
|
||||
_imeProcess = null;
|
||||
}
|
||||
|
||||
// Not under the lock: .NET holds its own lock on the Process while it delivers Exited,
|
||||
// OnImeExited takes _lock, and Process.Dispose waits for that delivery to finish.
|
||||
if (attached != null)
|
||||
{
|
||||
try { attached.Exited -= OnImeExited; } catch { }
|
||||
try { attached.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
-19
@@ -194,6 +194,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// subtree) into RJ's lifecycle.
|
||||
private bool _packageWatchersArmed;
|
||||
private bool _disposed;
|
||||
// Set by Stop under _stateLock: a registry callback that is still running then must not
|
||||
// arm or attach another watcher.
|
||||
private bool _stopped;
|
||||
|
||||
public event EventHandler<RealmJoinDetectedEventArgs>? RealmJoinDetected;
|
||||
public event EventHandler<RealmJoinResolvedEventArgs>? RealmJoinResolved;
|
||||
@@ -228,6 +231,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
public void Start()
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(RealmJoinWatcher));
|
||||
lock (_stateLock) { _stopped = false; }
|
||||
|
||||
// The Services\realmjoin gate is the SOLE entry point. HKLM\SOFTWARE\RealmJoin and
|
||||
// the HKU watcher do not run until this fires — RJ presence is the prerequisite for
|
||||
@@ -277,27 +281,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
try
|
||||
{
|
||||
var stoppedAny = false;
|
||||
var watchers = new List<RegistryWatcher>(8);
|
||||
lock (_stateLock)
|
||||
{
|
||||
stoppedAny |= DisposeAndNull(ref _servicesAppearanceWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _machineSoftwareAppearanceWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _userSoftwareAppearanceWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _uninstallAppearanceWatcher);
|
||||
_stopped = true;
|
||||
|
||||
Take(ref _servicesAppearanceWatcher, watchers);
|
||||
Take(ref _machineSoftwareAppearanceWatcher, watchers);
|
||||
Take(ref _userSoftwareAppearanceWatcher, watchers);
|
||||
Take(ref _uninstallAppearanceWatcher, watchers);
|
||||
|
||||
_serviceRealmjoinDebounce?.Dispose(); _serviceRealmjoinDebounce = null;
|
||||
_machineRealmJoinDebounce?.Dispose(); _machineRealmJoinDebounce = null;
|
||||
_userRealmJoinDebounce?.Dispose(); _userRealmJoinDebounce = null;
|
||||
_uninstallDebounce?.Dispose(); _uninstallDebounce = null;
|
||||
|
||||
stoppedAny |= DisposeAndNull(ref _serviceRealmjoinWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _machineRealmJoinWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _userRealmJoinWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _uninstallWatcher);
|
||||
Take(ref _serviceRealmjoinWatcher, watchers);
|
||||
Take(ref _machineRealmJoinWatcher, watchers);
|
||||
Take(ref _userRealmJoinWatcher, watchers);
|
||||
Take(ref _uninstallWatcher, watchers);
|
||||
}
|
||||
|
||||
// Disposed outside the lock: RegistryWatcher.Dispose waits for a callback that is
|
||||
// still running, and the appearance callback takes _stateLock.
|
||||
foreach (var watcher in watchers)
|
||||
{
|
||||
try { watcher.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
// Hosts call Stop and then Dispose, which stops again; only the pass that
|
||||
// stopped a watcher logs.
|
||||
if (stoppedAny) _logger.Info($"RealmJoinWatcher: stopped ({reason})");
|
||||
if (watchers.Count > 0) _logger.Info($"RealmJoinWatcher: stopped ({reason})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -391,6 +405,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
Action clearSlot,
|
||||
Action onAppeared)
|
||||
{
|
||||
lock (_stateLock) { if (_stopped) return; }
|
||||
try
|
||||
{
|
||||
var watcher = new RegistryWatcher(
|
||||
@@ -440,7 +455,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_serviceRealmjoinWatcher != null) return;
|
||||
if (_stopped || _serviceRealmjoinWatcher != null) return;
|
||||
try
|
||||
{
|
||||
_serviceRealmjoinDebounce = new Timer(_ => CheckParameters(), null, Timeout.Infinite, Timeout.Infinite);
|
||||
@@ -486,7 +501,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_machineRealmJoinWatcher != null) return;
|
||||
if (_stopped || _machineRealmJoinWatcher != null) return;
|
||||
try
|
||||
{
|
||||
// Seed dedup sets with every package sub-key already present at arming time.
|
||||
@@ -529,7 +544,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_userRealmJoinWatcher != null) return;
|
||||
if (_stopped || _userRealmJoinWatcher != null) return;
|
||||
try
|
||||
{
|
||||
// Seed dedup sets — see AttachMachineRealmJoinSubtreeWatcher for rationale.
|
||||
@@ -711,7 +726,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_disposed || _uninstallWatcher != null) return;
|
||||
if (_disposed || _stopped || _uninstallWatcher != null) return;
|
||||
try
|
||||
{
|
||||
_uninstallDebounce = new Timer(_ => CheckAutoUpdate(), null, Timeout.Infinite, Timeout.Infinite);
|
||||
@@ -941,12 +956,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
private static bool DisposeAndNull(ref RegistryWatcher? w)
|
||||
// Caller holds _stateLock; the taken watchers are disposed after it is released.
|
||||
private static void Take(ref RegistryWatcher? slot, List<RegistryWatcher> taken)
|
||||
{
|
||||
if (w == null) return false;
|
||||
try { w.Dispose(); } catch { }
|
||||
w = null;
|
||||
return true;
|
||||
if (slot == null) return;
|
||||
taken.Add(slot);
|
||||
slot = null;
|
||||
}
|
||||
|
||||
// Test seams — drive the read paths deterministically without touching the real registry.
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-12
@@ -79,11 +79,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
public void Start()
|
||||
{
|
||||
bool raise;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
TryArm();
|
||||
raise = TryArm();
|
||||
}
|
||||
|
||||
if (raise) Raise();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -129,17 +132,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
}
|
||||
|
||||
// Caller holds _lock.
|
||||
private void TryArm()
|
||||
// Caller holds _lock. Returns true when BinaryAppeared is due; the caller raises it after
|
||||
// releasing the lock, like OnRecheck does — the handler probes and may dispose this watcher.
|
||||
private bool TryArm()
|
||||
{
|
||||
if (_disposed || _fsw != null) return;
|
||||
if (_disposed || _fsw != null) return false;
|
||||
|
||||
// Initial scan — the binaries may already be present (armed late / update over existing).
|
||||
if (CoreBinariesPresent())
|
||||
{
|
||||
Raise();
|
||||
return;
|
||||
}
|
||||
if (CoreBinariesPresent()) return true;
|
||||
|
||||
if (!Directory.Exists(_installationPath))
|
||||
{
|
||||
@@ -154,7 +154,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
_logger.Debug($"[OfficeBinaryWatcher] install path '{_installationPath}' never appeared — giving up (no completion proof)");
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
@@ -172,17 +172,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
_logger.Info($"[OfficeBinaryWatcher] watching '{_installationPath}' for core Office binaries");
|
||||
|
||||
// Race: a binary may have appeared between the scan above and arming — re-scan once.
|
||||
if (CoreBinariesPresent()) Raise();
|
||||
return CoreBinariesPresent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[OfficeBinaryWatcher] could not watch '{_installationPath}': {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnArmRetry(object? state)
|
||||
{
|
||||
lock (_lock) { TryArm(); }
|
||||
bool raise;
|
||||
lock (_lock) { raise = TryArm(); }
|
||||
|
||||
if (raise) Raise();
|
||||
}
|
||||
|
||||
private void OnFileEvent(object sender, FileSystemEventArgs e) => CheckCandidate(e.Name);
|
||||
|
||||
+159
-58
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Management;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
@@ -15,7 +16,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// idle polling:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Start</b> — a true ETW-backed push via WMI <c>Win32_ProcessStartTrace</c>
|
||||
/// (<see cref="ManagementEventWatcher"/>), plus a one-shot startup probe so an install already
|
||||
/// (<see cref="ManagementEventWatcher"/>), plus a startup probe so an install already
|
||||
/// in flight when the agent boots (realistic during OOBE) is still caught.</item>
|
||||
/// <item><b>Stop</b> — <see cref="Process.Exited"/> on each tracked worker (clean OS event, same
|
||||
/// pattern as <c>ImeProcessWatcher</c>).</item>
|
||||
@@ -26,6 +27,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// a single active window). Fail-soft: if the WMI watcher cannot start, it is logged and the
|
||||
/// startup probe still covers the already-running case — no polling fallback by design.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Locking</b>: the lock guards this watcher's own state. No event is raised and no
|
||||
/// <see cref="Process"/> is disposed while it is held: .NET holds its own lock on a
|
||||
/// <see cref="Process"/> for as long as it delivers <see cref="Process.Exited"/>, that handler
|
||||
/// takes this lock, and <see cref="Process.Dispose()"/> waits for the delivery to finish.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class OfficeProcessWatcher : IDisposable
|
||||
{
|
||||
@@ -53,6 +60,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// </summary>
|
||||
public event EventHandler? Stopped;
|
||||
|
||||
/// <summary>Test seam: enumerates the running workers (default: Process.GetProcessesByName).</summary>
|
||||
internal Func<Process[]> WorkerSource { get; set; } = () => Process.GetProcessesByName(WorkerProcessNameNoExt);
|
||||
|
||||
/// <param name="settleSeconds">Grace period after the last worker exits before the window is
|
||||
/// declared closed; a new worker within it keeps the window open. <=0 closes immediately.</param>
|
||||
public OfficeProcessWatcher(AgentLogger logger, int settleSeconds = 10)
|
||||
@@ -67,7 +77,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
get { lock (_lock) { return _active; } }
|
||||
}
|
||||
|
||||
public void Start()
|
||||
public void Start() => Start(armWmi: true);
|
||||
|
||||
/// <summary>Test seam: <paramref name="armWmi"/> = false leaves out the WMI subscription.</summary>
|
||||
internal void Start(bool armWmi)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
@@ -75,10 +88,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
_started = true;
|
||||
}
|
||||
|
||||
// 1) Startup probe — catch a worker already running before the watcher armed.
|
||||
// 1) Startup probe — catch a worker already running before the subscription exists. It
|
||||
// stays on the caller's thread: it needs no WMI, so a worker that is already running is
|
||||
// reported even when the subscription below never completes.
|
||||
ProbeRunningWorkers();
|
||||
|
||||
// 2) ETW-backed push on worker creation — no polling. WinMgmt can hang during OOBE, so the
|
||||
// subscription never runs on the caller's thread (the orchestrator starts its hosts one
|
||||
// after another on it).
|
||||
if (armWmi) Task.Run(() => ArmStartTrace());
|
||||
}
|
||||
|
||||
private void ProbeRunningWorkers()
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var proc in Process.GetProcessesByName(WorkerProcessNameNoExt))
|
||||
foreach (var proc in WorkerSource())
|
||||
{
|
||||
TrackWorker(proc);
|
||||
}
|
||||
@@ -87,24 +112,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
_logger.Warning($"[OfficeProcessWatcher] startup probe failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// 2) ETW-backed push on worker creation — no polling.
|
||||
private void ArmStartTrace()
|
||||
{
|
||||
ManagementEventWatcher? watcher = null;
|
||||
try
|
||||
{
|
||||
var query = new WqlEventQuery(
|
||||
$"SELECT * FROM Win32_ProcessStartTrace WHERE ProcessName = '{WorkerProcessName}'");
|
||||
_startWatcher = new ManagementEventWatcher(query);
|
||||
_startWatcher.EventArrived += OnProcessStartTrace;
|
||||
_startWatcher.Start();
|
||||
_logger.Info($"[OfficeProcessWatcher] watching Win32_ProcessStartTrace for {WorkerProcessName}");
|
||||
watcher = new ManagementEventWatcher(new WqlEventQuery(
|
||||
$"SELECT * FROM Win32_ProcessStartTrace WHERE ProcessName = '{WorkerProcessName}'"));
|
||||
watcher.EventArrived += OnProcessStartTrace;
|
||||
watcher.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// No polling fallback by design — the startup probe already covers the in-flight case.
|
||||
_logger.Warning($"[OfficeProcessWatcher] could not start WMI process-start watcher " +
|
||||
$"({ex.GetType().Name}: {ex.Message}); relying on the startup probe only.");
|
||||
_startWatcher = null;
|
||||
if (watcher != null) TearDownStartTrace(watcher);
|
||||
return;
|
||||
}
|
||||
|
||||
// Published only once it is armed, so Dispose never meets a half-started subscription; one
|
||||
// that completes after Dispose is taken down right here.
|
||||
bool keep;
|
||||
lock (_lock)
|
||||
{
|
||||
keep = !_disposed;
|
||||
if (keep) _startWatcher = watcher;
|
||||
}
|
||||
if (!keep)
|
||||
{
|
||||
TearDownStartTrace(watcher);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info($"[OfficeProcessWatcher] watching Win32_ProcessStartTrace for {WorkerProcessName}");
|
||||
|
||||
// A worker may have started between the probe in Start and the subscription.
|
||||
ProbeRunningWorkers();
|
||||
}
|
||||
|
||||
private void TearDownStartTrace(ManagementEventWatcher watcher)
|
||||
{
|
||||
try { watcher.EventArrived -= OnProcessStartTrace; } catch { }
|
||||
try { watcher.Stop(); } catch { }
|
||||
try { watcher.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
private void OnProcessStartTrace(object sender, EventArrivedEventArgs e)
|
||||
@@ -113,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)
|
||||
{
|
||||
@@ -126,41 +174,76 @@ 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;
|
||||
bool raiseStarted = false;
|
||||
try
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) { try { proc.Dispose(); } catch { } return; }
|
||||
if (_workers.ContainsKey(proc.Id)) { try { proc.Dispose(); } catch { } return; }
|
||||
if (!_disposed && !_workers.ContainsKey(proc.Id))
|
||||
{
|
||||
try
|
||||
{
|
||||
proc.EnableRaisingEvents = true;
|
||||
proc.Exited += OnWorkerExited;
|
||||
tracked = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Process exited between discovery and Exited-hookup — drop it.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
proc.EnableRaisingEvents = true;
|
||||
proc.Exited += OnWorkerExited;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Process exited between discovery and Exited-hookup — drop it.
|
||||
try { proc.Dispose(); } catch { }
|
||||
return;
|
||||
}
|
||||
|
||||
_workers[proc.Id] = proc;
|
||||
// A worker is back — cancel any pending settle (the window must stay open).
|
||||
CancelSettleTimer();
|
||||
if (!_active)
|
||||
{
|
||||
_active = true;
|
||||
raiseStarted = true;
|
||||
if (tracked)
|
||||
{
|
||||
_workers[proc.Id] = proc;
|
||||
// A worker is back — cancel any pending settle (the window must stay open).
|
||||
CancelSettleTimer();
|
||||
if (!_active)
|
||||
{
|
||||
_active = true;
|
||||
raiseStarted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"[OfficeProcessWatcher] TrackWorker error: {ex.Message}");
|
||||
}
|
||||
|
||||
if (!tracked)
|
||||
{
|
||||
// Disposed, already tracked under another handle, or gone before the hookup.
|
||||
try { proc.Dispose(); } catch { }
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -169,6 +252,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
_logger.Info($"[OfficeProcessWatcher] Office worker started (PID={SafePid(proc)}) — install active");
|
||||
RaiseStarted();
|
||||
}
|
||||
|
||||
// .NET raises Exited once per Process object. A worker that exited before the hookup
|
||||
// above was subscribed would stay tracked forever and the window would never close.
|
||||
if (HasExitedSafe(proc))
|
||||
ThreadPool.QueueUserWorkItem(_ => OnWorkerExited(proc, EventArgs.Empty));
|
||||
}
|
||||
|
||||
private void OnWorkerExited(object? sender, EventArgs e)
|
||||
@@ -178,11 +266,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_workers.Remove(SafePid(proc)))
|
||||
{
|
||||
try { proc.Exited -= OnWorkerExited; } catch { }
|
||||
try { proc.Dispose(); } catch { }
|
||||
}
|
||||
// Only the call that still finds this worker acts: after Dispose nothing is tracked,
|
||||
// and TrackWorker's own re-check can deliver the same exit a second time.
|
||||
if (_disposed || !_workers.TryGetValue(SafePid(proc), out var tracked) || !ReferenceEquals(tracked, proc))
|
||||
return;
|
||||
_workers.Remove(SafePid(proc));
|
||||
|
||||
if (_active && _workers.Count == 0)
|
||||
{
|
||||
if (_settleSeconds <= 0)
|
||||
@@ -200,6 +289,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
}
|
||||
|
||||
try { proc.Exited -= OnWorkerExited; } catch { }
|
||||
try { proc.Dispose(); } catch { }
|
||||
|
||||
if (raiseStoppedNow)
|
||||
{
|
||||
_logger.Info("[OfficeProcessWatcher] last Office worker exited — install window closed");
|
||||
@@ -260,31 +352,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
try { return proc.Id; } catch { return -1; }
|
||||
}
|
||||
|
||||
private static bool HasExitedSafe(Process proc)
|
||||
{
|
||||
try { return proc.HasExited; } catch { return false; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ManagementEventWatcher? startWatcher;
|
||||
Process[] workers;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
if (_startWatcher != null)
|
||||
{
|
||||
try { _startWatcher.EventArrived -= OnProcessStartTrace; } catch { }
|
||||
try { _startWatcher.Stop(); } catch { }
|
||||
try { _startWatcher.Dispose(); } catch { }
|
||||
startWatcher = _startWatcher;
|
||||
_startWatcher = null;
|
||||
CancelSettleTimer();
|
||||
workers = new Process[_workers.Count];
|
||||
_workers.Values.CopyTo(workers, 0);
|
||||
_workers.Clear();
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
if (startWatcher != null)
|
||||
{
|
||||
CancelSettleTimer();
|
||||
foreach (var kv in _workers)
|
||||
{
|
||||
try { kv.Value.Exited -= OnWorkerExited; } catch { }
|
||||
try { kv.Value.Dispose(); } catch { }
|
||||
}
|
||||
_workers.Clear();
|
||||
// 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));
|
||||
}
|
||||
|
||||
foreach (var worker in workers)
|
||||
{
|
||||
try { worker.Exited -= OnWorkerExited; } catch { }
|
||||
try { worker.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+65
-28
@@ -26,6 +26,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// </para>
|
||||
/// One-shot per OS contract, so it re-arms after each fire. Fail-soft: any failure is logged and
|
||||
/// the watcher stays quiet (the detector still gets the process Started/Stopped boundary events).
|
||||
/// <para>
|
||||
/// <b>Locking</b>: the lock guards this watcher's own state and is never held while
|
||||
/// <see cref="Changed"/> is raised. A handler may dispose the watcher or take locks of its own,
|
||||
/// and <see cref="Dispose"/> never waits for a handler. Raising under the lock deadlocks against
|
||||
/// an owner that disposes this watcher under a lock its handler also takes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RegistryChangeWatcher : IDisposable
|
||||
{
|
||||
@@ -34,10 +40,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
ChangeName = 0x00000001, // subkey added/removed (ClickToRun appearing; Scenario\* churn)
|
||||
ChangeLastSet = 0x00000004, // value set (Configuration: VersionToReport / StreamingFinished)
|
||||
// The registration must outlive the thread that issued it: re-arming happens on rotating
|
||||
// ThreadPool threads, and without this flag Windows signals the event when such a thread
|
||||
// ends — reported here as a change that never happened.
|
||||
ThreadAgnostic = 0x10000000,
|
||||
}
|
||||
|
||||
private const RegNotifyFilter PrimaryFilter = RegNotifyFilter.ChangeName | RegNotifyFilter.ChangeLastSet;
|
||||
private const RegNotifyFilter BootstrapFilter = RegNotifyFilter.ChangeName; // cheap: direct-child create/delete only
|
||||
private const RegNotifyFilter PrimaryFilter =
|
||||
RegNotifyFilter.ChangeName | RegNotifyFilter.ChangeLastSet | RegNotifyFilter.ThreadAgnostic;
|
||||
// Cheap: direct-child create/delete only.
|
||||
private const RegNotifyFilter BootstrapFilter = RegNotifyFilter.ChangeName | RegNotifyFilter.ThreadAgnostic;
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
private static extern int RegNotifyChangeKeyValue(
|
||||
@@ -49,6 +61,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
private enum WatchMode { None, Primary, Bootstrap }
|
||||
|
||||
private readonly RegistryHive _hive;
|
||||
private readonly string _hiveLabel;
|
||||
private readonly string _targetSubKey;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly object _lock = new object();
|
||||
@@ -66,13 +80,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
/// <param name="targetSubKey">HKLM-relative key, e.g. <c>SOFTWARE\Microsoft\Office\ClickToRun</c>.</param>
|
||||
public RegistryChangeWatcher(string targetSubKey, AgentLogger logger)
|
||||
: this(RegistryHive.LocalMachine, targetSubKey, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam: production always watches HKLM; tests use a hive they may write to.</summary>
|
||||
internal RegistryChangeWatcher(RegistryHive hive, string targetSubKey, AgentLogger logger)
|
||||
{
|
||||
_hive = hive;
|
||||
_hiveLabel = hive == RegistryHive.LocalMachine ? "HKLM" : hive.ToString();
|
||||
_targetSubKey = targetSubKey ?? throw new ArgumentNullException(nameof(targetSubKey));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>The filter the full target watch is armed with (tests).</summary>
|
||||
internal uint EffectivePrimaryFilter => (uint)PrimaryFilter;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
bool raise = false;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_started || _disposed) return;
|
||||
@@ -81,24 +107,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
// Forced Registry64 view — consistent with the C2R reads (AnyCPU net48 may otherwise
|
||||
// resolve the stale WOW6432Node mirror).
|
||||
_baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
|
||||
_baseKey = RegistryKey.OpenBaseKey(_hive, RegistryView.Registry64);
|
||||
_signal = new AutoResetEvent(false);
|
||||
ArmBestEffort(raiseOnPromote: false);
|
||||
raise = ArmBestEffort(targetIsNews: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[RegistryChangeWatcher] failed to start on HKLM\\{_targetSubKey}: {ex.Message}");
|
||||
_logger.Warning($"[RegistryChangeWatcher] failed to start on {_hiveLabel}\\{_targetSubKey}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (raise) RaiseChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Arms the best available watch: the target key (full, subtree) if it exists, otherwise a cheap
|
||||
/// name-only watch on the nearest existing ancestor until the target appears. Must hold <see cref="_lock"/>.
|
||||
/// Returns true when the target key appeared and the caller has to raise <see cref="Changed"/>
|
||||
/// for it — after releasing the lock.
|
||||
/// </summary>
|
||||
private void ArmBestEffort(bool raiseOnPromote)
|
||||
/// <param name="targetIsNews">False when finding the target right away is the expected start
|
||||
/// state; true when the watch was waiting for it.</param>
|
||||
private bool ArmBestEffort(bool targetIsNews)
|
||||
{
|
||||
if (_disposed || _baseKey == null) return;
|
||||
if (_disposed || _baseKey == null) return false;
|
||||
|
||||
CloseWatchedKey();
|
||||
|
||||
@@ -107,27 +139,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
_watchedKey = target;
|
||||
_mode = WatchMode.Primary;
|
||||
if (!ArmNotify(subtree: true, PrimaryFilter)) return;
|
||||
if (raiseOnPromote) RaiseChanged();
|
||||
return;
|
||||
return ArmNotify(subtree: true, PrimaryFilter) && targetIsNews;
|
||||
}
|
||||
|
||||
// Target absent — watch the nearest existing ancestor (non-recursive, name-only = cheap).
|
||||
var ancestor = OpenNearestExistingAncestor(out var ancestorPath);
|
||||
if (ancestor == null)
|
||||
{
|
||||
_logger.Debug($"[RegistryChangeWatcher] no existing ancestor for HKLM\\{_targetSubKey}; not arming");
|
||||
return;
|
||||
_logger.Debug($"[RegistryChangeWatcher] no existing ancestor for {_hiveLabel}\\{_targetSubKey}; not arming");
|
||||
return false;
|
||||
}
|
||||
_watchedKey = ancestor;
|
||||
_mode = WatchMode.Bootstrap;
|
||||
_logger.Debug($"[RegistryChangeWatcher] target HKLM\\{_targetSubKey} absent — bootstrap-watching HKLM\\{ancestorPath} (name-only)", repeatKey: "RegistryChangeWatcher.bootstrap:" + _targetSubKey);
|
||||
if (!ArmNotify(subtree: false, BootstrapFilter)) return;
|
||||
_logger.Debug($"[RegistryChangeWatcher] target {_hiveLabel}\\{_targetSubKey} absent — bootstrap-watching {_hiveLabel}\\{ancestorPath} (name-only)", repeatKey: "RegistryChangeWatcher.bootstrap:" + _targetSubKey);
|
||||
if (!ArmNotify(subtree: false, BootstrapFilter)) return false;
|
||||
|
||||
// TOCTOU: the target may have been created between TryOpen and arming the ancestor watch.
|
||||
using (var recheck = TryOpen(_targetSubKey))
|
||||
{
|
||||
if (recheck != null) ArmBestEffort(raiseOnPromote: true);
|
||||
return recheck != null && ArmBestEffort(targetIsNews: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +169,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
int rc = RegNotifyChangeKeyValue(_watchedKey.Handle, subtree, filter, _signal.SafeWaitHandle, fAsynchronous: true);
|
||||
if (rc != 0)
|
||||
{
|
||||
_logger.Warning($"[RegistryChangeWatcher] RegNotifyChangeKeyValue failed rc={rc} (HKLM\\{_targetSubKey})");
|
||||
_logger.Warning($"[RegistryChangeWatcher] RegNotifyChangeKeyValue failed rc={rc} ({_hiveLabel}\\{_targetSubKey})");
|
||||
return false;
|
||||
}
|
||||
UnregisterWait();
|
||||
@@ -149,29 +179,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[RegistryChangeWatcher] arm failed (HKLM\\{_targetSubKey}): {ex.Message}");
|
||||
_logger.Warning($"[RegistryChangeWatcher] arm failed ({_hiveLabel}\\{_targetSubKey}): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSignalled(object? state, bool timedOut)
|
||||
{
|
||||
bool raise;
|
||||
bool rearmAfterHandler;
|
||||
lock (_lock)
|
||||
{
|
||||
UnregisterWait();
|
||||
if (_disposed || timedOut) return;
|
||||
|
||||
if (_mode == WatchMode.Primary)
|
||||
{
|
||||
RaiseChanged();
|
||||
ArmNotify(subtree: true, PrimaryFilter); // re-arm on the same target key
|
||||
}
|
||||
else
|
||||
{
|
||||
// A direct child changed under the ancestor — promote to the target watch if it now
|
||||
// exists (raises Changed on promotion), else keep the cheap bootstrap watch.
|
||||
ArmBestEffort(raiseOnPromote: true);
|
||||
}
|
||||
rearmAfterHandler = _mode == WatchMode.Primary;
|
||||
// Bootstrap: a direct child changed under the ancestor — promote to the target watch if
|
||||
// it now exists (then Changed is due), else keep the cheap bootstrap watch.
|
||||
raise = rearmAfterHandler || ArmBestEffort(targetIsNews: true);
|
||||
}
|
||||
|
||||
if (raise) RaiseChanged();
|
||||
|
||||
if (!rearmAfterHandler) return;
|
||||
lock (_lock)
|
||||
{
|
||||
// Re-arm on the same target key, and only now: no wait was registered while the handler
|
||||
// ran, so handlers never overlap, and a change that arrived meanwhile completes this
|
||||
// registration at once.
|
||||
ArmNotify(subtree: true, PrimaryFilter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +236,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
}
|
||||
|
||||
// Never called with _lock held.
|
||||
private void RaiseChanged()
|
||||
{
|
||||
var handler = Changed;
|
||||
|
||||
+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; }
|
||||
}
|
||||
}
|
||||
@@ -260,6 +260,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
await ThrowOnAuthFailureAsync(response, correlationId).ConfigureAwait(false);
|
||||
await ThrowOnCodedConflictAsync(response).ConfigureAwait(false);
|
||||
ThrowOnSessionGone(response, correlationId);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
@@ -412,6 +413,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
errorCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throws <see cref="BackendSessionGoneException"/> for a 410 the backend answered: the
|
||||
/// session was deleted or is being deleted. Only a response that echoes this request's
|
||||
/// <c>X-Correlation-ID</c> counts — the caller ends monitoring on it, so a 410 from the
|
||||
/// network path stays an ordinary failure (<c>EnsureSuccessStatusCode</c>) and is retried.
|
||||
/// </summary>
|
||||
private static void ThrowOnSessionGone(HttpResponseMessage response, string correlationId)
|
||||
{
|
||||
if (response.StatusCode != HttpStatusCode.Gone)
|
||||
return;
|
||||
if (!ResponseOriginClassifier.EchoesCorrelationId(response, correlationId))
|
||||
return;
|
||||
|
||||
throw new BackendSessionGoneException(
|
||||
"Backend returned 410 Gone: the session was deleted or is being deleted.",
|
||||
(int)response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pulls <c>errorCode</c> out of a backend JSON error body. Tolerant: any parse failure
|
||||
/// or a non-string value yields null, never an exception.
|
||||
@@ -537,4 +556,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown for a 410 Gone the backend answered: the session named in the request was deleted or
|
||||
/// is being deleted. No retry can clear it and it says nothing about the device's authorization.
|
||||
/// </summary>
|
||||
public class BackendSessionGoneException : Exception
|
||||
{
|
||||
/// <summary>HTTP status code returned by the backend (410).</summary>
|
||||
public int StatusCode { get; }
|
||||
|
||||
public BackendSessionGoneException(string message, int statusCode) : base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
/// - Same error key (ErrorType + HTTP status code) is only ever sent once per session
|
||||
/// - At most <see cref="MaxReportsPerSession"/> emergency reports total per session
|
||||
/// - At most one report every <see cref="MinIntervalMinutes"/> minutes regardless of error type
|
||||
/// (the terminal <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/> report is exempt)
|
||||
/// (the terminal session-gone reports <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/>
|
||||
/// and <see cref="AgentErrorType.RegistrationRejectedSessionGone"/> are exempt)
|
||||
///
|
||||
/// A failure in this reporter is silently swallowed — it must never cascade into the main loop.
|
||||
/// </summary>
|
||||
@@ -123,11 +124,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
return;
|
||||
}
|
||||
|
||||
// The session-unknown report is terminal and sent at most once per process (the
|
||||
// A session-gone report is terminal and sent at most once per process (the
|
||||
// termination follows immediately): a routine report minutes earlier must not cost
|
||||
// the last word about the session. Only the cooldown is waived; key dedup and the
|
||||
// per-session maximum still apply.
|
||||
var exemptFromCooldown = errorType == AgentErrorType.TelemetryRejectedSessionUnknown;
|
||||
var exemptFromCooldown = errorType == AgentErrorType.TelemetryRejectedSessionUnknown
|
||||
|| errorType == AgentErrorType.RegistrationRejectedSessionGone;
|
||||
if (!exemptFromCooldown && _lastReportTime != null &&
|
||||
(now - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes)
|
||||
{
|
||||
|
||||
@@ -67,6 +67,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// <summary>Default Timeout für den Terminal-Drain beim Stop.</summary>
|
||||
public static readonly TimeSpan DefaultTerminalDrainTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// How long termination waits for ONE collector host to stop (and later to dispose). A whole
|
||||
/// healthy stop pass takes about 0.2 s and the slowest single host seen in the field 7 s, so
|
||||
/// this only ever ends the wait for a host that hangs.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultCollectorHostStopTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly string _stateDirectory;
|
||||
@@ -81,6 +88,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly int _quarantineThreshold;
|
||||
private readonly TimeSpan _drainInterval;
|
||||
private readonly TimeSpan _terminalDrainTimeout;
|
||||
private readonly TimeSpan _collectorHostStopTimeout;
|
||||
private readonly TimeSpan? _agentMaxLifetime;
|
||||
private readonly int _uploadBatchSize;
|
||||
|
||||
@@ -124,6 +132,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private SessionTraceOrdinalProvider? _traceCounter;
|
||||
private SignalIngress? _ingress;
|
||||
private IReadOnlyList<ICollectorHost>? _collectorHosts;
|
||||
// Hosts that did not come back from Stop or Dispose in time. A thread is still inside them,
|
||||
// so nothing calls into them again. Locked on itself.
|
||||
private readonly HashSet<ICollectorHost> _unresponsiveHosts = new HashSet<ICollectorHost>();
|
||||
|
||||
private EventHandler<DeadlineFiredEventArgs>? _deadlineBridge;
|
||||
|
||||
@@ -181,7 +192,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int uploadBatchSize = 100,
|
||||
IDeadlineScheduler? schedulerOverride = null,
|
||||
Func<bool>? traceEventsEnabled = null,
|
||||
Func<bool>? espContinueAnywayObservationEnabled = null)
|
||||
Func<bool>? espContinueAnywayObservationEnabled = null,
|
||||
TimeSpan? collectorHostStopTimeout = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId));
|
||||
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId));
|
||||
@@ -211,6 +223,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_quarantineThreshold = quarantineThreshold;
|
||||
_drainInterval = drainInterval ?? DefaultDrainInterval;
|
||||
_terminalDrainTimeout = terminalDrainTimeout ?? DefaultTerminalDrainTimeout;
|
||||
_collectorHostStopTimeout = collectorHostStopTimeout ?? DefaultCollectorHostStopTimeout;
|
||||
|
||||
if (agentMaxLifetime.HasValue && agentMaxLifetime.Value <= TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(agentMaxLifetime), "AgentMaxLifetime must be positive when set.");
|
||||
@@ -947,6 +960,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// diagnostics ZIP is built so periodic collectors (PerformanceCollector,
|
||||
/// AgentSelfMetricsCollector, …) don't keep emitting <c>performance_snapshot</c> /
|
||||
/// <c>agent_metrics_snapshot</c> events while the package is being assembled.
|
||||
/// <para>
|
||||
/// Bounded per host (<see cref="DefaultCollectorHostStopTimeout"/>): a host that hangs is
|
||||
/// left behind and the remaining hosts are still stopped. The diagnostics upload and the
|
||||
/// self-destruct come after this call, so it has to return whatever a collector does.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void StopCollectorHosts()
|
||||
{
|
||||
@@ -955,8 +973,94 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
foreach (var host in _collectorHosts)
|
||||
{
|
||||
try { host.Stop(); }
|
||||
catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: host '{host.Name}' stop failed: {ex.Message}"); }
|
||||
RunHostStepBounded(host, "stop", h => h.Stop());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one host's Stop or Dispose on a thread of its own and waits at most
|
||||
/// <see cref="_collectorHostStopTimeout"/> for it.
|
||||
/// </summary>
|
||||
private void RunHostStepBounded(ICollectorHost host, string step, Action<ICollectorHost> action)
|
||||
{
|
||||
lock (_unresponsiveHosts)
|
||||
{
|
||||
if (_unresponsiveHosts.Contains(host)) return;
|
||||
}
|
||||
|
||||
Exception? failure = null;
|
||||
var worker = new Thread(() =>
|
||||
{
|
||||
try { action(host); }
|
||||
catch (Exception ex) { failure = ex; }
|
||||
})
|
||||
{
|
||||
// Not a ThreadPool thread: a host that hangs would keep one for good, and the pool may
|
||||
// be the very thing that is stuck. Background, so it cannot keep the process alive.
|
||||
IsBackground = true,
|
||||
Name = $"CollectorHost.{step}:{host.Name}",
|
||||
};
|
||||
try { worker.Start(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentOrchestrator: host '{host.Name}' {step} could not be started: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (worker.Join(_collectorHostStopTimeout))
|
||||
{
|
||||
if (failure == null) return;
|
||||
if (step == "stop")
|
||||
_logger.Warning($"EnrollmentOrchestrator: host '{host.Name}' stop failed: {failure.Message}");
|
||||
else
|
||||
_logger.Debug($"EnrollmentOrchestrator: host '{host.Name}' {step} failed: {failure.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_unresponsiveHosts)
|
||||
{
|
||||
_unresponsiveHosts.Add(host);
|
||||
}
|
||||
var seconds = (int)Math.Ceiling(_collectorHostStopTimeout.TotalSeconds);
|
||||
_logger.Warning(
|
||||
$"EnrollmentOrchestrator: host '{host.Name}' did not return from {step} within {seconds}s — " +
|
||||
"termination continues without it.");
|
||||
if (step == "stop") EmitCollectorHostStopTimeout(host.Name, seconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One <c>agent_trace</c> per host that hung in Stop, so the fleet can count it. Deliberately
|
||||
/// not <c>collector_degraded</c>: that one says the collector's signals are missing and gates
|
||||
/// analyze rules, while a host that will not stop has missed nothing. (A host that hangs in
|
||||
/// Dispose is logged only — the ingress is already down by then.)
|
||||
/// </summary>
|
||||
private void EmitCollectorHostStopTimeout(string hostName, int timeoutSeconds)
|
||||
{
|
||||
var ingress = _ingress;
|
||||
if (ingress == null) return;
|
||||
try
|
||||
{
|
||||
new InformationalEventPost(ingress, _clock).Emit(new Shared.Models.EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.AgentTrace,
|
||||
Severity = Shared.Models.EventSeverity.Warning,
|
||||
Source = "EnrollmentOrchestrator",
|
||||
Phase = Shared.Models.EnrollmentPhase.Unknown,
|
||||
Message = $"Collector host '{hostName}' did not stop within {timeoutSeconds}s; termination continued without it.",
|
||||
Timestamp = _clock.UtcNow,
|
||||
Data = new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["decision"] = "collector_host_stop_timeout",
|
||||
["host"] = hostName,
|
||||
["timeoutSeconds"] = timeoutSeconds,
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"EnrollmentOrchestrator: failed to post collector_host_stop_timeout: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1043,12 +1147,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: final snapshot save failed: {ex.Message}"); }
|
||||
|
||||
// 7) Dispose aller Disposables.
|
||||
// 7) Dispose all disposables. Hosts go through the same bound as in StopCollectorHosts;
|
||||
// one that is still stuck in Stop is skipped (its Dispose would wait on the same thing).
|
||||
if (_collectorHosts != null)
|
||||
{
|
||||
foreach (var host in _collectorHosts)
|
||||
{
|
||||
try { host.Dispose(); } catch { /* best-effort */ }
|
||||
RunHostStepBounded(host, "dispose", h => h.Dispose());
|
||||
}
|
||||
}
|
||||
try { _transport?.Dispose(); } catch { /* best-effort */ }
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
|
||||
|
||||
+64
-23
@@ -4,6 +4,7 @@ using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
@@ -49,9 +50,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly OfficeInstallDetector _detector;
|
||||
private readonly OfficeProcessWatcher _processWatcher;
|
||||
private readonly AgentLogger _logger;
|
||||
// Guards the fields below, nothing else. Never held across a call into a watcher or the
|
||||
// detector: their callbacks come back into this host and take it.
|
||||
private readonly object _lock = new object();
|
||||
|
||||
private readonly OfficeInstallStateData? _resumeState;
|
||||
private readonly RegistryHive _registryHive;
|
||||
private readonly string _registrySubKey;
|
||||
private RegistryChangeWatcher? _registryWatcher;
|
||||
private OfficeBinaryWatcher? _binaryWatcher;
|
||||
private bool _pathObserved; // InstallationPath known → stop poking the DO collector
|
||||
@@ -74,16 +79,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int settleSeconds,
|
||||
OfficeInstallStatePersistence? statePersistence = null,
|
||||
OfficeInstallStateData? resumeState = null)
|
||||
: this(sessionId, tenantId, ingress, clock, logger, settleSeconds, statePersistence, resumeState,
|
||||
RegistryHive.LocalMachine, ClickToRunSubKey, snapshotProvider: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: points the registry watcher at a key a test may write to and feeds the
|
||||
/// detector synthetic snapshots. Production uses the constructor above.
|
||||
/// </summary>
|
||||
internal OfficeInstallDetectorHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AgentLogger logger,
|
||||
int settleSeconds,
|
||||
OfficeInstallStatePersistence? statePersistence,
|
||||
OfficeInstallStateData? resumeState,
|
||||
RegistryHive registryHive,
|
||||
string registrySubKey,
|
||||
Func<OfficeC2RSnapshot>? snapshotProvider)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_resumeState = resumeState;
|
||||
_registryHive = registryHive;
|
||||
_registrySubKey = registrySubKey ?? throw new ArgumentNullException(nameof(registrySubKey));
|
||||
|
||||
var post = new InformationalEventPost(ingress, clock);
|
||||
_detector = new OfficeInstallDetector(sessionId, tenantId, post, logger, clock,
|
||||
onInstallationPathObserved: OnInstallationPathObserved,
|
||||
statePersistence: statePersistence);
|
||||
statePersistence: statePersistence)
|
||||
{
|
||||
SnapshotProvider = snapshotProvider,
|
||||
};
|
||||
_processWatcher = new OfficeProcessWatcher(logger, settleSeconds);
|
||||
_processWatcher.Started += OnWorkerStarted;
|
||||
}
|
||||
@@ -108,12 +139,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// Arm the registry watcher up front (not at worker-start) so the early Scenario\INSTALL key
|
||||
// is caught before the (late) worker process. The watcher's bootstrap fallback handles the
|
||||
// case where the ClickToRun key does not exist yet on a clean first install.
|
||||
lock (_lock)
|
||||
{
|
||||
_registryWatcher = new RegistryChangeWatcher(ClickToRunSubKey, _logger);
|
||||
_registryWatcher.Changed += OnRegistryChanged;
|
||||
_registryWatcher.Start();
|
||||
}
|
||||
var registryWatcher = new RegistryChangeWatcher(_registryHive, _registrySubKey, _logger);
|
||||
registryWatcher.Changed += OnRegistryChanged;
|
||||
lock (_lock) { _registryWatcher = registryWatcher; }
|
||||
registryWatcher.Start();
|
||||
_processWatcher.Start();
|
||||
|
||||
// Resume a persisted *Active* lifecycle (enrollments span reboots) AFTER the watchers are
|
||||
@@ -134,7 +163,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// re-poking it on every registry value churn is pure log noise (field session 7da7dead).
|
||||
bool raise;
|
||||
lock (_lock) { raise = !_pathObserved && !_lifecycleEnded; }
|
||||
if (raise)
|
||||
// A callback can still be running after Dispose returned — Dispose never waits for one.
|
||||
if (raise && Volatile.Read(ref _disposed) == 0)
|
||||
{
|
||||
try { OfficeExpected?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Warning($"[OfficeInstallDetectorHost] OfficeExpected handler threw: {ex.Message}"); }
|
||||
@@ -147,15 +177,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
private void OnInstallationPathObserved(string? installationPath)
|
||||
{
|
||||
OfficeBinaryWatcher watcher;
|
||||
lock (_lock)
|
||||
{
|
||||
_pathObserved = true; // stop raising OfficeExpected (lifecycle has started)
|
||||
if (_lifecycleEnded || _binaryWatcher != null || string.IsNullOrEmpty(installationPath)) return;
|
||||
if (Volatile.Read(ref _disposed) == 1) return;
|
||||
|
||||
_binaryWatcher = new OfficeBinaryWatcher(installationPath!, OfficeInstallDetector.CoreBinaries, _logger);
|
||||
_binaryWatcher.BinaryAppeared += OnBinaryAppeared;
|
||||
_binaryWatcher.Start();
|
||||
watcher = new OfficeBinaryWatcher(installationPath!, OfficeInstallDetector.CoreBinaries, _logger);
|
||||
watcher.BinaryAppeared += OnBinaryAppeared;
|
||||
_binaryWatcher = watcher;
|
||||
}
|
||||
|
||||
// Start can raise BinaryAppeared right away (Office already on disk), and that handler
|
||||
// ends in DisposeWatchers.
|
||||
watcher.Start();
|
||||
}
|
||||
|
||||
private void OnBinaryAppeared(object? sender, EventArgs e)
|
||||
@@ -191,20 +227,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
private void DisposeWatchers()
|
||||
{
|
||||
RegistryChangeWatcher? registryWatcher;
|
||||
OfficeBinaryWatcher? binaryWatcher;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_registryWatcher != null)
|
||||
{
|
||||
try { _registryWatcher.Changed -= OnRegistryChanged; } catch { }
|
||||
try { _registryWatcher.Dispose(); } catch { }
|
||||
_registryWatcher = null;
|
||||
}
|
||||
if (_binaryWatcher != null)
|
||||
{
|
||||
try { _binaryWatcher.BinaryAppeared -= OnBinaryAppeared; } catch { }
|
||||
try { _binaryWatcher.Dispose(); } catch { }
|
||||
_binaryWatcher = null;
|
||||
}
|
||||
registryWatcher = _registryWatcher;
|
||||
_registryWatcher = null;
|
||||
binaryWatcher = _binaryWatcher;
|
||||
_binaryWatcher = null;
|
||||
}
|
||||
|
||||
if (registryWatcher != null)
|
||||
{
|
||||
try { registryWatcher.Changed -= OnRegistryChanged; } catch { }
|
||||
try { registryWatcher.Dispose(); } catch { }
|
||||
}
|
||||
if (binaryWatcher != null)
|
||||
{
|
||||
try { binaryWatcher.BinaryAppeared -= OnBinaryAppeared; } catch { }
|
||||
try { binaryWatcher.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>
|
||||
|
||||
@@ -46,6 +46,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// authorized agent.</item>
|
||||
/// <item>Any other coded 409 (<see cref="BackendRefusalException"/>) → <see cref="SessionRegistrationOutcome.Failed"/>
|
||||
/// without retrying — a refusal with a code does not clear by waiting.</item>
|
||||
/// <item>410 from the backend (<see cref="BackendSessionGoneException"/>): the persisted
|
||||
/// session was deleted. With <c>rotateOnSessionGone</c> (a WhiteGlove Part 2 start, whose
|
||||
/// enrollment is only now continuing) the session is rotated like above and registered
|
||||
/// once more. Every other start returns <see cref="SessionRegistrationOutcome.SessionGone"/>
|
||||
/// without retrying — the caller ends monitoring. So does a second 410 or a failed rotation:
|
||||
/// the 48-hour break skips a WhiteGlove resume, so no path here may leave it retrying.</item>
|
||||
/// <item>Any other exception on the last attempt → <see cref="EmergencyReporter.TrySendAsync"/>
|
||||
/// with <c>AgentErrorType.RegisterSessionFailed</c> so operators see the final cause.</item>
|
||||
/// </list>
|
||||
@@ -75,6 +81,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// Optional pre-registration link wait used by tests. Production callers pass
|
||||
/// <c>null</c> → <see cref="WaitForNetworkLinkAsync"/> (bounded by <see cref="NetworkLinkWaitMax"/>).
|
||||
/// </param>
|
||||
/// <param name="rotateOnSessionGone">
|
||||
/// True at a WhiteGlove Part 2 start: a 410 for the persisted session then becomes a new
|
||||
/// session through <paramref name="rotateSession"/> instead of ending monitoring.
|
||||
/// </param>
|
||||
public static async Task<SessionRegistrationResult> RegisterWithRetryAsync(
|
||||
BackendApiClient apiClient,
|
||||
AgentConfiguration agentConfig,
|
||||
@@ -86,7 +96,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
Func<Exception, Task> onTerminalTransportFailure = null,
|
||||
(string Manufacturer, string Model, string SerialNumber)? deviceHardware = null,
|
||||
Func<AgentLogger, Task> networkLinkWait = null,
|
||||
Func<string> rotateSession = null)
|
||||
Func<string> rotateSession = null,
|
||||
bool rotateOnSessionGone = false)
|
||||
{
|
||||
if (apiClient == null) throw new ArgumentNullException(nameof(apiClient));
|
||||
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
|
||||
@@ -101,6 +112,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
string lastError = null;
|
||||
Exception lastException = null;
|
||||
string rotatedFromSessionId = null;
|
||||
string rotationReason = null;
|
||||
|
||||
for (int attempt = 1; attempt <= MaxAttempts; attempt++)
|
||||
{
|
||||
@@ -112,7 +124,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
if (response != null && response.Success)
|
||||
{
|
||||
logger.Info($"Session registered successfully: {response.SessionId} (validatedBy={response.ValidatedBy}, adminAction={response.AdminAction ?? "(none)"})");
|
||||
return SessionRegistrationResult.Succeeded(response, rotatedFromSessionId);
|
||||
return SessionRegistrationResult.Succeeded(response, rotatedFromSessionId, rotationReason);
|
||||
}
|
||||
|
||||
lastError = response?.Message ?? "(null response)";
|
||||
@@ -149,9 +161,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
$"Backend refused registration with {Constants.AgentErrorCodes.SessionOwnerMismatch}: session {rotatedFromSessionId} " +
|
||||
$"is not this device's (bound to another device identity after a re-enrollment without wipe, or claimed by another tenant). " +
|
||||
$"Rotated to {newSessionId}; re-registering.");
|
||||
rotationReason = SessionRotationReasons.OwnerMismatch;
|
||||
registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
|
||||
continue;
|
||||
}
|
||||
catch (BackendSessionGoneException ex)
|
||||
{
|
||||
if (rotateOnSessionGone && rotateSession != null && rotatedFromSessionId == null)
|
||||
{
|
||||
var goneSessionId = registration.SessionId;
|
||||
string newSessionId = null;
|
||||
try
|
||||
{
|
||||
newSessionId = rotateSession();
|
||||
}
|
||||
catch (Exception rotateEx)
|
||||
{
|
||||
logger.Error($"Backend answered 410 for session {goneSessionId} but session rotation failed: {rotateEx.Message}", rotateEx);
|
||||
}
|
||||
|
||||
if (newSessionId != null)
|
||||
{
|
||||
rotatedFromSessionId = goneSessionId;
|
||||
rotationReason = SessionRotationReasons.SessionGone;
|
||||
logger.Warning(
|
||||
$"Backend answered 410 for session {goneSessionId} at the start of a WhiteGlove Part 2: " +
|
||||
$"the session was deleted. Rotated to {newSessionId}; re-registering.");
|
||||
registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
logger.Error($"Session registration refused with 410 Gone — the backend deleted session {registration.SessionId}.");
|
||||
return SessionRegistrationResult.SessionGone(ex.StatusCode, ex.Message);
|
||||
}
|
||||
catch (BackendRefusalException ex)
|
||||
{
|
||||
logger.Error($"Session registration refused ({ex.StatusCode}, {ex.ErrorCode}): {ex.Message}");
|
||||
@@ -294,6 +337,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
|
||||
/// <summary>All attempts failed with non-auth errors (network, 5xx, malformed response).</summary>
|
||||
Failed = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 410 from the backend: the persisted session was deleted. No retry was attempted; the
|
||||
/// caller ends monitoring and cleans up.
|
||||
/// </summary>
|
||||
SessionGone = 3,
|
||||
}
|
||||
|
||||
/// <summary>Why a run registered under a new SessionId (<see cref="SessionRegistrationResult.RotationReason"/>).</summary>
|
||||
public static class SessionRotationReasons
|
||||
{
|
||||
/// <summary>The backend refused the persisted id with <c>session_owner_mismatch</c>.</summary>
|
||||
public const string OwnerMismatch = "owner_mismatch";
|
||||
|
||||
/// <summary>The backend answered 410 for the persisted id at a WhiteGlove Part 2 start.</summary>
|
||||
public const string SessionGone = "session_gone";
|
||||
}
|
||||
|
||||
/// <summary>Result of <see cref="SessionRegistrationHelper.RegisterWithRetryAsync"/>.</summary>
|
||||
@@ -308,12 +367,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
public ValidatorType ValidatedBy => Response?.ValidatedBy ?? ValidatorType.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// SESSION-OWNER-BINDING: the SessionId this run started with when the backend refused it
|
||||
/// with <c>session_owner_mismatch</c> and the session was rotated before the successful
|
||||
/// The SessionId this run started with when the backend refused it (<c>session_owner_mismatch</c>,
|
||||
/// or 410 at a WhiteGlove Part 2 start) and the session was rotated before the successful
|
||||
/// registration. Null on the normal path.
|
||||
/// </summary>
|
||||
public string RotatedFromSessionId { get; }
|
||||
|
||||
/// <summary>One of <see cref="SessionRotationReasons"/> when <see cref="RotatedFromSessionId"/> is set, else null.</summary>
|
||||
public string RotationReason { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the run ended on a 401/403 that something on the network path answered (TLS
|
||||
/// inspection, a proxy page — <see cref="BackendAuthException.IsForeignResponse"/>), not
|
||||
@@ -327,7 +389,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
int httpStatusCode,
|
||||
string errorMessage,
|
||||
string rotatedFromSessionId = null,
|
||||
bool isIntercepted = false)
|
||||
bool isIntercepted = false,
|
||||
string rotationReason = null)
|
||||
{
|
||||
Outcome = outcome;
|
||||
Response = response;
|
||||
@@ -335,10 +398,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
ErrorMessage = errorMessage;
|
||||
RotatedFromSessionId = rotatedFromSessionId;
|
||||
IsIntercepted = isIntercepted;
|
||||
RotationReason = rotatedFromSessionId != null ? rotationReason : null;
|
||||
}
|
||||
|
||||
public static SessionRegistrationResult Succeeded(RegisterSessionResponse response, string rotatedFromSessionId = null)
|
||||
=> new SessionRegistrationResult(SessionRegistrationOutcome.Succeeded, response, 200, null, rotatedFromSessionId);
|
||||
public static SessionRegistrationResult Succeeded(
|
||||
RegisterSessionResponse response, string rotatedFromSessionId = null, string rotationReason = null)
|
||||
=> new SessionRegistrationResult(
|
||||
SessionRegistrationOutcome.Succeeded, response, 200, null, rotatedFromSessionId, rotationReason: rotationReason);
|
||||
|
||||
public static SessionRegistrationResult SessionGone(int statusCode, string message)
|
||||
=> new SessionRegistrationResult(SessionRegistrationOutcome.SessionGone, null, statusCode, message);
|
||||
|
||||
public static SessionRegistrationResult AuthFailed(int statusCode, string message, bool intercepted = false)
|
||||
=> new SessionRegistrationResult(SessionRegistrationOutcome.AuthFailed, null, statusCode, message, isIntercepted: intercepted);
|
||||
|
||||
@@ -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";
|
||||
|
||||
+3
-1
@@ -324,7 +324,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
// DeliveryOptimizationCollector, …) before the diagnostics ZIP is built so
|
||||
// late `performance_snapshot` / `agent_metrics_snapshot` events don't slip in
|
||||
// after `diagnostics_collecting` and the snapshot captured in the package
|
||||
// matches the timeline. Best-effort — never blocks termination.
|
||||
// matches the timeline. Best-effort, and bounded per host by the orchestrator:
|
||||
// a collector that hangs is left behind, so the upload and the cleanup below
|
||||
// still run.
|
||||
StopPeripheralCollectorsBestEffort();
|
||||
|
||||
RunUploadDiagnosticsWithEvents(args);
|
||||
|
||||
@@ -257,7 +257,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
/// <c>true</c> when the file-based <c>enrollment-complete.marker</c> signals that a
|
||||
/// previous session already completed enrollment — written by
|
||||
/// <see cref="EndMonitoringAtStart"/> (emergency break, intercepted-registration give-up,
|
||||
/// config kill signal) right before it fires the cleanup retry, so that a next-boot observing the marker exits
|
||||
/// session gone at registration, config kill signal) right before it fires the cleanup retry, so that a next-boot observing the marker exits
|
||||
/// cleanly even if the cleanup retry failed to remove the Scheduled Task. Triggers another
|
||||
/// self-destruct attempt when <paramref name="selfDestructOnComplete"/> is set.
|
||||
/// <para>
|
||||
@@ -369,10 +369,11 @@ namespace AutopilotMonitor.Agent.V2
|
||||
/// next start exits cleanly even if the cleanup retry fails (Scheduled Task still alive,
|
||||
/// files locked, …), runs the cleanup retry when <paramref name="runSelfDestruct"/> is set
|
||||
/// (the emergency break and the give-up pass <c>SelfDestructOnComplete</c>, the kill signal
|
||||
/// forces it), and deletes the persisted session files. Shared by the session-age emergency
|
||||
/// break, the intercepted-registration give-up
|
||||
/// (<c>BackendSessionRegistration.ConcludeFailedRun</c>) and the config-channel kill signal
|
||||
/// so all three leave the device in the same state. Never throws.
|
||||
/// and the session-gone end force it), and deletes the persisted session files. Shared by
|
||||
/// the session-age emergency break, the intercepted-registration give-up
|
||||
/// (<c>BackendSessionRegistration.ConcludeFailedRun</c>), the registration answered with 410
|
||||
/// (<c>BackendSessionRegistration.ConcludeSessionGone</c>) and the config-channel kill signal
|
||||
/// so all four leave the device in the same state. Never throws.
|
||||
/// </summary>
|
||||
internal static void EndMonitoringAtStart(
|
||||
string stateDirectory,
|
||||
|
||||
@@ -275,7 +275,9 @@ namespace AutopilotMonitor.Agent.V2
|
||||
// mapping + on-failure client disposal) is encapsulated in BackendSessionRegistration.
|
||||
// Exit codes: 6 = AuthFailed, 7 = anything else. A registration intercepted on more
|
||||
// starts than the buffer allows cleans up like the emergency break (state folder +
|
||||
// cleanup factory) and still exits 6. See Runtime/BackendSessionRegistration.cs.
|
||||
// cleanup factory) and still exits 6. A registration the backend answers with 410
|
||||
// (session deleted) cleans up the same way and exits 0; a WhiteGlove Part 2 start
|
||||
// registers a new session instead. See Runtime/BackendSessionRegistration.cs.
|
||||
var registration = Runtime.BackendSessionRegistration.Register(
|
||||
bootstrap.AgentConfig, auth, telemetry.MtlsHttpClient, GetAgentVersion(), consoleMode, logger,
|
||||
rotateSession: () => Runtime.BackendSessionRegistration.RotateSession(
|
||||
@@ -283,7 +285,8 @@ namespace AutopilotMonitor.Agent.V2
|
||||
sessionPersistence: bootstrap.SessionPersistence,
|
||||
configFetchOutcome: runtimeConfig.RemoteConfigService.LastFetchOutcome.ToString(),
|
||||
stateDirectory: stateSubdir,
|
||||
cleanupServiceFactory: bootstrap.CleanupServiceFactory);
|
||||
cleanupServiceFactory: bootstrap.CleanupServiceFactory,
|
||||
isWhiteGloveResume: bootstrap.IsWhiteGloveResume);
|
||||
if (registration.ShouldExit)
|
||||
{
|
||||
return registration.ExitCode;
|
||||
|
||||
@@ -347,7 +347,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
lifecyclePost = new InformationalEventPost(ingress, SystemClock.Instance, logger);
|
||||
LifecycleEmitters.EmitAgentStarted(lifecyclePost, agentConfig, previousExit, agentVersion, remoteConfigService, logger,
|
||||
rotatedFromSessionId: registrationResult?.RotatedFromSessionId,
|
||||
priorRegistrationFailures: registration.PriorFailures);
|
||||
priorRegistrationFailures: registration.PriorFailures,
|
||||
sessionRotationReason: registrationResult?.RotationReason);
|
||||
// Wire-visible signal that the agent is running on defaults/cache
|
||||
// rather than the live tenant config — closes the cold-start
|
||||
// blind spot observed in session 8f2bef72 (2026-05-19).
|
||||
|
||||
@@ -26,6 +26,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// the next boot. The failed run is recorded for the emergency break report. A registration
|
||||
/// that something on the network path answered gets <c>InterceptedRegistrationRetryBoots</c>
|
||||
/// further boots, then the agent cleans up like the emergency break (<see cref="ConcludeFailedRun"/>).
|
||||
/// A registration the backend answers with 410 — the session was deleted — ends monitoring at
|
||||
/// once (<see cref="ConcludeSessionGone"/>); only a WhiteGlove Part 2 start registers a new
|
||||
/// session instead, because its enrollment is only now continuing.
|
||||
/// A successful registration after failed starts reports them on <c>agent_started</c>
|
||||
/// (<see cref="ClearAfterSuccess"/>).
|
||||
/// </summary>
|
||||
@@ -37,8 +40,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan PendingDistressBound = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <param name="stateDirectory">State folder for <c>enrollment-complete.marker</c> on the intercepted-registration give-up.</param>
|
||||
/// <param name="cleanupServiceFactory">Self-destruct for the give-up (run when <c>SelfDestructOnComplete</c>).</param>
|
||||
/// <param name="stateDirectory">State folder for <c>enrollment-complete.marker</c> on the intercepted-registration give-up and the session-gone end.</param>
|
||||
/// <param name="cleanupServiceFactory">Self-destruct for the give-up (run when <c>SelfDestructOnComplete</c>) and the session-gone end (always run).</param>
|
||||
/// <param name="isWhiteGloveResume">This start is a WhiteGlove Part 2: a 410 for the persisted session becomes a new session.</param>
|
||||
public static SessionRegistrationOutcomeResult Register(
|
||||
AgentConfiguration agentConfig,
|
||||
BackendAuthBundle auth,
|
||||
@@ -50,7 +54,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
SessionIdPersistence sessionPersistence = null,
|
||||
string configFetchOutcome = null,
|
||||
string stateDirectory = null,
|
||||
Func<CleanupService> cleanupServiceFactory = null)
|
||||
Func<CleanupService> cleanupServiceFactory = null,
|
||||
bool isWhiteGloveResume = false)
|
||||
{
|
||||
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
|
||||
if (auth == null) throw new ArgumentNullException(nameof(auth));
|
||||
@@ -86,7 +91,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// Reuse the single hardened hardware read from the auth bundle so the session row
|
||||
// matches the security headers the backend validated against (no second WMI query).
|
||||
deviceHardware: (auth.Manufacturer, auth.Model, auth.SerialNumber),
|
||||
rotateSession: rotateSession)
|
||||
rotateSession: rotateSession,
|
||||
rotateOnSessionGone: isWhiteGloveResume)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
if (registrationResult.Outcome == SessionRegistrationOutcome.Succeeded)
|
||||
@@ -97,6 +103,17 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
startContext.DeviceBootUtc, auth.ClientCertificateThumbprint));
|
||||
}
|
||||
|
||||
if (registrationResult.Outcome == SessionRegistrationOutcome.SessionGone)
|
||||
{
|
||||
// The report goes out through the auth bundle's client: dispose only afterwards.
|
||||
var exitCode = ConcludeSessionGone(
|
||||
agentConfig, auth.EmergencyReporter, sessionPersistence, stateDirectory,
|
||||
cleanupServiceFactory, consoleMode, logger);
|
||||
try { mtlsHttpClient?.Dispose(); } catch { }
|
||||
try { auth.BackendApiClient?.Dispose(); } catch { }
|
||||
return SessionRegistrationOutcomeResult.Exit(exitCode);
|
||||
}
|
||||
|
||||
var record = RecordFailedRun(
|
||||
sessionPersistence, registrationResult, startedUtc, DateTime.UtcNow,
|
||||
linkUpAtStart, TryIsNetworkLinkUp(), configFetchOutcome, NetworkFingerprint.TryCapture(), logger,
|
||||
@@ -264,6 +281,41 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
return registrationResult.Outcome == SessionRegistrationOutcome.AuthFailed ? 6 : 7;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The end of a start whose registration the backend answered with 410: the session was
|
||||
/// deleted. One report over the emergency channel (a deleted session has no timeline to
|
||||
/// carry an event), then monitoring ends like on the telemetry-channel 410 — marker first,
|
||||
/// self-destruct regardless of <c>SelfDestructOnComplete</c>, session files deleted
|
||||
/// (<see cref="Program.EndMonitoringAtStart"/>). Returns exit code 0: an intended end.
|
||||
/// </summary>
|
||||
internal static int ConcludeSessionGone(
|
||||
AgentConfiguration agentConfig,
|
||||
EmergencyReporter emergencyReporter,
|
||||
SessionIdPersistence sessionPersistence,
|
||||
string stateDirectory,
|
||||
Func<CleanupService> cleanupServiceFactory,
|
||||
bool consoleMode,
|
||||
AgentLogger logger)
|
||||
{
|
||||
logger.Error(
|
||||
$"=== SESSION GONE (HTTP 410): the backend deleted session {agentConfig.SessionId} — " +
|
||||
"reporting once over the emergency channel, then cleaning up with self-destruct. ===");
|
||||
if (consoleMode)
|
||||
Console.Error.WriteLine("Session was deleted in the backend. Cleaning up and exiting.");
|
||||
|
||||
SessionGoneReport.SendBlocking(
|
||||
emergencyReporter,
|
||||
AgentErrorType.RegistrationRejectedSessionGone,
|
||||
"Session registration rejected with HTTP 410 — the backend deleted this session. Agent cleaning up with self-destruct.",
|
||||
logger);
|
||||
|
||||
Program.EndMonitoringAtStart(
|
||||
stateDirectory, sessionPersistence, runSelfDestruct: true, cleanupServiceFactory, logger,
|
||||
markerText: $"Session gone at {DateTime.UtcNow:O} (registration refused with HTTP 410)",
|
||||
cleanupReason: "session_gone");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Boot times derived from the uptime counter jitter by milliseconds to seconds between two
|
||||
/// reads of the same boot (NTP corrections shift them further), so only a gap above this
|
||||
|
||||
@@ -47,7 +47,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
RemoteConfigService remoteConfigService,
|
||||
AgentLogger logger,
|
||||
string rotatedFromSessionId = null,
|
||||
PriorRegistrationFailures priorRegistrationFailures = null)
|
||||
PriorRegistrationFailures priorRegistrationFailures = null,
|
||||
string sessionRotationReason = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -72,9 +73,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
{ "diagnosticsUploadMode", agentConfig.DiagnosticsUploadMode ?? "Off" },
|
||||
{ "previousExitType", previousExit?.ExitType ?? "unknown" },
|
||||
{ "unrestrictedMode", agentConfig.UnrestrictedMode },
|
||||
// SESSION-OWNER-BINDING: true when the backend refused the persisted session
|
||||
// id (session_owner_mismatch) and this run rotated to a new session before
|
||||
// registering — the re-enroll-without-wipe shape, made visible on the timeline.
|
||||
// True when the backend refused the persisted session id and this run rotated
|
||||
// to a new session before registering: session_owner_mismatch (re-enroll
|
||||
// without wipe) or 410 at a WhiteGlove Part 2 start (session deleted).
|
||||
// sessionRotationReason below names which.
|
||||
{ "sessionRotated", rotatedFromSessionId != null },
|
||||
{ "previousSessionId", rotatedFromSessionId ?? string.Empty },
|
||||
// remoteConfigFetched answers "is the agent running on a live-fetched
|
||||
@@ -96,6 +98,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
if (recovered)
|
||||
data["remoteConfigRecovered"] = true;
|
||||
|
||||
if (rotatedFromSessionId != null && !string.IsNullOrEmpty(sessionRotationReason))
|
||||
data["sessionRotationReason"] = sessionRotationReason;
|
||||
|
||||
// Which bootstrap installed this agent (installer and loader versions, MSI or
|
||||
// Intune loader, OOBE go-script), read from the scripts' own logs: makes the
|
||||
// fleet's share of old or directly assigned installers countable.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
@@ -193,14 +192,6 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
};
|
||||
}
|
||||
|
||||
// Bounded blocking send for the one session-gone report: the terminate that follows ends
|
||||
// the process and the cleanup script deletes the agent, so a fire-and-forget send would
|
||||
// be abandoned mid-flight. Retries stay inside one anti-flood reservation (ONE report).
|
||||
private const int SessionGoneReportAttempts = 2;
|
||||
private static readonly TimeSpan SessionGoneReportTimeout = TimeSpan.FromSeconds(15);
|
||||
private static readonly TimeSpan SessionGoneReportRetryDelay = TimeSpan.FromSeconds(3);
|
||||
private static readonly TimeSpan SessionGoneReportBudget = TimeSpan.FromSeconds(40);
|
||||
|
||||
/// <summary>
|
||||
/// The backend answered HTTP 410 for the current session's telemetry: the session is over
|
||||
/// server-side. (a) Send ONE <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/>
|
||||
@@ -227,25 +218,11 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
|
||||
logger.Error($"Backend no longer knows this session (HTTP 410: {reason}) — reporting once over the emergency channel, then terminating with self-destruct.");
|
||||
|
||||
if (emergencyReporter != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
emergencyReporter
|
||||
.TrySendAsync(
|
||||
AgentErrorType.TelemetryRejectedSessionUnknown,
|
||||
$"Telemetry upload rejected with HTTP 410 — backend no longer knows this session ({reason}). Agent terminating with self-destruct.",
|
||||
httpStatusCode: (int)HttpStatusCode.Gone,
|
||||
attempts: SessionGoneReportAttempts,
|
||||
perAttemptTimeout: SessionGoneReportTimeout,
|
||||
retryDelay: SessionGoneReportRetryDelay)
|
||||
.Wait(SessionGoneReportBudget);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Debug($"Session-gone report send failed (best-effort): {ex.Message}");
|
||||
}
|
||||
}
|
||||
SessionGoneReport.SendBlocking(
|
||||
emergencyReporter,
|
||||
AgentErrorType.TelemetryRejectedSessionUnknown,
|
||||
$"Telemetry upload rejected with HTTP 410 — backend no longer knows this session ({reason}). Agent terminating with self-destruct.",
|
||||
logger);
|
||||
|
||||
var terminateAction = new ServerAction
|
||||
{
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// The one emergency report an agent sends when the backend answered HTTP 410 for its own
|
||||
/// session — on a telemetry upload (<see cref="ServerControlPlane.HandleSessionGone"/>) or at
|
||||
/// registration (<see cref="BackendSessionRegistration"/>). The send blocks within a bound:
|
||||
/// the caller ends the process right after and the cleanup deletes the agent, so a
|
||||
/// fire-and-forget send would be abandoned mid-flight. Retries stay inside one anti-flood
|
||||
/// reservation (ONE report). Never throws.
|
||||
/// </summary>
|
||||
internal static class SessionGoneReport
|
||||
{
|
||||
private const int Attempts = 2;
|
||||
private static readonly TimeSpan PerAttemptTimeout = TimeSpan.FromSeconds(15);
|
||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(3);
|
||||
private static readonly TimeSpan Budget = TimeSpan.FromSeconds(40);
|
||||
|
||||
public static void SendBlocking(
|
||||
EmergencyReporter emergencyReporter,
|
||||
AgentErrorType errorType,
|
||||
string message,
|
||||
AgentLogger logger)
|
||||
{
|
||||
if (emergencyReporter == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
emergencyReporter
|
||||
.TrySendAsync(
|
||||
errorType,
|
||||
message,
|
||||
httpStatusCode: (int)HttpStatusCode.Gone,
|
||||
attempts: Attempts,
|
||||
perAttemptTimeout: PerAttemptTimeout,
|
||||
retryDelay: RetryDelay)
|
||||
.Wait(Budget);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Debug($"Session-gone report send failed (best-effort): {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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]
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The agent gate never refuses on a cached configuration another instance's write has outdated:
|
||||
/// before a refusal the configuration decides, it reads the stored row and decides on that. An admin
|
||||
/// allows a manufacturer or switches a validation on, and the next device is admitted at once, on
|
||||
/// every instance. A request the cached copy admits must not cost a read — that is the hot path.
|
||||
/// The request comes from <see cref="CertTenantBindingTests"/>: a valid certificate of the sample
|
||||
/// tenant, manufacturer "Contoso", model "TestBook".
|
||||
/// </summary>
|
||||
public sealed class ConfigConfirmBeforeRefusalTests
|
||||
{
|
||||
private static readonly string TenantId = CertTenantBindingTests.SampleCertTenantId.ToString();
|
||||
|
||||
private sealed record Gate(SecurityValidator Validator, Mock<TenantConfigurationService> Config, Dictionary<object, object> Items);
|
||||
|
||||
/// <summary>A permissive tenant: no device validator needed, every hardware allowed.</summary>
|
||||
private static TenantConfiguration Admitting(Action<TenantConfiguration>? change = null)
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
AllowInsecureAgentRequests = true,
|
||||
ManufacturerWhitelist = "*",
|
||||
ModelWhitelist = "*",
|
||||
};
|
||||
change?.Invoke(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="cached"/> is what this instance holds; <paramref name="stored"/> is what a read
|
||||
/// before a refusal returns (null: nothing to read, as inside the confirm interval). Like the real
|
||||
/// service, a confirming read makes the stored row the cached one.
|
||||
/// </summary>
|
||||
private static Gate Build(TenantConfiguration cached, TenantConfiguration? stored)
|
||||
{
|
||||
var configRepo = Mock.Of<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var current = cached;
|
||||
|
||||
var config = new Mock<TenantConfigurationService>(configRepo, Mock.Of<ILogger<TenantConfigurationService>>(), cache)
|
||||
{ CallBase = false };
|
||||
config.Setup(x => x.TryGetConfigurationAsync(It.IsAny<string>())).ReturnsAsync(() => (current, true));
|
||||
config.Setup(x => x.ConfirmBeforeRefusalAsync(It.IsAny<string>())).ReturnsAsync(() =>
|
||||
{
|
||||
if (stored != null) current = stored;
|
||||
return stored;
|
||||
});
|
||||
|
||||
var adminConfig = new Mock<AdminConfigurationService>(configRepo, Mock.Of<ILogger<AdminConfigurationService>>(), cache)
|
||||
{ CallBase = false };
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration());
|
||||
|
||||
var validator = new SecurityValidator(
|
||||
configService: config.Object,
|
||||
adminConfigService: adminConfig.Object,
|
||||
rateLimitService: new RateLimitService(cache, Mock.Of<ILogger<RateLimitService>>()),
|
||||
autopilotDeviceValidator: null!,
|
||||
corporateIdentifierValidator: null!,
|
||||
logger: Mock.Of<ILogger>(),
|
||||
bootstrapSessionService: null,
|
||||
deviceAssociationValidator: null);
|
||||
return new Gate(validator, config, new Dictionary<object, object>());
|
||||
}
|
||||
|
||||
private static Task<SecurityValidationResult> Validate(Gate gate)
|
||||
=> gate.Validator.ValidateRequestAsync(
|
||||
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), gate.Items), TenantId);
|
||||
|
||||
private static string? Stamp(Gate gate)
|
||||
=> gate.Items.TryGetValue(RequestRowMarkers.ConfigConfirmKey, out var value) ? value as string : null;
|
||||
|
||||
// ---------------------------------------------------------------- a refusal is confirmed first
|
||||
|
||||
public static TheoryData<string, TenantConfiguration, string> OutdatedCopies() => new()
|
||||
{
|
||||
{ "manufacturer just allowed", Admitting(c => c.ManufacturerWhitelist = "Dell*"), "Hardware not authorized" },
|
||||
{ "model just allowed", Admitting(c => c.ModelWhitelist = "Latitude*"), "Hardware not authorized" },
|
||||
{ "validation requirement just met", Admitting(c => c.AllowInsecureAgentRequests = false), "Device validation is required" },
|
||||
{ "tenant just re-enabled", Admitting(c => { c.Disabled = true; c.DisabledReason = "paused"; }), "Tenant is suspended" },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(OutdatedCopies))]
|
||||
public async Task The_stored_row_admits_what_the_outdated_copy_would_refuse(string _, TenantConfiguration outdated, string refusal)
|
||||
{
|
||||
var gate = Build(cached: outdated, stored: Admitting());
|
||||
|
||||
var result = await Validate(gate);
|
||||
|
||||
Assert.True(result.IsValid, $"refused with '{result.ErrorMessage}' although the stored row admits (outdated copy would say '{refusal}')");
|
||||
Assert.Equal(RequestRowMarkers.ConfigConfirm.Averted, Stamp(gate));
|
||||
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(OutdatedCopies))]
|
||||
public async Task The_refusal_stands_when_the_stored_row_says_the_same(string _, TenantConfiguration refusing, string refusal)
|
||||
{
|
||||
var gate = Build(cached: refusing, stored: ConfigRepoMockExtensions.Clone(refusing));
|
||||
|
||||
var result = await Validate(gate);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
|
||||
Assert.Equal(refusal, result.ErrorMessage);
|
||||
Assert.Equal(RequestRowMarkers.ConfigConfirm.Confirmed, Stamp(gate));
|
||||
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(OutdatedCopies))]
|
||||
public async Task Without_a_read_the_cached_copy_decides(string _, TenantConfiguration refusing, string refusal)
|
||||
{
|
||||
// Inside the confirm interval, or storage failed: nothing newer to decide on.
|
||||
var gate = Build(cached: refusing, stored: null);
|
||||
|
||||
var result = await Validate(gate);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(refusal, result.ErrorMessage);
|
||||
Assert.Null(Stamp(gate));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the hot path stays untouched
|
||||
|
||||
[Fact]
|
||||
public async Task A_request_the_cached_copy_admits_reads_nothing()
|
||||
{
|
||||
var gate = Build(cached: Admitting(), stored: Admitting());
|
||||
|
||||
var result = await Validate(gate);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Null(Stamp(gate));
|
||||
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(It.IsAny<string>()), Times.Never);
|
||||
gate.Config.Verify(x => x.TryGetConfigurationAsync(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refusal_the_configuration_does_not_decide_reads_nothing()
|
||||
{
|
||||
// A certificate of another tenant: the configuration admits, the certificate does not.
|
||||
var gate = Build(cached: Admitting(), stored: Admitting());
|
||||
|
||||
var result = await gate.Validator.ValidateRequestAsync(
|
||||
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), gate.Items),
|
||||
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Null(Stamp(gate));
|
||||
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the device validation chain
|
||||
|
||||
[Fact]
|
||||
public async Task A_validation_switched_on_since_runs_the_chain_once_more_and_no_further()
|
||||
{
|
||||
// The Cloud PC validation is on in both rows, but this gate has no Cloud PC validator wired:
|
||||
// nothing admits the device, on either pass. What counts here is the control flow.
|
||||
var cached = Admitting(c => { c.AllowInsecureAgentRequests = false; c.ValidateCloudPcDevice = true; });
|
||||
var stored = Admitting(c => { c.AllowInsecureAgentRequests = false; c.ValidateCloudPcDevice = true; c.ValidateIntuneDeviceBinding = true; });
|
||||
var gate = Build(cached, stored);
|
||||
|
||||
var result = await Validate(gate);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("Device not registered", result.ErrorMessage);
|
||||
Assert.Equal(RequestRowMarkers.ConfigConfirm.Rerun, Stamp(gate));
|
||||
gate.Config.Verify(x => x.TryGetConfigurationAsync(TenantId), Times.Exactly(2));
|
||||
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_chain_does_not_run_again_when_the_stored_row_asks_nothing_new()
|
||||
{
|
||||
var cached = Admitting(c => { c.AllowInsecureAgentRequests = false; c.ValidateCloudPcDevice = true; });
|
||||
var gate = Build(cached, stored: ConfigRepoMockExtensions.Clone(cached));
|
||||
|
||||
var result = await Validate(gate);
|
||||
|
||||
Assert.Equal("Device not registered", result.ErrorMessage);
|
||||
Assert.Equal(RequestRowMarkers.ConfigConfirm.Confirmed, Stamp(gate));
|
||||
gate.Config.Verify(x => x.TryGetConfigurationAsync(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_a_validation_that_was_off_and_is_now_on_asks_for_another_pass()
|
||||
{
|
||||
var used = new TenantConfiguration { ValidateAutopilotDevice = true };
|
||||
|
||||
Assert.False(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateAutopilotDevice = true }));
|
||||
Assert.False(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration()));
|
||||
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateAutopilotDevice = true, ValidateCorporateIdentifier = true }));
|
||||
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateDeviceAssociation = true }));
|
||||
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateCloudPcDevice = true }));
|
||||
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateIntuneDeviceBinding = true }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the patch marker
|
||||
|
||||
[Fact]
|
||||
public void A_field_patch_counts_the_sent_fields_the_stored_row_already_held()
|
||||
{
|
||||
static PatchOutcome Applied(params string[] changed)
|
||||
=> new(true, PatchFailure.None, null, null, changed, new Dictionary<string, string>(), null);
|
||||
|
||||
// Wire names are camelCase, applied names are the model's property names.
|
||||
Assert.Equal(0, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(new[] { "logLevel" }, Applied("LogLevel")));
|
||||
Assert.Equal(1, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(
|
||||
new[] { "enableTimezoneAutoSet", "rebootOnComplete" }, Applied("RebootOnComplete")));
|
||||
Assert.Equal(1, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(new[] { "diagnosticsUploadMode" }, Applied()));
|
||||
Assert.Equal(0, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(
|
||||
new[] { "logLevel" }, PatchOutcome.Fail(PatchFailure.ValidationFailed, "invalid")));
|
||||
}
|
||||
}
|
||||
+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 },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Graph;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// POST tenants/{tenantId}/scripts/display-names: a lookup that ran into a time limit says so in
|
||||
/// its answer, so the portal asks once more, and the request row tells a cut first lookup, a
|
||||
/// follow-up and a follow-up that was cut again apart. A first lookup within its limits is the
|
||||
/// normal case and carries neither the flag nor a marker.
|
||||
/// </summary>
|
||||
public class GetScriptDisplayNamesFunctionTests
|
||||
{
|
||||
private const string TenantId = "00000000-0000-4000-8000-000000000002";
|
||||
private const string Ref = "Platform:1f9d3a52-4c1e-4b7a-9a01-52f3aaaa1001";
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false, null)]
|
||||
[InlineData(true, false, "timedOut")]
|
||||
[InlineData(false, true, "retry")]
|
||||
[InlineData(true, true, "retryTimedOut")]
|
||||
public async Task A_cut_lookup_is_flagged_in_the_answer_and_marked_on_the_request_row(
|
||||
bool timedOut, bool isFollowUp, string? expectedMarker)
|
||||
{
|
||||
var resolver = new Mock<IScriptDisplayNameResolver>();
|
||||
resolver
|
||||
.Setup(r => r.ResolveAsync(TenantId, It.IsAny<IReadOnlyCollection<ScriptRef>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((string _, IReadOnlyCollection<ScriptRef> refs, CancellationToken _) =>
|
||||
new ScriptDisplayNameLookup(refs.ToDictionary(r => r, _ => (string?)null), timedOut));
|
||||
var function = new GetScriptDisplayNamesFunction(NullLogger<GetScriptDisplayNamesFunction>.Instance, resolver.Object);
|
||||
var body = "{\"refs\":[\"" + Ref + "\"]" + (isFollowUp ? ",\"retry\":true" : "") + "}";
|
||||
var (req, context) = EndpointHarness.Request(TenantId, jsonBody: body);
|
||||
|
||||
var response = await function.Run(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
response.Body.Position = 0;
|
||||
using var doc = JsonDocument.Parse(response.Body);
|
||||
Assert.True(doc.RootElement.GetProperty("refs").TryGetProperty(Ref, out _));
|
||||
// The key is left out, not false, when no time limit fired.
|
||||
Assert.Equal(timedOut, doc.RootElement.TryGetProperty("timedOut", out var flag));
|
||||
if (timedOut) Assert.True(flag.GetBoolean());
|
||||
|
||||
if (expectedMarker == null)
|
||||
Assert.False(context.Items.ContainsKey(RequestRowMarkers.ScriptNameLookupKey));
|
||||
else
|
||||
Assert.Equal(expectedMarker, context.Items[RequestRowMarkers.ScriptNameLookupKey]);
|
||||
}
|
||||
}
|
||||
+13
-2
@@ -25,10 +25,21 @@ internal sealed class FakeGraphFeatureDetector : IGraphFeatureDetector
|
||||
ctx?.GrantedRoles ?? (IReadOnlySet<string>)new HashSet<string>());
|
||||
}
|
||||
|
||||
public Task<GraphTenantTokenContext?> TryGetTokenContextAsync(string tenantId, CancellationToken ct = default)
|
||||
/// <summary>Tenants whose token never arrives: the call waits until the caller's budget cancels it.</summary>
|
||||
public HashSet<string> HangingTenants { get; } = new();
|
||||
|
||||
/// <summary>When set, <see cref="TryGetTokenContextAsync"/> throws it.</summary>
|
||||
public Exception? TokenContextThrows { get; set; }
|
||||
|
||||
public async Task<GraphTenantTokenContext?> TryGetTokenContextAsync(string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
if (TokenContextThrows != null) throw TokenContextThrows;
|
||||
if (HangingTenants.Contains(tenantId))
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false);
|
||||
}
|
||||
ContextByTenant.TryGetValue(tenantId, out var ctx);
|
||||
return Task.FromResult(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/// <summary>When set for a tenant, <see cref="GetSnapshotAsync"/> reports it as transient.</summary>
|
||||
|
||||
+165
-17
@@ -1,12 +1,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.GraphResolution;
|
||||
@@ -28,9 +31,43 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, refs);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Null(result[refs[0]]);
|
||||
Assert.Single(result.Names);
|
||||
Assert.Null(result.Names[refs[0]]);
|
||||
Assert.Empty(fix.Http.Requests); // Graph never called when permission gate fails
|
||||
Assert.Empty(fix.IncompleteLines); // "no token or no role" is an answer, not a cut
|
||||
Assert.False(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Token_that_outlasts_its_budget_is_reported_as_an_incomplete_lookup()
|
||||
{
|
||||
var fix = new Fixture(tokenAcquireBudget: TimeSpan.FromMilliseconds(50));
|
||||
fix.Detector.HangingTenants.Add(TenantId);
|
||||
var r = new ScriptRef(ScriptKind.Platform, "abc");
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result.Names[r]);
|
||||
Assert.Empty(fix.Http.Requests);
|
||||
var line = Assert.Single(fix.IncompleteLines);
|
||||
Assert.Contains($"tenant={TenantId} ", line);
|
||||
Assert.Contains("reasons=TokenTimeout ", line);
|
||||
Assert.Contains("requested=1 ", line);
|
||||
Assert.True(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failing_token_context_is_reported_as_an_incomplete_lookup()
|
||||
{
|
||||
var fix = new Fixture();
|
||||
fix.Detector.TokenContextThrows = new InvalidOperationException("simulated");
|
||||
var r = new ScriptRef(ScriptKind.Platform, "abc");
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result.Names[r]);
|
||||
Assert.Contains("reasons=TokenError ", Assert.Single(fix.IncompleteLines));
|
||||
Assert.False(result.TimedOut); // a failure is not a time limit: asking again is not suggested
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -58,7 +95,7 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Equal("MyScript.ps1", result[r]);
|
||||
Assert.Equal("MyScript.ps1", result.Names[r]);
|
||||
Assert.Empty(fix.Http.Requests);
|
||||
}
|
||||
|
||||
@@ -77,11 +114,78 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Equal("Bootstrap", result[r]);
|
||||
Assert.Equal("Bootstrap", result.Names[r]);
|
||||
Assert.Single(fix.Http.Requests);
|
||||
// Both list entries cached for future calls.
|
||||
Assert.Equal(2, fix.Repo.Entries.Count);
|
||||
Assert.True(fix.Repo.Meta.ContainsKey((TenantId, ScriptKind.Platform)));
|
||||
Assert.Empty(fix.IncompleteLines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_missing_from_the_list_is_still_resolved_by_id()
|
||||
{
|
||||
// The list leaves out some scripts that a single GET returns. Treating "not in the list"
|
||||
// as "not found" would drop their names.
|
||||
var fix = new Fixture();
|
||||
fix.GivenPermission();
|
||||
fix.Http.When("deviceHealthScripts?", HttpStatusCode.OK,
|
||||
"{\"value\":[{\"id\":\"listed\",\"displayName\":\"Listed\"}]}");
|
||||
fix.Http.When("deviceHealthScripts/unlisted", HttpStatusCode.OK,
|
||||
"{\"id\":\"unlisted\",\"displayName\":\"Unlisted\"}");
|
||||
var listed = new ScriptRef(ScriptKind.Remediation, "listed");
|
||||
var unlisted = new ScriptRef(ScriptKind.Remediation, "unlisted");
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { listed, unlisted });
|
||||
|
||||
Assert.Equal("Listed", result.Names[listed]);
|
||||
Assert.Equal("Unlisted", result.Names[unlisted]);
|
||||
Assert.Equal(2, fix.Http.Requests.Count);
|
||||
Assert.False(fix.Repo.Entries[(TenantId, ScriptKind.Remediation, "unlisted")].IsNotFound);
|
||||
Assert.Empty(fix.IncompleteLines);
|
||||
Assert.False(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timed_out_list_pull_is_reported_and_skips_the_single_lookups()
|
||||
{
|
||||
var fix = new Fixture();
|
||||
fix.GivenPermission();
|
||||
fix.Http.WhenTimedOut("deviceManagementScripts?");
|
||||
fix.Http.When("deviceManagementScripts/abc", HttpStatusCode.OK,
|
||||
"{\"id\":\"abc\",\"displayName\":\"SHOULD-NOT-BE-RETURNED\"}");
|
||||
var r = new ScriptRef(ScriptKind.Platform, "abc");
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result.Names[r]);
|
||||
Assert.Single(fix.Http.Requests);
|
||||
Assert.False(fix.Repo.Meta.ContainsKey((TenantId, ScriptKind.Platform)));
|
||||
Assert.Contains("reasons=ListTimeout:Platform ", Assert.Single(fix.IncompleteLines));
|
||||
Assert.True(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Budget_ending_between_list_pages_stores_nothing_and_leaves_the_kind_unfetched()
|
||||
{
|
||||
// A list cut after its first page is incomplete. Stored together with the meta row it
|
||||
// would count as the tenant's whole list for a week.
|
||||
var fix = new Fixture();
|
||||
fix.GivenPermission();
|
||||
using var caller = new CancellationTokenSource();
|
||||
fix.Http.OnRequest = _ => caller.Cancel();
|
||||
fix.Http.When("deviceManagementScripts?", HttpStatusCode.OK,
|
||||
"{\"value\":[{\"id\":\"id-1\",\"displayName\":\"Bootstrap\"}]," +
|
||||
"\"@odata.nextLink\":\"https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts?$skiptoken=2\"}");
|
||||
var r = new ScriptRef(ScriptKind.Platform, "id-1");
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r }, caller.Token);
|
||||
|
||||
Assert.Equal("Bootstrap", result.Names[r]); // page one was read before the cut
|
||||
Assert.Single(fix.Http.Requests);
|
||||
Assert.Empty(fix.Repo.Entries);
|
||||
Assert.False(fix.Repo.Meta.ContainsKey((TenantId, ScriptKind.Platform)));
|
||||
Assert.True(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -102,7 +206,7 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Equal("Net-New", result[r]);
|
||||
Assert.Equal("Net-New", result.Names[r]);
|
||||
Assert.Single(fix.Http.Requests);
|
||||
Assert.Contains("deviceHealthScripts/net-new-id", fix.Http.Requests[0]);
|
||||
// Per-ID fetch also writes to cache.
|
||||
@@ -125,9 +229,40 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result[r]);
|
||||
Assert.Null(result.Names[r]);
|
||||
Assert.True(fix.Repo.Entries.ContainsKey((TenantId, ScriptKind.Platform, "deleted-id")));
|
||||
Assert.True(fix.Repo.Entries[(TenantId, ScriptKind.Platform, "deleted-id")].IsNotFound);
|
||||
Assert.Empty(fix.IncompleteLines); // a 404 is an answer
|
||||
Assert.False(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timed_out_single_lookup_is_reported_and_ends_the_loop()
|
||||
{
|
||||
var fix = new Fixture();
|
||||
fix.GivenPermission();
|
||||
fix.Repo.Meta[(TenantId, ScriptKind.Remediation)] = new ScriptNameCacheMeta
|
||||
{
|
||||
TenantId = TenantId,
|
||||
Kind = ScriptKind.Remediation,
|
||||
LastFullRefreshAt = fix.FixedNow - TimeSpan.FromDays(1),
|
||||
};
|
||||
fix.Http.WhenTimedOut("deviceHealthScripts/first");
|
||||
fix.Http.When("deviceHealthScripts/second", HttpStatusCode.OK,
|
||||
"{\"id\":\"second\",\"displayName\":\"SHOULD-NOT-BE-RETURNED\"}");
|
||||
var refs = new[]
|
||||
{
|
||||
new ScriptRef(ScriptKind.Remediation, "first"),
|
||||
new ScriptRef(ScriptKind.Remediation, "second"),
|
||||
};
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, refs);
|
||||
|
||||
Assert.All(refs, r => Assert.Null(result.Names[r]));
|
||||
Assert.Single(fix.Http.Requests);
|
||||
Assert.Empty(fix.Repo.Entries); // a cut is not an answer: nothing is cached as not found
|
||||
Assert.Contains("reasons=LookupTimeout ", Assert.Single(fix.IncompleteLines));
|
||||
Assert.True(result.TimedOut);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -152,8 +287,8 @@ public class ScriptDisplayNameResolverTests
|
||||
var first = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
var second = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(first[r]);
|
||||
Assert.Null(second[r]);
|
||||
Assert.Null(first.Names[r]);
|
||||
Assert.Null(second.Names[r]);
|
||||
Assert.Single(fix.Http.Requests);
|
||||
var entry = fix.Repo.Entries[(TenantId, ScriptKind.Platform, "nameless")];
|
||||
Assert.False(entry.IsNotFound);
|
||||
@@ -168,7 +303,7 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, Array.Empty<ScriptRef>());
|
||||
|
||||
Assert.Empty(result);
|
||||
Assert.Empty(result.Names);
|
||||
Assert.Empty(fix.Http.Requests);
|
||||
}
|
||||
|
||||
@@ -182,7 +317,7 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result[r]);
|
||||
Assert.Null(result.Names[r]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -200,9 +335,11 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result[r]); // per-ID never ran
|
||||
Assert.Null(result.Names[r]); // per-ID never ran
|
||||
Assert.Single(fix.Http.Requests); // only the failed full-pull
|
||||
Assert.Contains("deviceManagementScripts?", fix.Http.Requests[0]);
|
||||
Assert.Contains("reasons=ListError:Platform ", Assert.Single(fix.IncompleteLines));
|
||||
Assert.False(result.TimedOut); // Graph refused: an immediate second lookup would add to the throttle
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -234,9 +371,11 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, refs);
|
||||
|
||||
Assert.All(refs, r => Assert.Null(result[r]));
|
||||
Assert.All(refs, r => Assert.Null(result.Names[r]));
|
||||
Assert.Single(fix.Http.Requests); // second call never issued
|
||||
Assert.Contains("deviceHealthScripts/first", fix.Http.Requests[0]);
|
||||
Assert.Contains("reasons=LookupError ", Assert.Single(fix.IncompleteLines));
|
||||
Assert.False(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -257,7 +396,7 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result[r]);
|
||||
Assert.Null(result.Names[r]);
|
||||
Assert.Empty(fix.Http.Requests);
|
||||
}
|
||||
|
||||
@@ -269,19 +408,28 @@ public class ScriptDisplayNameResolverTests
|
||||
public FakeScriptNameCacheRepository Repo { get; } = new();
|
||||
public StubHttpMessageHandler Http { get; } = new();
|
||||
public StubHttpClientFactory HttpFactory { get; }
|
||||
public Mock<ILogger<ScriptDisplayNameResolver>> Logger { get; } = new();
|
||||
public ScriptDisplayNameResolver Sut { get; }
|
||||
public DateTimeOffset FixedNow { get; } = new(2026, 5, 18, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public Fixture()
|
||||
public Fixture(TimeSpan? tokenAcquireBudget = null)
|
||||
{
|
||||
HttpFactory = new StubHttpClientFactory(Http);
|
||||
Sut = new ScriptDisplayNameResolver(
|
||||
Detector, Repo, HttpFactory,
|
||||
NullLogger<ScriptDisplayNameResolver>.Instance,
|
||||
Logger.Object,
|
||||
new TelemetryClient(new TelemetryConfiguration()),
|
||||
new FakeTimeProvider(FixedNow));
|
||||
new FakeTimeProvider(FixedNow),
|
||||
tokenAcquireBudget);
|
||||
}
|
||||
|
||||
/// <summary>The summary lines written for lookups that ended without every answer.</summary>
|
||||
public List<string> IncompleteLines => Logger.Invocations
|
||||
.Where(i => i.Method.Name == nameof(ILogger.Log) && i.Arguments[0] is LogLevel.Warning)
|
||||
.Select(i => i.Arguments[2]?.ToString() ?? string.Empty)
|
||||
.Where(m => m.StartsWith("Script-name lookup incomplete:", StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
public void GivenPermission()
|
||||
{
|
||||
Detector.ContextByTenant[TenantId] = new GraphTenantTokenContext
|
||||
|
||||
+8
-7
@@ -12,8 +12,7 @@ namespace AutopilotMonitor.Functions.Tests.GraphResolution;
|
||||
/// <list type="bullet">
|
||||
/// <item>Positive rows older than 7 days are removed.</item>
|
||||
/// <item>Positive rows younger than 7 days are kept.</item>
|
||||
/// <item>NotFound rows older than 1 hour are removed.</item>
|
||||
/// <item>NotFound rows younger than 1 hour are kept.</item>
|
||||
/// <item>NotFound rows follow the same 7 days: older ones are removed, younger ones are kept.</item>
|
||||
/// <item>A failed delete on one row doesn't poison the run — others still get processed.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
@@ -55,18 +54,20 @@ public class ScriptNameCacheCleanupFunctionTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deletes_notfound_rows_older_than_1_hour()
|
||||
public async Task Deletes_notfound_rows_only_after_the_same_7_days()
|
||||
{
|
||||
// A not-found row that goes after hours instead of days sends one Graph lookup per
|
||||
// unknown ID on the first view after every cleanup run.
|
||||
var fix = new Fixture();
|
||||
var deletedId = "deleted-1h-ago";
|
||||
var recentId = "recent-30min";
|
||||
var deletedId = "not-found-8-days-ago";
|
||||
var recentId = "not-found-2-hours-ago";
|
||||
fix.Repo.Entries[(TenantId, ScriptKind.Remediation, deletedId)] = new ScriptDisplayNameEntry
|
||||
{
|
||||
TenantId = TenantId,
|
||||
Kind = ScriptKind.Remediation,
|
||||
ScriptId = deletedId,
|
||||
DisplayName = null,
|
||||
FetchedAt = fix.FixedNow - TimeSpan.FromHours(2),
|
||||
FetchedAt = fix.FixedNow - TimeSpan.FromDays(8),
|
||||
IsNotFound = true,
|
||||
};
|
||||
fix.Repo.Entries[(TenantId, ScriptKind.Remediation, recentId)] = new ScriptDisplayNameEntry
|
||||
@@ -75,7 +76,7 @@ public class ScriptNameCacheCleanupFunctionTests
|
||||
Kind = ScriptKind.Remediation,
|
||||
ScriptId = recentId,
|
||||
DisplayName = null,
|
||||
FetchedAt = fix.FixedNow - TimeSpan.FromMinutes(30),
|
||||
FetchedAt = fix.FixedNow - TimeSpan.FromHours(2),
|
||||
IsNotFound = true,
|
||||
};
|
||||
|
||||
|
||||
+16
@@ -16,18 +16,34 @@ namespace AutopilotMonitor.Functions.Tests.GraphResolution;
|
||||
internal sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly List<(string UrlSubstring, HttpStatusCode Status, string Body)> _scripted = new();
|
||||
private readonly List<string> _timedOut = new();
|
||||
public List<string> Requests { get; } = new();
|
||||
|
||||
/// <summary>Runs when a request arrives, before it is answered (e.g. to cancel the caller mid-flight).</summary>
|
||||
public Action<string>? OnRequest { get; set; }
|
||||
|
||||
public StubHttpMessageHandler When(string urlSubstring, HttpStatusCode status, string body)
|
||||
{
|
||||
_scripted.Add((urlSubstring, status, body));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Scripts a request that ends the way a per-request timeout does, without waiting.</summary>
|
||||
public StubHttpMessageHandler WhenTimedOut(string urlSubstring)
|
||||
{
|
||||
_timedOut.Add(urlSubstring);
|
||||
return this;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = request.RequestUri!.ToString();
|
||||
Requests.Add(url);
|
||||
OnRequest?.Invoke(url);
|
||||
if (_timedOut.Any(s => url.Contains(s, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new TaskCanceledException($"StubHttpMessageHandler: scripted timeout for {url}");
|
||||
}
|
||||
var match = _scripted.FirstOrDefault(s => url.Contains(s.UrlSubstring, StringComparison.OrdinalIgnoreCase));
|
||||
if (match.Equals(default((string, HttpStatusCode, string))))
|
||||
{
|
||||
|
||||
@@ -76,6 +76,34 @@ public class GraphWireParityTests
|
||||
new GetScriptDisplayNamesResponse { Refs = new Dictionary<string, string?>() });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetScriptDisplayNamesResponse_carries_timedOut_only_when_a_time_limit_fired()
|
||||
{
|
||||
// The portal asks once more when it sees timedOut. Null leaves the key out, so an
|
||||
// answer within the limits keeps the shapes pinned above.
|
||||
var payload = new Dictionary<string, string?>
|
||||
{
|
||||
["Platform:6cef8fa7-9163-40cf-8f56-52f3aaaa1006"] = null,
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { refs = payload, timedOut = true },
|
||||
new GetScriptDisplayNamesResponse { Refs = payload, TimedOut = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptDisplayNamesRequest_reads_the_retry_flag_and_tolerates_its_absence()
|
||||
{
|
||||
var options = ApiJsonOptions.Create();
|
||||
const string Ref = "\"Platform:7df090b8-a274-41d0-9067-52f3aaaa1007\"";
|
||||
|
||||
var followUp = JsonSerializer.Deserialize<ScriptDisplayNamesRequest>("{\"refs\":[" + Ref + "],\"retry\":true}", options);
|
||||
var first = JsonSerializer.Deserialize<ScriptDisplayNamesRequest>("{\"refs\":[" + Ref + "]}", options);
|
||||
|
||||
Assert.True(followUp!.Retry);
|
||||
Assert.Null(first!.Retry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetScriptDisplayNamesResponse_dictionary_keys_are_not_camel_cased()
|
||||
{
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>X-Tenant-Config: stored</c> (<c>readStoredConfig</c>): a signed-in caller that has just written a
|
||||
/// tenant configuration reads the stored row instead of this instance's cached copy — for its own tenant
|
||||
/// and for a target it may reach. Without the header nothing changes: no extra read on any request.
|
||||
/// </summary>
|
||||
public partial class PolicyEnforcementMiddlewareTests
|
||||
{
|
||||
private static string FlagsPath(string tenantId) => $"/api/config/{tenantId}/feature-flags";
|
||||
|
||||
private static void StoreRows(Harness h)
|
||||
=> h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((string tenantId) => TenantConfiguration.CreateDefault(tenantId));
|
||||
|
||||
private static int Reads(Harness h, string tenantId)
|
||||
=> h.ConfigRepo.Invocations.Count(i =>
|
||||
i.Method.Name == nameof(AutopilotMonitor.Shared.DataAccess.IConfigRepository.GetTenantConfigurationAsync)
|
||||
&& string.Equals(i.Arguments[0] as string, tenantId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
[Fact]
|
||||
public async Task Without_the_header_a_request_reads_the_cached_copy_only()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
StoreRows(h);
|
||||
h.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
var caller = AuthedPrincipal(TenantA, "admin@contoso.com");
|
||||
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller)).Allowed); // warms the cache
|
||||
h.ConfigRepo.Invocations.Clear();
|
||||
|
||||
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller)).Allowed);
|
||||
|
||||
Assert.Equal(0, Reads(h, TenantA));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task With_the_header_the_callers_tenant_is_read_from_storage_despite_a_cached_copy()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
StoreRows(h);
|
||||
h.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
var caller = AuthedPrincipal(TenantA, "admin@contoso.com");
|
||||
await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller);
|
||||
h.ConfigRepo.Invocations.Clear();
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller, readStoredConfig: true);
|
||||
|
||||
Assert.True(r.Allowed);
|
||||
Assert.Equal(1, Reads(h, TenantA));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task With_the_header_a_Global_Admin_reads_its_own_and_the_target_tenant()
|
||||
{
|
||||
// A Global Admin skips the suspension gate, so without the header nothing here reads the target.
|
||||
var h = BuildHarness();
|
||||
StoreRows(h);
|
||||
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
|
||||
var caller = AuthedPrincipal(TenantA, "ga@contoso.com");
|
||||
await h.Middleware.DecideAsync("GET", FlagsPath(TenantB), null, caller); // warms whatever the decision caches
|
||||
h.ConfigRepo.Invocations.Clear();
|
||||
|
||||
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantB), null, caller)).Allowed);
|
||||
Assert.Equal(0, Reads(h, TenantA) + Reads(h, TenantB));
|
||||
|
||||
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantB), null, caller, readStoredConfig: true)).Allowed);
|
||||
Assert.Equal(1, Reads(h, TenantA));
|
||||
Assert.Equal(1, Reads(h, TenantB));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_header_never_makes_an_instance_read_a_tenant_the_caller_may_not_reach()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
StoreRows(h);
|
||||
h.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
|
||||
var r = await h.Middleware.DecideAsync(
|
||||
"GET", FlagsPath(TenantB), null, AuthedPrincipal(TenantA, "admin@contoso.com"), readStoredConfig: true);
|
||||
|
||||
Assert.False(r.Allowed);
|
||||
Assert.Equal(0, Reads(h, TenantB));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_signed_in_caller_the_header_reads_nothing()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
StoreRows(h);
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, principal: null, readStoredConfig: true);
|
||||
|
||||
Assert.False(r.Allowed);
|
||||
Assert.Empty(h.ConfigRepo.Invocations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_storage_failure_on_that_read_does_not_fail_the_request()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
StoreRows(h);
|
||||
h.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
var caller = AuthedPrincipal(TenantA, "admin@contoso.com");
|
||||
await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller); // a cached copy exists
|
||||
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ThrowsAsync(new InvalidOperationException("storage unavailable"));
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller, readStoredConfig: true);
|
||||
|
||||
Assert.True(r.Allowed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>GET config/{tenantId}</c> is the baseline of the settings editor and of every read-modify-write
|
||||
/// in the portal, <c>GET config/{tenantId}/feature-flags</c> decides its banners and gates. Both
|
||||
/// answer with the tenant's row or with an error — never with the synthesized default the plain
|
||||
/// cached read hands out when storage fails.
|
||||
/// </summary>
|
||||
public sealed class PortalConfigReadTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
private static (TenantConfigurationService Service, StoredTenantConfig Store) Service(TenantConfiguration? row)
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
var store = repo.BackWithRow(row);
|
||||
var service = new TenantConfigurationService(
|
||||
repo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
return (service, store);
|
||||
}
|
||||
|
||||
private static TenantConfiguration Row(string manufacturers)
|
||||
{
|
||||
var row = TenantConfiguration.CreateDefault(TenantId);
|
||||
row.ManufacturerWhitelist = manufacturers;
|
||||
return row;
|
||||
}
|
||||
|
||||
private static string? Manufacturers(Microsoft.Azure.Functions.Worker.Http.HttpResponseData response)
|
||||
{
|
||||
response.Body.Position = 0;
|
||||
using var doc = JsonDocument.Parse(response.Body);
|
||||
return doc.RootElement.GetProperty("manufacturerWhitelist").GetString();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Config_answers_with_the_tenants_row()
|
||||
{
|
||||
var (service, _) = Service(Row("Dell*"));
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await new GetTenantConfigurationFunction(NullLogger<GetTenantConfigurationFunction>.Instance, service).Run(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("Dell*", Manufacturers(response));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Config_answers_a_storage_failure_with_an_error_not_with_defaults()
|
||||
{
|
||||
var (service, store) = Service(Row("Dell*"));
|
||||
store.ThrowOnRead = new InvalidOperationException("storage unavailable");
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await new GetTenantConfigurationFunction(NullLogger<GetTenantConfigurationFunction>.Instance, service).Run(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Config_still_creates_the_default_row_for_a_tenant_without_one()
|
||||
{
|
||||
var (service, store) = Service(null);
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await new GetTenantConfigurationFunction(NullLogger<GetTenantConfigurationFunction>.Instance, service).Run(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.NotNull(store.Row);
|
||||
Assert.Equal(TenantConfiguration.CreateDefault(TenantId).ManufacturerWhitelist, Manufacturers(response));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flags_answer_a_storage_failure_with_an_error_not_with_defaults()
|
||||
{
|
||||
var (service, store) = Service(Row("Dell*"));
|
||||
store.ThrowOnRead = new InvalidOperationException("storage unavailable");
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
// The read fails before the homing service or the enrollment counter is asked.
|
||||
var function = new GetTenantFeatureFlagsFunction(
|
||||
NullLogger<GetTenantFeatureFlagsFunction>.Instance, service, appHomingService: null!, Mock.Of<IMetricsRepository>());
|
||||
|
||||
var response = await function.Run(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A tenant configuration is cached per backend instance, and a write clears only the cache of the
|
||||
/// instance that served it. These tests model two instances (two services, each with its own cache)
|
||||
/// over one stored row and pin the two ways the stored row reaches the other instance without
|
||||
/// shortening the cache: <see cref="TenantConfigurationService.RefreshFromStoreAsync"/> for a request
|
||||
/// that follows its caller's own write, and <see cref="TenantConfigurationService.ConfirmBeforeRefusalAsync"/>
|
||||
/// before a refusal.
|
||||
/// </summary>
|
||||
public sealed class TenantConfigReadAfterWriteTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private static readonly DateTimeOffset Start = new(2026, 11, 11, 8, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private sealed record Instances(
|
||||
TenantConfigurationService A, TenantConfigurationService B, StoredTenantConfig Store, Mock<IConfigRepository> Repo, TestTimeProvider Clock);
|
||||
|
||||
private static Instances TwoInstances(Action<TenantConfiguration>? seed = null)
|
||||
{
|
||||
var row = TenantConfiguration.CreateDefault(TenantId);
|
||||
seed?.Invoke(row);
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
var store = repo.BackWithRow(row);
|
||||
var clock = new TestTimeProvider(Start);
|
||||
TenantConfigurationService Instance() => new(
|
||||
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()),
|
||||
ManagedTenantProIndex.None, clock);
|
||||
return new Instances(Instance(), Instance(), store, repo, clock);
|
||||
}
|
||||
|
||||
private static Task<TenantConfigUpdate> AllowOnly(TenantConfigurationService instance, string manufacturers)
|
||||
=> instance.UpdateAsync(TenantId, row =>
|
||||
{
|
||||
row.ManufacturerWhitelist = manufacturers;
|
||||
return true;
|
||||
}, "test", "hardware");
|
||||
|
||||
// ---------------------------------------------------------------- the hazard itself
|
||||
|
||||
[Fact]
|
||||
public async Task A_write_through_one_instance_leaves_the_other_instances_cached_copy_untouched()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
await s.B.GetConfigurationAsync(TenantId); // B holds a copy from before the write
|
||||
|
||||
await AllowOnly(s.A, "Dell*");
|
||||
|
||||
Assert.Equal("Dell*", (await s.A.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
|
||||
// This is the behaviour the two reads below exist for; it changes only with the cache itself.
|
||||
Assert.NotEqual("Dell*", (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- RefreshFromStoreAsync
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_puts_the_stored_row_into_the_instance_cache_for_every_later_read()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
await s.B.GetConfigurationAsync(TenantId);
|
||||
await AllowOnly(s.A, "Dell*");
|
||||
|
||||
await s.B.RefreshFromStoreAsync(TenantId);
|
||||
|
||||
Assert.Equal("Dell*", (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
|
||||
Assert.Equal("Dell*", (await s.B.GetConfigurationIfExistsAsync(TenantId))!.ManufacturerWhitelist);
|
||||
var (viaGate, exists) = await s.B.TryGetConfigurationAsync(TenantId);
|
||||
Assert.True(exists);
|
||||
Assert.Equal("Dell*", viaGate.ManufacturerWhitelist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_refresh_keeps_the_cached_copy_and_does_not_fail_the_request()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
var before = (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist;
|
||||
await AllowOnly(s.A, "Dell*");
|
||||
s.Store.ThrowOnRead = new InvalidOperationException("storage unavailable");
|
||||
|
||||
await s.B.RefreshFromStoreAsync(TenantId);
|
||||
|
||||
Assert.Equal(before, (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_of_a_tenant_without_a_row_creates_nothing()
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
var store = repo.BackWithRow(null);
|
||||
var service = new TenantConfigurationService(
|
||||
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
await service.RefreshFromStoreAsync(TenantId);
|
||||
|
||||
Assert.Null(store.Row);
|
||||
Assert.Empty(store.Writes);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ConfirmBeforeRefusalAsync
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_returns_the_stored_row_and_refreshes_the_cache()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
await s.B.GetConfigurationAsync(TenantId);
|
||||
await AllowOnly(s.A, "Dell*,Contoso");
|
||||
|
||||
var stored = await s.B.ConfirmBeforeRefusalAsync(TenantId);
|
||||
|
||||
Assert.Equal("Dell*,Contoso", stored!.ManufacturerWhitelist);
|
||||
Assert.Equal("Dell*,Contoso", (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_reads_storage_once_per_interval_per_tenant()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
await s.B.GetConfigurationAsync(TenantId);
|
||||
s.Repo.Invocations.Clear();
|
||||
|
||||
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync(TenantId));
|
||||
s.Clock.SetUtcNow(Start + TenantConfigurationService.RefusalConfirmInterval - TimeSpan.FromSeconds(1));
|
||||
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId));
|
||||
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId));
|
||||
s.Repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Once);
|
||||
|
||||
s.Clock.SetUtcNow(Start + TenantConfigurationService.RefusalConfirmInterval);
|
||||
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync(TenantId));
|
||||
s.Repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_counts_each_tenant_on_its_own_and_ignores_the_case_of_the_id()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
|
||||
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync(TenantId));
|
||||
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId.ToUpperInvariant()));
|
||||
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync("22222222-2222-2222-2222-222222222222"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_answers_null_when_the_read_fails_or_no_row_exists_so_the_refusal_stands()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
s.Store.ThrowOnRead = new InvalidOperationException("storage unavailable");
|
||||
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId));
|
||||
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.BackWithRow(null);
|
||||
var service = new TenantConfigurationService(
|
||||
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()));
|
||||
Assert.Null(await service.ConfirmBeforeRefusalAsync(TenantId));
|
||||
Assert.Null(await service.ConfirmBeforeRefusalAsync(string.Empty));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the bootstrap feature switch
|
||||
|
||||
[Fact]
|
||||
public async Task Bootstrap_gate_sees_a_plan_change_another_instance_wrote()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
await s.B.GetConfigurationAsync(TenantId); // B: still the free plan
|
||||
Assert.False(TenantEntitlementService.IsBootstrapEnabled((await s.B.GetConfigurationAsync(TenantId)), DateTime.UtcNow));
|
||||
|
||||
await s.A.UpdateAsync(TenantId, row =>
|
||||
{
|
||||
row.BootstrapTokenEnabled = true;
|
||||
return true;
|
||||
}, "test", "bootstrap");
|
||||
|
||||
Assert.True(await BootstrapFeatureGate.IsEnabledAsync(s.B, TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bootstrap_gate_reads_nothing_extra_while_the_cached_copy_says_yes()
|
||||
{
|
||||
var s = TwoInstances(row => row.BootstrapTokenEnabled = true);
|
||||
await s.B.GetConfigurationAsync(TenantId);
|
||||
s.Repo.Invocations.Clear();
|
||||
|
||||
Assert.True(await BootstrapFeatureGate.IsEnabledAsync(s.B, TenantId));
|
||||
|
||||
s.Repo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bootstrap_gate_refuses_a_disabled_feature_and_an_unknown_tenant()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
Assert.False(await BootstrapFeatureGate.IsEnabledAsync(s.B, TenantId));
|
||||
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.BackWithRow(null);
|
||||
var service = new TenantConfigurationService(
|
||||
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()));
|
||||
Assert.False(await BootstrapFeatureGate.IsEnabledAsync(service, TenantId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ratchet for the portal's "I have just written the configuration" hint. After a successful write
|
||||
/// under <c>/api/config/</c> (or one of the listed routes) the portal asks for the stored tenant
|
||||
/// configuration for a few minutes, because a write clears only the cache of the instance that
|
||||
/// served it (web <c>lib/storedConfigHint.ts</c>, <c>writesTenantConfig</c>). A function that writes
|
||||
/// the tenant row from a route the portal does not recognise brings the outdated reads back for
|
||||
/// that route — so every such function lives under <c>Functions/Config</c> with routes below
|
||||
/// <c>config/</c>, or is listed here with the reason it needs no hint.
|
||||
/// </summary>
|
||||
public class TenantConfigWriteRouteGuardTests
|
||||
{
|
||||
private static readonly Regex TenantConfigWrite = new(
|
||||
@"_(?:tenantConfigService|configService|tenantConfigurationService)\s*\.\s*(?:UpdateAsync|CreateOrUpdateAsync)\("
|
||||
+ @"|_patchService\s*\.\s*(?:ApplyFieldPatchAsync|RevertAsync)\(",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex WriteRoute = new(
|
||||
@"HttpTrigger\([^)]*?""(?:post|put|patch|delete)""[^)]*?Route\s*=\s*""(?<route>[^""]+)""",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
|
||||
/// <summary>Writers outside <c>Functions/Config</c>: file → (why, the portal route the web must list, if any).</summary>
|
||||
private static readonly IReadOnlyDictionary<string, (string Why, string? PortalRoute)> Baseline =
|
||||
new Dictionary<string, (string, string?)>(StringComparer.Ordinal)
|
||||
{
|
||||
["Functions/Infrastructure/AuthFunction.cs"] =
|
||||
("the terms confirmation is on the portal's list; the sign-in writes of auth/me answer with what they wrote", "/api/auth/dpa-acceptance"),
|
||||
["Functions/Admin/TenantOffboardFunction.cs"] =
|
||||
("writes the offboarding tombstone; the tenant's members are locked out from then on", null),
|
||||
};
|
||||
|
||||
private static string RepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_function_that_writes_the_tenant_row_uses_a_route_the_portal_recognises()
|
||||
{
|
||||
var functionsRoot = Path.Combine(RepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
|
||||
var portalList = File.ReadAllText(Path.Combine(
|
||||
RepoRoot(), "src", "Web", "autopilot-monitor-web", "lib", "storedConfigHint.ts"));
|
||||
Assert.Contains("\"/api/config/\"", portalList);
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var offenders = new List<string>();
|
||||
foreach (var path in Directory.EnumerateFiles(Path.Combine(functionsRoot, "Functions"), "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
var source = File.ReadAllText(path);
|
||||
if (!TenantConfigWrite.IsMatch(source))
|
||||
continue;
|
||||
var rel = Path.GetRelativePath(functionsRoot, path).Replace('\\', '/');
|
||||
seen.Add(rel);
|
||||
|
||||
if (Baseline.TryGetValue(rel, out var entry))
|
||||
{
|
||||
if (entry.PortalRoute != null && !portalList.Contains($"\"{entry.PortalRoute}\"", StringComparison.Ordinal))
|
||||
offenders.Add($"{rel}: the portal no longer lists {entry.PortalRoute}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!rel.StartsWith("Functions/Config/", StringComparison.Ordinal))
|
||||
{
|
||||
offenders.Add($"{rel}: writes the tenant row outside Functions/Config");
|
||||
continue;
|
||||
}
|
||||
foreach (Match route in WriteRoute.Matches(source))
|
||||
{
|
||||
var value = route.Groups["route"].Value;
|
||||
if (!value.StartsWith("config/", StringComparison.Ordinal) && !value.StartsWith("global/config", StringComparison.Ordinal))
|
||||
offenders.Add($"{rel}: write route '{value}' is not under config/");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(offenders.Count == 0,
|
||||
"A tenant-configuration write the portal's hint does not cover — move the route under config/, or add the file to the "
|
||||
+ "baseline with its reason and, for a portal route, extend writesTenantConfig in lib/storedConfigHint.ts:\n "
|
||||
+ string.Join("\n ", offenders));
|
||||
|
||||
var stale = Baseline.Keys.Where(k => !seen.Contains(k)).ToList();
|
||||
Assert.True(stale.Count == 0,
|
||||
"Baseline entries that no longer write the tenant row — remove them:\n " + string.Join("\n ", stale));
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -71,8 +71,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
}
|
||||
|
||||
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
|
||||
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
|
||||
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
|
||||
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
|
||||
{
|
||||
return req.CreateResponse(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
+1
-2
@@ -83,8 +83,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
var registration = request.Registration;
|
||||
|
||||
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
|
||||
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(registration.TenantId);
|
||||
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
|
||||
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, registration.TenantId))
|
||||
{
|
||||
return new RegisterSessionOutput { HttpResponse = req.CreateResponse(HttpStatusCode.NotFound) };
|
||||
}
|
||||
|
||||
+1
-2
@@ -72,8 +72,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
}
|
||||
|
||||
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
|
||||
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
|
||||
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
|
||||
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
|
||||
{
|
||||
return req.CreateResponse(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
+1
-2
@@ -53,8 +53,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
var request = read.Value!;
|
||||
|
||||
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
|
||||
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
|
||||
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
|
||||
{
|
||||
return await req.ForbiddenAsync("Bootstrap token feature is not enabled for this tenant");
|
||||
}
|
||||
|
||||
+2
-2
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -94,8 +95,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
|
||||
// Feature gate — same generic message as not-found so a disabled tenant is
|
||||
// indistinguishable from an unknown code (no enumeration oracle).
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(session.TenantId);
|
||||
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
|
||||
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, session.TenantId))
|
||||
{
|
||||
_logger.LogWarning("Bootstrap script for code {Code} rejected — feature disabled for tenant {TenantId}", code, session.TenantId);
|
||||
return await ScriptResponseAsync(
|
||||
|
||||
+1
-2
@@ -42,8 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
var tenantId = requestCtx.TargetTenantId;
|
||||
|
||||
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
|
||||
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
|
||||
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
|
||||
{
|
||||
// Return empty list instead of error — feature simply not visible
|
||||
var emptyResponse = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
+1
-2
@@ -47,8 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
var userIdentifier = requestCtx.UserPrincipalName;
|
||||
|
||||
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
|
||||
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
|
||||
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
|
||||
{
|
||||
return await req.ForbiddenAsync("Bootstrap token feature is not enabled for this tenant");
|
||||
}
|
||||
|
||||
+1
-2
@@ -75,8 +75,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
}
|
||||
|
||||
// Check if the bootstrap feature is enabled for the session's tenant (Pro plan or GA flag)
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(session.TenantId);
|
||||
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
|
||||
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, session.TenantId))
|
||||
{
|
||||
_logger.LogWarning("Bootstrap code {Code} rejected — feature disabled for tenant {TenantId}", code, session.TenantId);
|
||||
return await req.NotFoundAsync("Code not found, expired, or revoked");
|
||||
|
||||
+6
-1
@@ -36,7 +36,12 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
|
||||
_logger.LogInformation($"GetTenantConfiguration: {requestCtx.TargetTenantId} by user {userIdentifier}");
|
||||
|
||||
var config = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
|
||||
// The tenant's row or an error, never a synthesized default: this answer is the baseline
|
||||
// of the settings editor and of every read-modify-write in the portal, and the plain
|
||||
// cached read answers a storage failure with defaults. A tenant without a row still
|
||||
// gets its default row.
|
||||
var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId)
|
||||
?? await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
|
||||
|
||||
// Only a caller with WRITE authority over THIS target tenant sees the full config including
|
||||
// DiagnosticsBlobSasUrl / webhook URLs / custom headers (they manage those in the Settings
|
||||
|
||||
+6
-3
@@ -47,8 +47,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
///
|
||||
/// Adding a field here is a deliberate decision that the field is non-sensitive: it must not
|
||||
/// expose webhook URLs, SAS tokens, admin allowlists, or any other admin-only data. The full
|
||||
/// configuration (which does contain such data) lives behind GET /api/config/{tenantId}, gated
|
||||
/// to TenantAdminOrGA.
|
||||
/// configuration (which does contain such data) lives behind GET /api/config/{tenantId}, which
|
||||
/// redacts its secrets for every caller without write authority over the tenant.
|
||||
/// </summary>
|
||||
[Function("GetTenantFeatureFlags")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
@@ -60,7 +60,10 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||
var requestCtx = req.GetRequestContext();
|
||||
|
||||
var config = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
|
||||
// The tenant's row or an error, never a synthesized default (see GetTenantConfiguration):
|
||||
// the flags decide banners and gates, and a failed answer is not cached by the portal.
|
||||
var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId)
|
||||
?? await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
|
||||
var appHomingFunnelActive = await _appHomingService.IsFunnelEligibleAsync(config);
|
||||
// Only the setup guide needs the enrollment history; a hidden guide skips the read.
|
||||
var hasEnrollmentSessions = config.SetupGuideDismissedAt == null
|
||||
|
||||
+24
@@ -1,9 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -88,6 +91,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
request.Reason,
|
||||
callerTier);
|
||||
|
||||
StampUnchangedFields(req, request.Fields.Keys, outcome);
|
||||
return await WriteOutcome(req, outcome);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -96,6 +100,26 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the sent fields the stored row already held onto the request row. The portal sends
|
||||
/// only what its user changed, so such a field means the page showed an outdated configuration.
|
||||
/// </summary>
|
||||
private static void StampUnchangedFields(HttpRequestData req, IEnumerable<string> sentFields, PatchOutcome outcome)
|
||||
{
|
||||
var unchanged = UnchangedFieldCount(sentFields, outcome);
|
||||
if (unchanged > 0)
|
||||
RequestRowMarkers.Stamp(req, RequestRowMarkers.ConfigPatchUnchangedKey, unchanged.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
/// <summary>Sent fields (wire names) a successful patch did not change; zero for a failed patch.</summary>
|
||||
internal static int UnchangedFieldCount(IEnumerable<string> sentFields, PatchOutcome outcome)
|
||||
{
|
||||
if (!outcome.Success)
|
||||
return 0;
|
||||
var applied = new HashSet<string>(outcome.AppliedFields, StringComparer.OrdinalIgnoreCase);
|
||||
return sentFields.Count(field => !applied.Contains(field));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tags the backup Source with the write path: the Collect Logs intent, else MCP (tools stamp
|
||||
/// X-Client-Source: mcp) or the plain API.
|
||||
|
||||
+7
-2
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
@@ -77,18 +78,22 @@ public class GetScriptDisplayNamesFunction
|
||||
|
||||
var distinctRefs = parsedRefs.Distinct().ToList();
|
||||
|
||||
var resolved = await _resolver.ResolveAsync(requestCtx.TargetTenantId, distinctRefs, default);
|
||||
var lookup = await _resolver.ResolveAsync(requestCtx.TargetTenantId, distinctRefs, default);
|
||||
|
||||
var payload = new Dictionary<string, string?>(distinctRefs.Count);
|
||||
foreach (var r in distinctRefs)
|
||||
{
|
||||
payload[r.ToString()] = resolved.TryGetValue(r, out var name) ? name : null;
|
||||
payload[r.ToString()] = lookup.Names.TryGetValue(r, out var name) ? name : null;
|
||||
}
|
||||
|
||||
var marker = RequestRowMarkers.ScriptNameLookup.For(lookup.TimedOut, isRetry: body.Retry == true);
|
||||
if (marker != null) RequestRowMarkers.Stamp(req, RequestRowMarkers.ScriptNameLookupKey, marker);
|
||||
|
||||
return await req.OkAsync(new GetScriptDisplayNamesResponse
|
||||
{
|
||||
Refs = payload,
|
||||
Malformed = malformed.Count > 0 ? malformed : null,
|
||||
TimedOut = lookup.TimedOut ? true : null,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
+8
-5
@@ -13,9 +13,9 @@ namespace AutopilotMonitor.Functions.Functions.Maintenance;
|
||||
/// <list type="bullet">
|
||||
/// <item>Positive (found) rows older than <see cref="PositiveTtl"/> are deleted so a
|
||||
/// renamed Intune script eventually surfaces under its new name.</item>
|
||||
/// <item>Negative (NotFound) rows expire much faster (<see cref="NotFoundTtl"/>) — they
|
||||
/// exist only to suppress repeated 404 fetches for genuinely deleted scripts;
|
||||
/// once the cache is gone the resolver will lazily re-check.</item>
|
||||
/// <item>Negative (NotFound) rows live just as long (<see cref="NotFoundTtl"/>): they
|
||||
/// suppress repeated 404 fetches for an ID Graph does not know. A list pull that
|
||||
/// contains the ID after all overwrites the row.</item>
|
||||
/// <item>Per-tenant meta rows (<c>"{Kind}_$meta"</c>) live forever; they're tiny and
|
||||
/// steer the resolver's "do a full pull vs. per-ID fallback" decision.</item>
|
||||
/// </list>
|
||||
@@ -27,8 +27,11 @@ public class ScriptNameCacheCleanupFunction
|
||||
/// <summary>Positive entries are usable for 7 days; covers all reasonable troubleshooting timeframes.</summary>
|
||||
public static readonly TimeSpan PositiveTtl = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>Negative-cache rows expire in 1h so deleted/renamed scripts come back quickly.</summary>
|
||||
public static readonly TimeSpan NotFoundTtl = TimeSpan.FromHours(1);
|
||||
/// <summary>
|
||||
/// Same lifetime as a name. A shorter one asks Graph again for every unknown ID on the
|
||||
/// first view after each cleanup run.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan NotFoundTtl = PositiveTtl;
|
||||
|
||||
/// <summary>03:00 UTC daily — outside European working hours, off the busy maintenance window.</summary>
|
||||
private const string Cron = "0 0 3 * * *";
|
||||
|
||||
+5
-4
@@ -434,10 +434,6 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
RegisteredAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// 410 Gone response for the cascade-delete guard. Mirrors the V2 ingest 410 contract so
|
||||
/// the agent treats register and telemetry uniformly when a session is being deleted.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Clamps <see cref="SessionRegistration.StartedAt"/> to the same plausibility window
|
||||
/// events get (<see cref="EventTimestampValidator.SanitizeTimestamp"/>): outside
|
||||
@@ -492,6 +488,11 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
ErrorCode = Constants.AgentErrorCodes.SessionOwnerMismatch,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// 410 Gone for the cascade-delete guard: a cascade owns the session, or its tombstone is
|
||||
/// still fresh. Same status as the telemetry ingest. The agent ends monitoring on it and
|
||||
/// removes itself; a WhiteGlove Part 2 start registers under a new session id instead.
|
||||
/// </summary>
|
||||
private static Task<HttpResponseData> WriteSessionLockedAsync(HttpRequestData req, SessionDeletionLockedException locked)
|
||||
=> req.JsonAsync(HttpStatusCode.Gone, new RegisterSessionResponse
|
||||
{
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// The feature switch in front of every bootstrap route (Pro plan, or the per-tenant flag). A "no"
|
||||
/// is confirmed against the stored row first (<see cref="TenantConfigurationService.ConfirmBeforeRefusalAsync"/>):
|
||||
/// the cached copy can predate the plan change that enabled the feature, and a tenant that has just
|
||||
/// become Pro creates and uses its first code at once. False for an unknown tenant.
|
||||
/// </summary>
|
||||
internal static class BootstrapFeatureGate
|
||||
{
|
||||
public static async Task<bool> IsEnabledAsync(TenantConfigurationService configService, string tenantId)
|
||||
{
|
||||
var (config, exists) = await configService.TryGetConfigurationAsync(tenantId);
|
||||
if (!exists)
|
||||
return false;
|
||||
if (TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
|
||||
return true;
|
||||
|
||||
var stored = await configService.ConfirmBeforeRefusalAsync(tenantId);
|
||||
return stored != null && TenantEntitlementService.IsBootstrapEnabled(stored, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>X-Tenant-Config: stored</c> request header. A client sends it for a few minutes after it
|
||||
/// wrote a tenant configuration, because a write clears only the cache of the instance that served
|
||||
/// it: the instance that answers reads the stored row first. Caller-supplied, so it only ever costs
|
||||
/// the caller's own request one read — it grants nothing.
|
||||
/// </summary>
|
||||
internal static class StoredConfigHeader
|
||||
{
|
||||
public const string HeaderName = "X-Tenant-Config";
|
||||
public const string Stored = "stored";
|
||||
|
||||
/// <summary>True when the request asks for the stored row instead of this instance's cached copy.</summary>
|
||||
public static bool IsSet(HttpContext? httpContext)
|
||||
=> httpContext != null && string.Equals(
|
||||
httpContext.Request.Headers[HeaderName].FirstOrDefault(), Stored, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -96,12 +96,18 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
httpContext.Request.Headers[ViewAsHeaders.Tenant].FirstOrDefault(),
|
||||
httpContext.Request.Headers[ViewAsHeaders.Role].FirstOrDefault());
|
||||
|
||||
// A signed-in caller that has just written a tenant configuration asks for the stored row
|
||||
// (StoredConfigHeader); the request row counts how often that happens.
|
||||
var readStoredConfig = principal != null && StoredConfigHeader.IsSet(httpContext);
|
||||
if (readStoredConfig)
|
||||
context.Items[RequestRowMarkers.ConfigRefreshKey] = RequestRowMarkers.ConfigRefreshHint;
|
||||
|
||||
// All authorization + RequestContext construction is the transport-agnostic DecideAsync seam.
|
||||
// Fail-closed on service errors: return 503 (not 500) so clients can retry.
|
||||
PolicyResult result;
|
||||
try
|
||||
{
|
||||
result = await DecideAsync(httpMethod, requestPath, queryTenantId, principal, ClientSourceHeader.IsMcp(httpContext), viewAs);
|
||||
result = await DecideAsync(httpMethod, requestPath, queryTenantId, principal, ClientSourceHeader.IsMcp(httpContext), viewAs, readStoredConfig);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -224,10 +230,13 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
/// <paramref name="isMcpClient"/> is the self-declared <c>X-Client-Source: mcp</c> marker: it only ever
|
||||
/// narrows (the tenant MCP switch), never grants. <paramref name="viewAs"/> (the view-as headers) only
|
||||
/// ever narrows too: a Global Admin is decided as the emulated member of the viewed tenant, read-only.
|
||||
/// <paramref name="readStoredConfig"/> (<see cref="StoredConfigHeader"/>) reads the stored configuration of
|
||||
/// the acting and the target tenant into this instance's cache first, so this decision and the function
|
||||
/// behind it see what the caller has just written through another instance.
|
||||
/// </summary>
|
||||
internal async Task<PolicyResult> DecideAsync(
|
||||
string httpMethod, string requestPath, string? queryTenantId, ClaimsPrincipal? principal,
|
||||
bool isMcpClient = false, ViewAsTarget? viewAs = null)
|
||||
bool isMcpClient = false, ViewAsTarget? viewAs = null, bool readStoredConfig = false)
|
||||
{
|
||||
// Sanitized copies for logging only — never used for routing/authorization decisions.
|
||||
var logPath = LogSanitizer.Clean(requestPath);
|
||||
@@ -255,6 +264,12 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
viewing = viewAs;
|
||||
}
|
||||
var actingTenantId = viewing?.TenantId ?? jwtTenantId;
|
||||
|
||||
// Before the roles and the suspension gate below, which read this tenant's configuration too.
|
||||
readStoredConfig = readStoredConfig && principal != null;
|
||||
if (readStoredConfig)
|
||||
await _tenantConfigService.RefreshFromStoreAsync(actingTenantId);
|
||||
|
||||
var roles = viewing == null ? CallerRoles.Real(this, principal) : CallerRoles.Emulated(viewing.Role);
|
||||
|
||||
var decision = await EvaluateCatalogPolicyAsync(principal, catalogEntry, roles, actingTenantId);
|
||||
@@ -327,6 +342,11 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
decision.UserIdentifier, decision.UserRole, "CrossTenant", catalogEntry.Policy);
|
||||
}
|
||||
|
||||
// The target of an admitted cross-tenant call (Global Admin, delegated reader) likewise; only
|
||||
// past the cross-tenant check, so nobody makes this instance read a tenant it may not reach.
|
||||
if (readStoredConfig && !string.Equals(targetTenantId, actingTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
await _tenantConfigService.RefreshFromStoreAsync(targetTenantId);
|
||||
|
||||
// Tenant suspension gate — the operator kill-switch (TenantConfiguration.Disabled/DisabledUntil) and
|
||||
// the offboarding tombstone. Enforced HERE, on the shared authorization path, so it covers every
|
||||
// tenant-scoped API call and not just the SPA's auth/me login probe (which also carries it, for the
|
||||
|
||||
@@ -231,6 +231,20 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
|
||||
requestTelemetry.Properties[key] = text;
|
||||
}
|
||||
|
||||
// TENANT-CONFIG FRESHNESS — the stored row was read instead of this instance's cached copy
|
||||
// (a caller's own write, a refusal about to happen), or a field patch found values the
|
||||
// client believed it was changing.
|
||||
foreach (var key in RequestRowMarkers.ConfigFreshnessKeys)
|
||||
{
|
||||
if (context.Items.TryGetValue(key, out var value) && value is string text)
|
||||
requestTelemetry.Properties[key] = text;
|
||||
}
|
||||
|
||||
// SCRIPT-NAME LOOKUP — set by GetScriptDisplayNames when a lookup ran into a time limit
|
||||
// or is the portal's follow-up to one.
|
||||
if (context.Items.TryGetValue(RequestRowMarkers.ScriptNameLookupKey, out var scriptNameLookup) && scriptNameLookup is string scriptNameLookupOutcome)
|
||||
requestTelemetry.Properties[RequestRowMarkers.ScriptNameLookupKey] = scriptNameLookupOutcome;
|
||||
|
||||
var reqCtx = context.GetRequestContext();
|
||||
var tenantId = ResolveTenantId(
|
||||
reqCtx.IdentityTenantId(),
|
||||
|
||||
@@ -57,6 +57,64 @@ namespace AutopilotMonitor.Functions.Security
|
||||
public const string SessionUnknown = "session_unknown";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The agent gate read the stored tenant configuration before refusing a request the cached
|
||||
/// copy would have refused (<c>TenantConfigurationService.ConfirmBeforeRefusalAsync</c>).
|
||||
/// Absent on requests that pass on the cached copy and within the confirm interval.
|
||||
/// </summary>
|
||||
public const string ConfigConfirmKey = "ConfigConfirm";
|
||||
|
||||
/// <summary>Closed set of <see cref="ConfigConfirmKey"/> values.</summary>
|
||||
public static class ConfigConfirm
|
||||
{
|
||||
/// <summary>The stored row no longer refuses: another instance's write had outdated the copy.</summary>
|
||||
public const string Averted = "averted";
|
||||
/// <summary>The stored row refuses as well.</summary>
|
||||
public const string Confirmed = "confirmed";
|
||||
/// <summary>The stored row switches on a validation the chain had not asked; the chain ran again.</summary>
|
||||
public const string Rerun = "rerun";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A signed-in request asked for the stored tenant configuration (<c>StoredConfigHeader</c>):
|
||||
/// its caller wrote the configuration within the last minutes.
|
||||
/// </summary>
|
||||
public const string ConfigRefreshKey = "ConfigRefresh";
|
||||
|
||||
/// <summary>The one <see cref="ConfigRefreshKey"/> value.</summary>
|
||||
public const string ConfigRefreshHint = "hint";
|
||||
|
||||
/// <summary>
|
||||
/// On a tenant-configuration field patch: how many of the sent fields the stored row already
|
||||
/// held. The client believed they differed, so it was showing an outdated configuration.
|
||||
/// Absent when every sent field changed.
|
||||
/// </summary>
|
||||
public const string ConfigPatchUnchangedKey = "ConfigPatchUnchanged";
|
||||
|
||||
/// <summary>
|
||||
/// On a script display-name lookup: one of <see cref="ScriptNameLookup"/>. Absent on a
|
||||
/// first lookup that stayed within its time limits, which is the normal case.
|
||||
/// </summary>
|
||||
public const string ScriptNameLookupKey = "ScriptNameLookup";
|
||||
|
||||
/// <summary>Closed set of <see cref="ScriptNameLookupKey"/> values.</summary>
|
||||
public static class ScriptNameLookup
|
||||
{
|
||||
/// <summary>A first lookup ran into a time limit; the portal asks again once.</summary>
|
||||
public const string TimedOut = "timedOut";
|
||||
/// <summary>The portal's follow-up lookup, answered within the limits.</summary>
|
||||
public const string Retry = "retry";
|
||||
/// <summary>The follow-up ran into a time limit too: the names stay missing until a reload.</summary>
|
||||
public const string RetryTimedOut = "retryTimedOut";
|
||||
|
||||
/// <summary>The value for one lookup, or null when the row gets no marker.</summary>
|
||||
public static string? For(bool timedOut, bool isRetry)
|
||||
{
|
||||
if (isRetry) return timedOut ? RetryTimedOut : Retry;
|
||||
return timedOut ? TimedOut : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the Intune device lookup behind the client certificate
|
||||
/// (<see cref="IntuneDeviceBindingOutcome"/> name). Absent when no lookup result exists for
|
||||
@@ -90,6 +148,12 @@ namespace AutopilotMonitor.Functions.Security
|
||||
CertDeviceOwnerTypeKey, CertDeviceEnrollmentTypeKey,
|
||||
};
|
||||
|
||||
/// <summary>The tenant-configuration freshness keys; copied onto the request row like the ones above.</summary>
|
||||
public static readonly string[] ConfigFreshnessKeys =
|
||||
{
|
||||
ConfigConfirmKey, ConfigRefreshKey, ConfigPatchUnchangedKey,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Stamps one Intune device lookup onto the request row. <paramref name="headerSerial"/> is
|
||||
/// the agent-supplied serial, compared case-insensitively against the one Intune recorded.
|
||||
|
||||
@@ -139,7 +139,48 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// add-on permission counts toward the add-on notice.
|
||||
/// </param>
|
||||
/// <returns>Security validation result</returns>
|
||||
public async Task<SecurityValidationResult> ValidateRequestAsync(HttpRequestData req, string tenantId, string? sessionId = null, bool selfReportedCloudPc = false)
|
||||
public Task<SecurityValidationResult> ValidateRequestAsync(HttpRequestData req, string tenantId, string? sessionId = null, bool selfReportedCloudPc = false)
|
||||
=> ValidateRequestAsync(req, tenantId, sessionId, selfReportedCloudPc, configConfirmed: false);
|
||||
|
||||
/// <summary>
|
||||
/// Whether a gate the tenant configuration alone decides would refuse this request: the
|
||||
/// suspension, the bootstrap feature for a token request, "no validation switched on", the
|
||||
/// hardware whitelist. Mirrors those gates of the validation below in their order; pure, logs
|
||||
/// nothing. A gate added there that the configuration decides belongs here too, or it keeps
|
||||
/// refusing on an outdated copy.
|
||||
/// </summary>
|
||||
internal bool RefusesByConfiguration(TenantConfiguration config, HttpRequestData req)
|
||||
{
|
||||
if (config.IsCurrentlyDisabled())
|
||||
return true;
|
||||
|
||||
// A request the bootstrap token admits is decided by the feature switch alone.
|
||||
if (GetBootstrapToken(req) != null && _bootstrapSessionService != null)
|
||||
return !Services.TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow);
|
||||
|
||||
if (!config.HasAnyDeviceValidation() && !config.AllowInsecureAgentRequests)
|
||||
return true;
|
||||
|
||||
return !HardwareWhitelistValidator.ValidateHardware(
|
||||
req.Headers.Contains("X-Device-Manufacturer") ? req.Headers.GetValues("X-Device-Manufacturer").FirstOrDefault() : null,
|
||||
req.Headers.Contains("X-Device-Model") ? req.Headers.GetValues("X-Device-Model").FirstOrDefault() : null,
|
||||
config.GetManufacturerWhitelist(),
|
||||
config.GetModelWhitelist()).IsValid;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="stored"/> switches on a device validation the chain did not ask
|
||||
/// while it ran on <paramref name="used"/>.
|
||||
/// </summary>
|
||||
internal static bool SwitchesOnUnaskedValidation(TenantConfiguration used, TenantConfiguration stored)
|
||||
=> (stored.ValidateAutopilotDevice && !used.ValidateAutopilotDevice)
|
||||
|| (stored.ValidateCorporateIdentifier && !used.ValidateCorporateIdentifier)
|
||||
|| (stored.ValidateDeviceAssociation && !used.ValidateDeviceAssociation)
|
||||
|| (stored.ValidateCloudPcDevice && !used.ValidateCloudPcDevice)
|
||||
|| (stored.ValidateIntuneDeviceBinding && !used.ValidateIntuneDeviceBinding);
|
||||
|
||||
private async Task<SecurityValidationResult> ValidateRequestAsync(
|
||||
HttpRequestData req, string tenantId, string? sessionId, bool selfReportedCloudPc, bool configConfirmed)
|
||||
{
|
||||
// 0. Verify tenant is known and not suspended (cheapest gate — runs before cert/rate/hardware)
|
||||
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
|
||||
@@ -156,6 +197,23 @@ namespace AutopilotMonitor.Functions.Security
|
||||
};
|
||||
}
|
||||
|
||||
// A refusal the configuration decides is never taken from a copy another instance's write
|
||||
// has outdated: the stored row is read first, before any gate has run or logged. Requests
|
||||
// the cached copy admits read nothing here. An unknown tenant needs no confirmation —
|
||||
// nothing is cached for a missing row, so that answer came from storage.
|
||||
if (!configConfirmed && RefusesByConfiguration(config, req))
|
||||
{
|
||||
configConfirmed = true;
|
||||
var stored = await _configService.ConfirmBeforeRefusalAsync(tenantId);
|
||||
if (stored != null)
|
||||
{
|
||||
RequestRowMarkers.Stamp(req, RequestRowMarkers.ConfigConfirmKey, RefusesByConfiguration(stored, req)
|
||||
? RequestRowMarkers.ConfigConfirm.Confirmed
|
||||
: RequestRowMarkers.ConfigConfirm.Averted);
|
||||
config = stored;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.IsCurrentlyDisabled())
|
||||
{
|
||||
_logger.LogWarning("Rejected agent request: suspended tenant {TenantId} (reason: {Reason})",
|
||||
@@ -592,6 +650,21 @@ namespace AutopilotMonitor.Functions.Security
|
||||
};
|
||||
}
|
||||
|
||||
// Definitive: no switched-on validation knows the device. When the stored row switches
|
||||
// on one this pass has not asked (another instance's write), the chain runs once more
|
||||
// on it — the read above put that row into this instance's cache.
|
||||
if (!configConfirmed)
|
||||
{
|
||||
var stored = await _configService.ConfirmBeforeRefusalAsync(tenantId);
|
||||
if (stored != null && SwitchesOnUnaskedValidation(config, stored))
|
||||
{
|
||||
RequestRowMarkers.Stamp(req, RequestRowMarkers.ConfigConfirmKey, RequestRowMarkers.ConfigConfirm.Rerun);
|
||||
return await ValidateRequestAsync(req, tenantId, sessionId, selfReportedCloudPc, configConfirmed: true);
|
||||
}
|
||||
if (stored != null)
|
||||
RequestRowMarkers.Stamp(req, RequestRowMarkers.ConfigConfirmKey, RequestRowMarkers.ConfigConfirm.Confirmed);
|
||||
}
|
||||
|
||||
LogRequestRejection("device", tenantId, req, sessionId,
|
||||
extraReason: deviceValidationError,
|
||||
thumbprint: certValidation.Thumbprint);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
+11
-4
@@ -11,15 +11,22 @@ namespace AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
/// script recently?), the Graph fetch (list-full-pull or per-ID fallback), and the negative-
|
||||
/// cache write for 404s.
|
||||
/// <para>
|
||||
/// Returns a dictionary where the value for any unresolved ref is <c>null</c>. Never throws
|
||||
/// to the caller — Graph timeouts / 429s / 5xx degrade to "no name" so the UI can still
|
||||
/// render IDs. Cache failures behave the same way.
|
||||
/// The value for any unresolved ref is <c>null</c>. Never throws to the caller — Graph
|
||||
/// timeouts / 429s / 5xx degrade to "no name" so the UI can still render IDs. Cache failures
|
||||
/// behave the same way.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IScriptDisplayNameResolver
|
||||
{
|
||||
Task<IReadOnlyDictionary<ScriptRef, string?>> ResolveAsync(
|
||||
Task<ScriptDisplayNameLookup> ResolveAsync(
|
||||
string tenantId,
|
||||
IReadOnlyCollection<ScriptRef> refs,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One lookup's outcome. <see cref="TimedOut"/> is true when a time limit of the resolver ended
|
||||
/// the lookup before every ref had an answer, so asking again can return more names. A failed
|
||||
/// Graph call leaves it false: asking again at once would only add to a throttle or an outage.
|
||||
/// </summary>
|
||||
public sealed record ScriptDisplayNameLookup(IReadOnlyDictionary<ScriptRef, string?> Names, bool TimedOut);
|
||||
|
||||
+93
-20
@@ -1,6 +1,7 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
@@ -33,11 +34,11 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
|
||||
internal static readonly TimeSpan TotalBudget = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Tight cap on the token-acquire path specifically. The default
|
||||
/// <see cref="GraphTokenService"/> retries 5+15+30 s with no cancellation in older code paths;
|
||||
/// for this optional feature we want a hard wall well below <see cref="TotalBudget"/>.
|
||||
/// Cap on the token-acquire path, whose retry chain (5+15+30 s) would otherwise outlast
|
||||
/// <see cref="TotalBudget"/>. The first mint on a cold instance often needs more than
|
||||
/// 1.5 s; a lower value drops every name on the first lookup after an idle period.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan TokenAcquireBudget = TimeSpan.FromMilliseconds(1500);
|
||||
internal static readonly TimeSpan TokenAcquireBudget = TimeSpan.FromSeconds(3);
|
||||
|
||||
private const string GraphBetaBase = Constants.GraphBaseUrl + "/beta";
|
||||
private const int PageSize = 100;
|
||||
@@ -48,6 +49,7 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
|
||||
private readonly ILogger<ScriptDisplayNameResolver> _logger;
|
||||
private readonly TelemetryClient _telemetry;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly TimeSpan _tokenAcquireBudget;
|
||||
|
||||
public ScriptDisplayNameResolver(
|
||||
IGraphFeatureDetector detector,
|
||||
@@ -66,7 +68,8 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<ScriptDisplayNameResolver> logger,
|
||||
TelemetryClient telemetry,
|
||||
TimeProvider time)
|
||||
TimeProvider time,
|
||||
TimeSpan? tokenAcquireBudget = null)
|
||||
{
|
||||
_detector = detector;
|
||||
_cache = cache;
|
||||
@@ -74,38 +77,69 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
|
||||
_logger = logger;
|
||||
_telemetry = telemetry;
|
||||
_time = time;
|
||||
_tokenAcquireBudget = tokenAcquireBudget ?? TokenAcquireBudget;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<ScriptRef, string?>> ResolveAsync(
|
||||
// Reasons in the "lookup incomplete" line. The three time limits also set
|
||||
// ScriptDisplayNameLookup.TimedOut, which tells the caller that asking again can help.
|
||||
private const string TokenTimeout = "TokenTimeout";
|
||||
private const string TokenError = "TokenError";
|
||||
private const string LookupTimeout = "LookupTimeout";
|
||||
private const string LookupError = "LookupError";
|
||||
|
||||
public async Task<ScriptDisplayNameLookup> ResolveAsync(
|
||||
string tenantId,
|
||||
IReadOnlyCollection<ScriptRef> refs,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var result = new Dictionary<ScriptRef, string?>();
|
||||
if (refs == null || refs.Count == 0) return result;
|
||||
if (refs == null || refs.Count == 0) return new ScriptDisplayNameLookup(result, TimedOut: false);
|
||||
foreach (var r in refs) result[r] = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId)) return result;
|
||||
if (string.IsNullOrWhiteSpace(tenantId)) return new ScriptDisplayNameLookup(result, TimedOut: false);
|
||||
|
||||
// Total budget starts BEFORE the detector call. A slow Graph token-acquire used to
|
||||
// be able to burn ~50 s before this method even decided whether to proceed; the
|
||||
// linked CTS + the detector's CT support cap that to TokenAcquireBudget.
|
||||
var started = Stopwatch.GetTimestamp();
|
||||
using var budgetCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
budgetCts.CancelAfter(TotalBudget);
|
||||
var budget = budgetCts.Token;
|
||||
|
||||
// 1. Permission gate — with a tight sub-budget so the optional feature can't hang
|
||||
// waiting for a slow Azure AD response on tenants with consent-propagation delay.
|
||||
GraphTenantTokenContext? ctx;
|
||||
GraphTenantTokenContext? ctx = null;
|
||||
string? tokenProblem = null;
|
||||
using (var tokenCts = CancellationTokenSource.CreateLinkedTokenSource(budget))
|
||||
{
|
||||
tokenCts.CancelAfter(TokenAcquireBudget);
|
||||
ctx = await SafeAsync(_detector.TryGetTokenContextAsync(tenantId, tokenCts.Token)).ConfigureAwait(false);
|
||||
tokenCts.CancelAfter(_tokenAcquireBudget);
|
||||
try
|
||||
{
|
||||
ctx = await _detector.TryGetTokenContextAsync(tenantId, tokenCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Counted below as a timeout when it was this class's cap that fired.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Script-name resolver: token context failed for tenant={Tenant}", tenantId);
|
||||
tokenProblem = TokenError;
|
||||
}
|
||||
if (ctx == null && tokenProblem == null && tokenCts.IsCancellationRequested && !ct.IsCancellationRequested)
|
||||
{
|
||||
tokenProblem = TokenTimeout;
|
||||
}
|
||||
}
|
||||
if (ctx == null)
|
||||
{
|
||||
// No token means no cache read either, so resolved and cacheHits are 0 by construction.
|
||||
if (tokenProblem != null) LogIncomplete(tenantId, new[] { tokenProblem }, refs.Count, resolved: 0, cacheHits: 0, started);
|
||||
return new ScriptDisplayNameLookup(result, TimedOut: tokenProblem == TokenTimeout);
|
||||
}
|
||||
if (ctx == null) return result;
|
||||
if (!ctx.GrantedRoles.Contains(GraphAppPermissions.DeviceManagementScriptsReadAll))
|
||||
{
|
||||
return result;
|
||||
return new ScriptDisplayNameLookup(result, TimedOut: false);
|
||||
}
|
||||
|
||||
// 2. Cache lookup for all refs.
|
||||
@@ -131,13 +165,16 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
|
||||
var triggeredFullPull = false;
|
||||
|
||||
var missing = refs.Where(r => !cached.ContainsKey(r)).ToList();
|
||||
if (missing.Count == 0) return result;
|
||||
if (missing.Count == 0) return new ScriptDisplayNameLookup(result, TimedOut: false);
|
||||
|
||||
// 3. Decide per kind: list-full-pull when meta stale, else per-ID fallback.
|
||||
// Track kinds whose full-pull hit a TRANSIENT failure (429/503/network/timeout).
|
||||
// Falling back to per-ID for those kinds in the same call would just amplify the
|
||||
// throttling Graph is trying to apply, so we suppress per-ID for that kind here.
|
||||
var skipPerIdKinds = new HashSet<ScriptKind>();
|
||||
// Why refs may end without an answer; see LogIncomplete.
|
||||
var incomplete = new List<string>();
|
||||
var timedOut = false;
|
||||
|
||||
foreach (var kind in missing.Select(r => r.Kind).Distinct())
|
||||
{
|
||||
@@ -172,6 +209,8 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
|
||||
{
|
||||
_logger.LogInformation("Script-name resolver: full-pull cancelled for tenant={Tenant} kind={Kind}", tenantId, kind);
|
||||
skipPerIdKinds.Add(kind);
|
||||
incomplete.Add($"ListTimeout:{kind}");
|
||||
timedOut = true;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
@@ -181,6 +220,7 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
|
||||
"Script-name resolver: full-pull transient for tenant={Tenant} kind={Kind} — skipping per-ID fallback for this kind",
|
||||
tenantId, kind);
|
||||
skipPerIdKinds.Add(kind);
|
||||
incomplete.Add($"ListError:{kind}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -192,12 +232,19 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
|
||||
}
|
||||
|
||||
// 4. Per-ID fallback for whatever the full-pull (or its absence) did not produce.
|
||||
// This step stays after a successful list too: the list leaves out some scripts
|
||||
// that a single GET returns, so "not in the list" never means "not found".
|
||||
var stillMissing = refs
|
||||
.Where(r => result[r] == null && !cached!.ContainsKey(r) && !skipPerIdKinds.Contains(r.Kind))
|
||||
.ToList();
|
||||
foreach (var r in stillMissing)
|
||||
{
|
||||
if (budget.IsCancellationRequested) break;
|
||||
if (budget.IsCancellationRequested)
|
||||
{
|
||||
incomplete.Add(LookupTimeout);
|
||||
timedOut = true;
|
||||
break;
|
||||
}
|
||||
try
|
||||
{
|
||||
var (displayName, notFound) = await FetchSingleAsync(r.Kind, r.Id, ctx.AccessToken, budget).ConfigureAwait(false);
|
||||
@@ -216,6 +263,8 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Script-name resolver: per-ID fetch cancelled for tenant={Tenant} ref={Ref}", tenantId, r);
|
||||
incomplete.Add(LookupTimeout);
|
||||
timedOut = true;
|
||||
break;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
@@ -225,28 +274,48 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
|
||||
_logger.LogWarning(ex,
|
||||
"Script-name resolver: per-ID transient for tenant={Tenant} ref={Ref} — aborting remaining per-ID fallbacks",
|
||||
tenantId, r);
|
||||
incomplete.Add(LookupError);
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Script-name resolver: per-ID fetch failed for tenant={Tenant} ref={Ref}", tenantId, r);
|
||||
if (!incomplete.Contains(LookupError)) incomplete.Add(LookupError);
|
||||
}
|
||||
}
|
||||
|
||||
var resolved = result.Values.Count(v => v != null);
|
||||
if (incomplete.Count > 0 && !ct.IsCancellationRequested)
|
||||
{
|
||||
LogIncomplete(tenantId, incomplete, refs.Count, resolved, cacheHits, started);
|
||||
}
|
||||
|
||||
// Usage telemetry — fires only when the optional permission was active AND the
|
||||
// resolver actually did work. Volume = once per session-view that has scripts,
|
||||
// which is a meaningful "tenant is using this feature" pulse.
|
||||
EmitResolved(tenantId, refs.Count, result, cacheHits, triggeredFullPull);
|
||||
EmitResolved(tenantId, refs.Count, resolved, cacheHits, triggeredFullPull);
|
||||
|
||||
return result;
|
||||
return new ScriptDisplayNameLookup(result, timedOut);
|
||||
}
|
||||
|
||||
private void EmitResolved(string tenantId, int refsRequested, Dictionary<ScriptRef, string?> result, int cacheHits, bool triggeredFullPull)
|
||||
/// <summary>
|
||||
/// One Warning per lookup that ended without an answer for every ref because a limit of
|
||||
/// this class fired or Graph failed. Worker logs below Warning are not collected, and the
|
||||
/// health marker reads the message prefix and the <c>reasons=</c> field: keep both stable.
|
||||
/// </summary>
|
||||
private void LogIncomplete(
|
||||
string tenantId, IReadOnlyCollection<string> reasons, int requested, int resolved, int cacheHits, long started)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Script-name lookup incomplete: tenant={Tenant} reasons={Reasons} requested={Requested} resolved={Resolved} cacheHits={CacheHits} elapsedMs={ElapsedMs}",
|
||||
tenantId, string.Join(",", reasons), requested, resolved, cacheHits,
|
||||
(long)Stopwatch.GetElapsedTime(started).TotalMilliseconds);
|
||||
}
|
||||
|
||||
private void EmitResolved(string tenantId, int refsRequested, int resolved, int cacheHits, bool triggeredFullPull)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resolved = 0;
|
||||
foreach (var v in result.Values) { if (v != null) resolved++; }
|
||||
_telemetry.TrackEvent("ScriptDisplayNamesResolved", new Dictionary<string, string>
|
||||
{
|
||||
["TenantId"] = tenantId,
|
||||
@@ -314,6 +383,10 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
|
||||
url = nextUrl;
|
||||
}
|
||||
|
||||
// A budget that ends between two pages leaves the loop with pages still to read. That
|
||||
// list is incomplete and must not be stored, or the kind counts as fetched for a week.
|
||||
if (!string.IsNullOrEmpty(url)) ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (entries.Count > 0)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -29,25 +29,47 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// How often <see cref="ConfirmBeforeRefusalAsync"/> reads the stored row per tenant on this
|
||||
/// instance: a burst of refused requests costs one read per interval, not one each.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan RefusalConfirmInterval = TimeSpan.FromSeconds(15);
|
||||
|
||||
// Concurrent misses for one tenant (cold start, after invalidation) share one repository
|
||||
// read instead of each issuing their own (audit 2026-09-05 F12).
|
||||
private readonly ConcurrentDictionary<string, Lazy<Task<TenantConfiguration>>> _inFlight = new();
|
||||
|
||||
// When this instance last read the stored row before a refusal, per tenant.
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _refusalConfirmedAt = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public TenantConfigurationService(
|
||||
IConfigRepository configRepo,
|
||||
ILogger<TenantConfigurationService> logger,
|
||||
IMemoryCache cache,
|
||||
ManagedTenantProIndex proIndex)
|
||||
: this(configRepo, logger, cache, proIndex, TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam — a clock the test moves across <see cref="RefusalConfirmInterval"/>.</summary>
|
||||
internal TenantConfigurationService(
|
||||
IConfigRepository configRepo,
|
||||
ILogger<TenantConfigurationService> logger,
|
||||
IMemoryCache cache,
|
||||
ManagedTenantProIndex proIndex,
|
||||
TimeProvider time)
|
||||
{
|
||||
_configRepo = configRepo;
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
_proIndex = proIndex;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam — no delegation index: no tenant is ever projected as managed. Production DI always
|
||||
/// resolves the four-argument constructor (the longest satisfiable one).
|
||||
/// resolves the four-parameter constructor (the longest public one it can satisfy).
|
||||
/// </summary>
|
||||
public TenantConfigurationService(IConfigRepository configRepo, ILogger<TenantConfigurationService> logger, IMemoryCache cache)
|
||||
: this(configRepo, logger, cache, ManagedTenantProIndex.None)
|
||||
@@ -320,12 +342,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
/// <summary>
|
||||
/// Cache-BYPASSING read (refreshes this instance's cache on hit); null when no row exists,
|
||||
/// never auto-creates. For read-modify-write side-effect writers that persist the WHOLE
|
||||
/// entity (e.g. AuthFunction's LastAuthClientId tracking): a cached view can be up to
|
||||
/// 5 minutes stale on OTHER instances, and blindly saving it would silently rewind any
|
||||
/// field another writer just changed — most critically HomedAppClientId right after a
|
||||
/// consent-driven app-homing flip (the exact login that follows a flip is the one that
|
||||
/// carries a changed audience and triggers the tracking write).
|
||||
/// never auto-creates. A write clears only the cache of the instance that served it, so a
|
||||
/// cached view can be up to 5 minutes stale on every OTHER instance. Callers that must not
|
||||
/// act on such a view read here: a verdict on the tenant's current state (activation), and
|
||||
/// the two wrappers below — <see cref="RefreshFromStoreAsync"/> for a request that follows
|
||||
/// its caller's own write, <see cref="ConfirmBeforeRefusalAsync"/> before a refusal.
|
||||
/// </summary>
|
||||
public virtual async Task<TenantConfiguration?> GetConfigurationFreshAsync(string tenantId)
|
||||
{
|
||||
@@ -339,6 +360,75 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return await ProjectAsync(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the stored row into this instance's cache, so that everything the current request
|
||||
/// reads afterwards sees it. For a request whose caller has just written the configuration
|
||||
/// through another instance. Never throws: on a storage failure the cached copy stays in use
|
||||
/// and the request goes on.
|
||||
/// </summary>
|
||||
public virtual async Task RefreshFromStoreAsync(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await GetConfigurationFreshAsync(tenantId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Stored configuration of tenant {TenantId} could not be re-read; the cached copy stays in use", tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The stored row for a caller about to refuse a request because of the configuration, or
|
||||
/// null when the cached answer stands. A refusal is never decided on a copy another
|
||||
/// instance's write has outdated: an admin allows a manufacturer or switches a validation on,
|
||||
/// and the next device must not be turned away for up to 5 minutes. Requests that pass never
|
||||
/// come here, so the hot path keeps reading the cache.
|
||||
/// <para>
|
||||
/// Null within <see cref="RefusalConfirmInterval"/> of the last read for this tenant (by then
|
||||
/// this instance's cache holds that read), when no row exists, and when the read fails — the
|
||||
/// caller then refuses as the cached copy says.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public virtual async Task<TenantConfiguration?> ConfirmBeforeRefusalAsync(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
return null;
|
||||
|
||||
// The factories can run more than once under contention; each run decides `claimed` anew.
|
||||
var now = _time.GetUtcNow();
|
||||
var claimed = false;
|
||||
_refusalConfirmedAt.AddOrUpdate(
|
||||
tenantId,
|
||||
_ =>
|
||||
{
|
||||
claimed = true;
|
||||
return now;
|
||||
},
|
||||
(_, last) =>
|
||||
{
|
||||
claimed = now - last >= RefusalConfirmInterval;
|
||||
return claimed ? now : last;
|
||||
});
|
||||
if (!claimed)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return await GetConfigurationFreshAsync(tenantId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Stored configuration of tenant {TenantId} could not be read before a refusal; the cached copy decides", tenantId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns (config, exists). exists=false when no row was found — does NOT auto-create.
|
||||
/// Use for agent security gates where unknown tenants must be rejected: storage errors are
|
||||
|
||||
+8
-8
@@ -16,7 +16,7 @@
|
||||
"compression": "^1.8.2",
|
||||
"express": "^5.3.0",
|
||||
"fuse.js": "^7.5.0",
|
||||
"jose": "^6.2.14",
|
||||
"jose": "^6.2.15",
|
||||
"undici": "^8.12.3",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
@@ -30,7 +30,7 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.5",
|
||||
"eslint": "^9.39.5",
|
||||
"knip": "^6.44.0",
|
||||
"knip": "^6.44.1",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.72.1",
|
||||
@@ -4289,9 +4289,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.14",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.14.tgz",
|
||||
"integrity": "sha512-3JasLuki1NRLQvY4wCoC37KZk4qxoBx2k2aFqLxT84IezqW0bkJKnNtxnZj/Fu1xsQivoc/lytxaRHJoGLjJ1A==",
|
||||
"version": "6.2.15",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.15.tgz",
|
||||
"integrity": "sha512-qdF6ewcZARtef63fJ9AKKZPgxw+t7USH6HLXYcD4CrdmSvDp79rpJSPFEKc90QBeGS63u1KQtN2MZQHC0yDIzA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
@@ -4351,9 +4351,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/knip": {
|
||||
"version": "6.44.0",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.44.0.tgz",
|
||||
"integrity": "sha512-mT0fl71Mqrko91azUO2Jw/uhYKwldORKT4liRq6yV0ZAtt6G2eYusdnvx8eBSQJDjvhRGKkpKUUx+vUG6syV4w==",
|
||||
"version": "6.44.1",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.44.1.tgz",
|
||||
"integrity": "sha512-Hbd8nvkIsNgFfXgC7Jsb5LHv7jj6VwA3tmfHd53XPX2z9Vc9RnAZqV0BIiP4OrtYqKwCoLseFHQZerb1bNlbSw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"compression": "^1.8.2",
|
||||
"express": "^5.3.0",
|
||||
"fuse.js": "^7.5.0",
|
||||
"jose": "^6.2.14",
|
||||
"jose": "^6.2.15",
|
||||
"undici": "^8.12.3",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
@@ -40,7 +40,7 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.5",
|
||||
"eslint": "^9.39.5",
|
||||
"knip": "^6.44.0",
|
||||
"knip": "^6.44.1",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.72.1",
|
||||
|
||||
@@ -59,6 +59,13 @@ describe('get_tenant_config — forced redaction pin', () => {
|
||||
const path = apiFetchMock.mock.calls[0][0] as string;
|
||||
expect(path).toBe(`/api/config/${TENANT}?view=redacted`);
|
||||
});
|
||||
|
||||
it('asks for the stored row — the read that checks update_tenant_config must not come from an instance cache', async () => {
|
||||
apiFetchMock.mockResolvedValueOnce({ tenantId: TENANT });
|
||||
await captureToolHandlers(true, true).get_tenant_config({ tenantId: TENANT });
|
||||
|
||||
expect(apiFetchMock.mock.calls[0][1]).toEqual({ headers: { 'X-Tenant-Config': 'stored' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('get_tenant_config_schema — request shape', () => {
|
||||
|
||||
@@ -2445,6 +2445,8 @@ export interface GetScriptDisplayNamesResponse {
|
||||
refs: Record<string, string | null>;
|
||||
/** Ref tokens from the request that failed to parse, or null when there were none — the key is omitted when null (the empty-body and empty-refs early-exit sites never wrote this key at all; they leave it null). */
|
||||
malformed?: string[];
|
||||
/** True when the lookup ran into one of its time limits before every ref had an answer, so asking again can return more names. Null otherwise — the key is omitted. A failed Graph call does not set it. */
|
||||
timedOut?: boolean;
|
||||
}
|
||||
|
||||
/** Response of GET sessions/{sessionId}/annotations: the annotation lanes visible to the caller plus the server-computed list of lanes the caller may write. */
|
||||
@@ -4755,6 +4757,8 @@ export interface SaveNotificationEmailRequest {
|
||||
/** Body of POST tenants/{tenantId}/scripts/display-names: Graph script references to resolve. */
|
||||
export interface ScriptDisplayNamesRequest {
|
||||
refs?: string[];
|
||||
/** True on the one follow-up lookup a client sends after a response with timedOut. The lookup itself runs the same; the flag only labels the request. */
|
||||
retry?: boolean | null;
|
||||
}
|
||||
|
||||
/** SearchSessions envelope. Sessions carries full SessionSummary items, or dictionary projections of them when the caller passed a fields= subset. */
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1818,7 +1818,11 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
// view=redacted is load-bearing: without it the backend serves a GA the
|
||||
// clear-text secrets, which must never enter model context (pinned by
|
||||
// security-guards tests).
|
||||
const data = await apiFetch<TenantConfiguration>(`/api/config/${encodeURIComponent(args.tenantId)}?view=redacted`);
|
||||
// X-Tenant-Config asks for the stored row: the backend caches a configuration per
|
||||
// instance, and this read is how a caller checks what update_tenant_config just wrote.
|
||||
const data = await apiFetch<TenantConfiguration>(
|
||||
`/api/config/${encodeURIComponent(args.tenantId)}?view=redacted`,
|
||||
{ headers: { 'X-Tenant-Config': 'stored' } });
|
||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||
} catch (error: unknown) {
|
||||
return toolError('get_tenant_config', args, error);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -47,6 +47,14 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// channel is exactly what was refused.
|
||||
/// </summary>
|
||||
TelemetryRejectedSessionUnknown = 5,
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/agent/register-session answered 410 Gone at agent start: the session this
|
||||
/// device resumes was deleted or is being deleted. The agent ends monitoring and removes
|
||||
/// itself; this single report is the last thing the device says about the session. A
|
||||
/// WhiteGlove Part 2 start registers a new session instead and sends no report.
|
||||
/// </summary>
|
||||
RegistrationRejectedSessionGone = 6,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user