mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-11 04:09:51 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7055a6f0fc | |||
| 587de860dd | |||
| 409794c037 | |||
| 337b3b012a | |||
| c2ae5ddd83 | |||
| 60baf5317c | |||
| a5d5a37dc1 | |||
| d4b56c417d | |||
| a5bbdf9f80 | |||
| 389d4f27dc | |||
| ce5f22bd19 | |||
| 85fadc8ac4 | |||
| 7633c02a1c | |||
| bd66ad7097 | |||
| be13d8f5ef | |||
| 8296fa7291 | |||
| 4faca8692b | |||
| ba715d5aa7 | |||
| ed73dc3a87 | |||
| 1e88d1db4b | |||
| f3778fbad6 | |||
| 1a85a75af1 | |||
| 5f13c71f1d | |||
| 041803e55c | |||
| eff89fd09f | |||
| 0cc35d2bae | |||
| 27cb286118 | |||
| c88e5411a8 | |||
| 5eba39f658 | |||
| 5f95648c73 | |||
| e4d527b6d5 | |||
| 258d7cd5f4 | |||
| 668ae14657 | |||
| 16bd82b148 |
+1
-1
Submodule .claude/commands updated: dbacde8e0d...3699da8230
+1
-1
Submodule internal updated: 1a3979773f...e0913c34d2
@@ -2,10 +2,10 @@
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ID-002",
|
||||
"title": "Unexpected Local Admin Accounts Detected",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts on the device, including accounts allowed only as standard users that hold local admin rights. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -65,6 +65,18 @@
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "role_violations",
|
||||
"source": "event_data",
|
||||
"eventType": "local_admin_analysis",
|
||||
"dataField": "checks.role_violations",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"filterField": "finding",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
@@ -80,7 +92,7 @@
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 50,
|
||||
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
|
||||
"explanation": "The agent detected unexpected local user accounts on this device: accounts the built-in or tenant-configured allowed list does not allow, or accounts it allows only as standard users that are members of the local Administrators group.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not allowed by the list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), role_violations (accounts allowed only as standard users that are members of Administrators — Windows never puts Guest, kioskUser0 or WsiAccount there), account_details (per account: disabled state, Administrators membership and RID — 500 is the built-in Administrator), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list under Settings → Agent Analyzers. Choose Standard user only for an account that must never be a local administrator.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review the unexpected accounts",
|
||||
@@ -94,7 +106,8 @@
|
||||
"title": "Add expected accounts to the allowed list (false positive)",
|
||||
"steps": [
|
||||
"Go to Settings → Agent Analyzers section",
|
||||
"Under 'Allowed Admin Accounts', add the account names that are expected in your environment",
|
||||
"Under 'Allowed Local Accounts', add the account names that are expected in your environment",
|
||||
"Choose 'Standard user only' for an account that must not be a local administrator — it is still reported if it ever joins the Administrators group",
|
||||
"Save the settings — future enrollments will no longer flag these accounts"
|
||||
]
|
||||
},
|
||||
@@ -104,6 +117,7 @@
|
||||
"Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)",
|
||||
"Review the enrollment timeline for Shift+F10 or other OOBE escape indicators",
|
||||
"Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)",
|
||||
"Check 'role_violations' — a built-in Windows account or an account you allowed as standard user that sits in Administrators was put there on purpose",
|
||||
"Consider re-imaging the device if unauthorized admin access is confirmed"
|
||||
]
|
||||
}
|
||||
@@ -126,6 +140,11 @@
|
||||
"label": "In Administrators",
|
||||
"value": "{{unexpected_admin_members.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Allowed only as standard user",
|
||||
"value": "{{role_violations.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+23
-4
@@ -4103,10 +4103,10 @@
|
||||
{
|
||||
"ruleId": "ANALYZE-ID-002",
|
||||
"title": "Unexpected Local Admin Accounts Detected",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts on the device, including accounts allowed only as standard users that hold local admin rights. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -4166,6 +4166,18 @@
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "role_violations",
|
||||
"source": "event_data",
|
||||
"eventType": "local_admin_analysis",
|
||||
"dataField": "checks.role_violations",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"filterField": "finding",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
@@ -4181,7 +4193,7 @@
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 50,
|
||||
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
|
||||
"explanation": "The agent detected unexpected local user accounts on this device: accounts the built-in or tenant-configured allowed list does not allow, or accounts it allows only as standard users that are members of the local Administrators group.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not allowed by the list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), role_violations (accounts allowed only as standard users that are members of Administrators — Windows never puts Guest, kioskUser0 or WsiAccount there), account_details (per account: disabled state, Administrators membership and RID — 500 is the built-in Administrator), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list under Settings → Agent Analyzers. Choose Standard user only for an account that must never be a local administrator.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review the unexpected accounts",
|
||||
@@ -4195,7 +4207,8 @@
|
||||
"title": "Add expected accounts to the allowed list (false positive)",
|
||||
"steps": [
|
||||
"Go to Settings → Agent Analyzers section",
|
||||
"Under 'Allowed Admin Accounts', add the account names that are expected in your environment",
|
||||
"Under 'Allowed Local Accounts', add the account names that are expected in your environment",
|
||||
"Choose 'Standard user only' for an account that must not be a local administrator — it is still reported if it ever joins the Administrators group",
|
||||
"Save the settings — future enrollments will no longer flag these accounts"
|
||||
]
|
||||
},
|
||||
@@ -4205,6 +4218,7 @@
|
||||
"Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)",
|
||||
"Review the enrollment timeline for Shift+F10 or other OOBE escape indicators",
|
||||
"Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)",
|
||||
"Check 'role_violations' — a built-in Windows account or an account you allowed as standard user that sits in Administrators was put there on purpose",
|
||||
"Consider re-imaging the device if unauthorized admin access is confirmed"
|
||||
]
|
||||
}
|
||||
@@ -4227,6 +4241,11 @@
|
||||
"label": "In Administrators",
|
||||
"value": "{{unexpected_admin_members.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Allowed only as standard user",
|
||||
"value": "{{role_violations.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>tests/fixtures/local-admin-allow-list/cases.json</c> run against the netstandard2.0 build of
|
||||
/// <see cref="LocalAdminAllowList"/> — the one the agent ships. The backend runs the same file against
|
||||
/// its net10.0 build, the portal against <c>allowedAccounts.ts</c>.
|
||||
/// </summary>
|
||||
public sealed class LocalAdminAllowListFixtureTests
|
||||
{
|
||||
private static IEnumerable<JToken> ParseCases()
|
||||
{
|
||||
var path = Path.Combine(RepoPaths.Fixtures("local-admin-allow-list"), "cases.json");
|
||||
return JObject.Parse(File.ReadAllText(path))["parse"]!;
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> ParseEntries() => ParseCases().Select(c => new object[] { c.Value<string>("entry")! });
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ParseEntries))]
|
||||
public void Agent_parses_the_entry_as_the_shared_case_says(string entry)
|
||||
{
|
||||
var c = ParseCases().First(x => x.Value<string>("entry") == entry);
|
||||
|
||||
var parsed = LocalAdminAllowList.TryParseEntry(entry, out var pattern, out var role);
|
||||
|
||||
if (c.Value<bool?>("ignored") == true)
|
||||
{
|
||||
Assert.False(parsed);
|
||||
return;
|
||||
}
|
||||
Assert.True(parsed);
|
||||
Assert.Equal(c.Value<string>("pattern"), pattern);
|
||||
Assert.Equal(c.Value<string>("role") == "user" ? LocalAccountRole.StandardUser : LocalAccountRole.Any, role);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-21
@@ -18,10 +18,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
private const string Machine = "DESKTOP-TEST01";
|
||||
private const string MachineSidPrefix = "S-1-5-21-111-222-333-";
|
||||
|
||||
private static readonly List<string> Allowed = new List<string>
|
||||
{
|
||||
"Administrator", "Guest", "DefaultAccount", "WDAGUtilityAccount", "defaultuser0", "defaultuser1", "adm-*"
|
||||
};
|
||||
// The real built-in catalog plus one tenant glob.
|
||||
private static readonly List<LocalAdminAnalyzer.AllowedEntry> Allowed =
|
||||
LocalAdminAnalyzer.BuildAllowedEntries(new[] { "adm-*" }, new List<string>());
|
||||
|
||||
private static LocalAdminAnalyzer.LocalAccountInfo Account(string name, bool disabled, int rid) =>
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = name, Disabled = disabled, Sid = MachineSidPrefix + rid };
|
||||
@@ -126,7 +125,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
[Fact]
|
||||
public void Local_admin_member_missing_from_the_wmi_inventory_is_still_flagged()
|
||||
{
|
||||
// WMI failed or did not list the account — the SAM group membership alone is enough.
|
||||
// WMI failed or did not list the account — the SAM group membership alone is enough,
|
||||
// and the account counts as unexpected too, like any unknown administrator (score 80).
|
||||
var members = new List<LocalGroupMember>
|
||||
{
|
||||
LocalMember("Administrator", 500),
|
||||
@@ -136,8 +136,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
var result = LocalAdminAnalyzer.EvaluateAccounts(
|
||||
new List<LocalAdminAnalyzer.LocalAccountInfo>(), members, true, Machine, Allowed);
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -191,26 +192,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Well_known_rid_does_not_cover_ordinary_accounts_or_foreign_sids()
|
||||
public void Machine_account_rid_is_read_only_from_local_machine_sids()
|
||||
{
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "500"));
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "501"));
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "503"));
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "504"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "502"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "1500"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "1001"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid("S-1-12-1-1-2-3-500"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid("S-1-5-32-544"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(null));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(""));
|
||||
Assert.Equal(500, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "500"));
|
||||
Assert.Equal(501, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "501"));
|
||||
Assert.Equal(1001, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "1001"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid("S-1-12-1-1-2-3-500"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid("S-1-5-32-544"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "x1"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(null!));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_named_like_a_builtin_but_with_an_ordinary_rid_is_still_matched_by_name_only()
|
||||
public void Localized_builtin_name_on_an_ordinary_rid_is_not_allowed()
|
||||
{
|
||||
// Name-based allowance is unchanged: the RID match only ADDS coverage.
|
||||
// Only the RID makes a built-in: an ordinary account merely named like a localized
|
||||
// built-in is no entry at all.
|
||||
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
|
||||
{
|
||||
Account("Gast", disabled: false, rid: 1001)
|
||||
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
{
|
||||
/// <summary>
|
||||
/// Roles on the allowed list: <c>name|user</c> and the standard-user built-ins (Guest,
|
||||
/// DefaultAccount, WDAGUtilityAccount by RID; kioskUser0, WsiAccount and other accounts carrying a
|
||||
/// built-in name by name) allow an account only while it is not an Administrators member. A
|
||||
/// restriction always wins; the four built-in accounts are decided by RID alone; profile folders
|
||||
/// have no role; a login allows a profile folder, never an account. All names are synthetic.
|
||||
/// </summary>
|
||||
public sealed class LocalAdminAnalyzerRoleTests
|
||||
{
|
||||
private const string Machine = "DESKTOP-TEST01";
|
||||
private const string MachineSidPrefix = "S-1-5-21-111-222-333-";
|
||||
|
||||
private static List<LocalAdminAnalyzer.AllowedEntry> Entries(params string[] tenantEntries)
|
||||
=> LocalAdminAnalyzer.BuildAllowedEntries(tenantEntries, new List<string>());
|
||||
|
||||
private static LocalAdminAnalyzer.LocalAccountInfo Account(string name, int rid, bool disabled = false) =>
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = name, Disabled = disabled, Sid = MachineSidPrefix + rid };
|
||||
|
||||
private static LocalGroupMember Member(string name, int rid) =>
|
||||
new LocalGroupMember { DomainAndName = Machine + "\\" + name, Sid = MachineSidPrefix + rid, SidUsage = 1 };
|
||||
|
||||
private static LocalAdminAnalyzer.LocalAccountCheckResult Evaluate(
|
||||
IList<LocalAdminAnalyzer.LocalAccountInfo> accounts, IList<LocalGroupMember> admins, List<LocalAdminAnalyzer.AllowedEntry> entries,
|
||||
bool enumerated = true)
|
||||
=> LocalAdminAnalyzer.EvaluateAccounts(accounts, admins, enumerated, Machine, entries);
|
||||
|
||||
// -------------------------------------------------------------- WsiAccount (the field case)
|
||||
|
||||
[Fact]
|
||||
public void WsiAccount_as_standard_user_with_its_profile_folder_scores_zero()
|
||||
{
|
||||
// The field case: web sign-in enabled the account for the sign-in, it holds no admin
|
||||
// membership and has C:\Users\WsiAccount — before roles existed this scored 80 (Error).
|
||||
var accounts = new[]
|
||||
{
|
||||
Account("Administrator", 500, disabled: true),
|
||||
Account("DefaultAccount", 503, disabled: true),
|
||||
Account("defaultuser0", 1000, disabled: true),
|
||||
Account("Gast", 501, disabled: true),
|
||||
Account("WDAGUtilityAccount", 504, disabled: true),
|
||||
Account("WsiAccount", 1001, disabled: true),
|
||||
Account("ADM-0A1B2C3D", 1002),
|
||||
};
|
||||
var admins = new List<LocalGroupMember>
|
||||
{
|
||||
Member("Administrator", 500),
|
||||
new LocalGroupMember { DomainAndName = "S-1-12-1-11-22-33-44", Sid = "S-1-12-1-11-22-33-44", SidUsage = 8 },
|
||||
Member("ADM-0A1B2C3D", 1002),
|
||||
};
|
||||
|
||||
var result = Evaluate(accounts, admins, Entries("ADM-*"));
|
||||
var score = LocalAdminAnalyzer.ComputeConfidenceScore(
|
||||
bypassNro: false, result.Unexpected, result.UnexpectedAdminMembers, new List<string> { "jane.doe" });
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Empty(result.UnexpectedAdminMembers);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
Assert.Equal(0, score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WsiAccount_in_administrators_is_a_role_violation_even_while_disabled()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("WsiAccount", 1001, disabled: true) },
|
||||
new List<LocalGroupMember> { Member("WsiAccount", 1001) },
|
||||
Entries());
|
||||
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.UnexpectedAdminMembers);
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.RoleViolations);
|
||||
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(
|
||||
false, result.Unexpected, result.UnexpectedAdminMembers, new List<string>()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("WsiAccount")]
|
||||
[InlineData("wsiaccount")]
|
||||
[InlineData("Wsi*")]
|
||||
[InlineData("*")]
|
||||
public void Tenant_entries_cannot_lift_a_builtin_restriction(string tenantEntry)
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("WsiAccount", 1001) },
|
||||
new List<LocalGroupMember> { Member("WsiAccount", 1001) },
|
||||
Entries(tenantEntry));
|
||||
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KioskUser0_in_administrators_is_a_role_violation()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("kioskUser0", 1003) },
|
||||
new List<LocalGroupMember> { Member("kioskUser0", 1003) },
|
||||
Entries());
|
||||
|
||||
Assert.Equal(new[] { "kioskUser0" }, result.RoleViolations);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- built-ins by RID
|
||||
|
||||
[Theory]
|
||||
[InlineData("Guest", 501)]
|
||||
[InlineData("Gast", 501)]
|
||||
[InlineData("Invité", 501)]
|
||||
[InlineData("DefaultAccount", 503)]
|
||||
[InlineData("WDAGUtilityAccount", 504)]
|
||||
public void Builtin_accounts_that_are_never_administrators_are_flagged_as_one(string name, int rid)
|
||||
{
|
||||
// A tenant entry naming the localized account (or even "*") cannot lift it either.
|
||||
var result = Evaluate(
|
||||
new[] { Account(name, rid, disabled: true) },
|
||||
new List<LocalGroupMember> { Member(name, rid) },
|
||||
Entries(name, "*"));
|
||||
|
||||
Assert.Equal(new[] { name }, result.Unexpected);
|
||||
Assert.Equal(new[] { name }, result.UnexpectedAdminMembers);
|
||||
Assert.Equal(new[] { name }, result.RoleViolations);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Administrator")]
|
||||
[InlineData("Administrateur")]
|
||||
[InlineData("Renamed_Admin")]
|
||||
public void The_builtin_administrator_is_allowed_by_rid_under_any_name(string name)
|
||||
{
|
||||
// "Admin*|user" must not turn the real Administrator into a finding: name entries never
|
||||
// decide the RID accounts — and the built-in name entry "Administrator|user" neither.
|
||||
var result = Evaluate(
|
||||
new[] { Account(name, 500) },
|
||||
new List<LocalGroupMember> { Member(name, 500) },
|
||||
Entries("Admin*|user", "Renamed*|user"));
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
Assert.True(result.BuiltInAdministratorEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_ordinary_account_named_administrator_is_allowed_only_as_a_decoy()
|
||||
{
|
||||
// French device: the RID-500 account is "Administrateur", so "Administrator" is free.
|
||||
var accounts = new[]
|
||||
{
|
||||
Account("Administrateur", 500, disabled: true),
|
||||
Account("Administrator", 1004),
|
||||
};
|
||||
|
||||
var decoy = Evaluate(accounts, new List<LocalGroupMember> { Member("Administrateur", 500) }, Entries());
|
||||
Assert.Empty(decoy.Unexpected);
|
||||
|
||||
var impostor = Evaluate(accounts,
|
||||
new List<LocalGroupMember> { Member("Administrateur", 500), Member("Administrator", 1004) }, Entries());
|
||||
Assert.Equal(new[] { "Administrator" }, impostor.RoleViolations);
|
||||
Assert.Equal(new[] { "Administrator" }, impostor.UnexpectedAdminMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_oobe_account_may_be_an_administrator()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("defaultuser0", 1000) },
|
||||
new List<LocalGroupMember> { Member("defaultuser0", 1000) },
|
||||
Entries());
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_missing_sid_lets_the_english_builtin_name_stand_in_for_the_rid()
|
||||
{
|
||||
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
|
||||
{
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = "Administrator", Disabled = false, Sid = null! },
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = "Guest", Disabled = false, Sid = null! },
|
||||
};
|
||||
var admins = new List<LocalGroupMember>
|
||||
{
|
||||
new LocalGroupMember { DomainAndName = Machine + "\\Administrator", Sid = null!, SidUsage = 1 },
|
||||
new LocalGroupMember { DomainAndName = Machine + "\\Guest", Sid = null!, SidUsage = 1 },
|
||||
};
|
||||
|
||||
var result = Evaluate(accounts, admins, Entries());
|
||||
|
||||
Assert.Equal(new[] { "Guest" }, result.RoleViolations);
|
||||
Assert.DoesNotContain("Administrator", result.Unexpected);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- tenant roles
|
||||
|
||||
[Fact]
|
||||
public void A_standard_user_entry_allows_the_account_until_it_is_an_administrator()
|
||||
{
|
||||
var accounts = new[] { Account("frontdesk", 1005) };
|
||||
|
||||
Assert.Empty(Evaluate(accounts, new List<LocalGroupMember>(), Entries("frontdesk|user")).Unexpected);
|
||||
|
||||
var admin = Evaluate(accounts, new List<LocalGroupMember> { Member("frontdesk", 1005) }, Entries("frontdesk|user"));
|
||||
Assert.Equal(new[] { "frontdesk" }, admin.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_standard_user_glob_restricts_each_matching_account()
|
||||
{
|
||||
var accounts = new[] { Account("svc-print", 1006), Account("svc-scan", 1007) };
|
||||
|
||||
var result = Evaluate(accounts, new List<LocalGroupMember> { Member("svc-scan", 1007) }, Entries("svc-*|user"));
|
||||
|
||||
Assert.Equal(new[] { "svc-scan" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "svc-scan" }, result.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_restriction_wins_over_a_broader_entry_that_allows_more()
|
||||
{
|
||||
var accounts = new[] { Account("adm-kiosk", 1008), Account("adm-4711", 1009) };
|
||||
var admins = new List<LocalGroupMember> { Member("adm-kiosk", 1008), Member("adm-4711", 1009) };
|
||||
|
||||
var result = Evaluate(accounts, admins, Entries("adm-*", "adm-kiosk|user"));
|
||||
|
||||
Assert.Equal(new[] { "adm-kiosk" }, result.RoleViolations);
|
||||
Assert.DoesNotContain("adm-4711", result.Unexpected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Membership_unknown_lets_a_standard_user_entry_act_like_a_plain_one()
|
||||
{
|
||||
// Enumeration failed: nobody is known to be an administrator, so nothing is a violation.
|
||||
var result = Evaluate(new[] { Account("WsiAccount", 1001) }, new List<LocalGroupMember>(), Entries(), enumerated: false);
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.False(result.AdministratorsGroupEnumerated);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- folders are no accounts
|
||||
|
||||
[Fact]
|
||||
public void An_account_named_like_a_profile_folder_is_unexpected_while_the_folder_is_allowed()
|
||||
{
|
||||
var entries = Entries();
|
||||
|
||||
var result = Evaluate(new[] { Account("Public", 1010) }, new List<LocalGroupMember> { Member("Public", 1010) }, entries);
|
||||
|
||||
Assert.Equal(new[] { "Public" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "Public" }, result.UnexpectedAdminMembers);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
Assert.Equal(LocalAdminAnalyzer.AccountDecision.Unlisted,
|
||||
LocalAdminAnalyzer.Decide("Public", MachineSidPrefix + "1010", isAdmin: false, entries));
|
||||
Assert.Contains(entries, e => e.Pattern == "Public" && e.ProfileFolderOnly);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- accounts WMI did not return
|
||||
|
||||
[Fact]
|
||||
public void Administrators_members_missing_from_wmi_are_judged_as_administrators()
|
||||
{
|
||||
var members = new List<LocalGroupMember>
|
||||
{
|
||||
Member("Administrator", 500),
|
||||
Member("WsiAccount", 1001),
|
||||
Member("Gast", 501),
|
||||
Member("adm-4711", 1009),
|
||||
};
|
||||
|
||||
var result = Evaluate(new List<LocalAdminAnalyzer.LocalAccountInfo>(), members, Entries("adm-*"));
|
||||
|
||||
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.RoleViolations);
|
||||
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.UnexpectedAdminMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_member_wmi_already_returned_is_not_judged_twice()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("backdoor", 1011) },
|
||||
new List<LocalGroupMember> { Member("backdoor", 1011) },
|
||||
Entries());
|
||||
|
||||
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_login_never_allows_an_account_not_even_when_wmi_failed()
|
||||
{
|
||||
// WMI failed: the split cannot recognise "backdoor" as local and allows it dynamically.
|
||||
// The dynamic entry is folder-only, so the signed-in backdoor administrator is still found.
|
||||
var split = LocalAdminAnalyzer.SplitLoggedInUsers(new[] { "backdoor" }, null!);
|
||||
var entries = Entries();
|
||||
entries.AddRange(split.Allowed.Select(u => new LocalAdminAnalyzer.AllowedEntry(u, LocalAccountRole.Any, profileFolderOnly: true)));
|
||||
|
||||
var result = Evaluate(new List<LocalAdminAnalyzer.LocalAccountInfo>(), new List<LocalGroupMember> { Member("backdoor", 1011) }, entries);
|
||||
|
||||
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
|
||||
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- the entry list
|
||||
|
||||
[Fact]
|
||||
public void Entries_carry_the_builtins_and_skip_invalid_tenant_entries()
|
||||
{
|
||||
var ignored = new List<string>();
|
||||
var entries = LocalAdminAnalyzer.BuildAllowedEntries(
|
||||
new[] { "kiosk|user", "x|admin", "|user", "legacy*", "CONTOSO\\admin", " ", "Guest|user" }, ignored);
|
||||
|
||||
Assert.Equal(new[] { "x|admin", "|user" }, ignored);
|
||||
var accounts = entries.Where(e => !e.ProfileFolderOnly).Select(e => e.Display).ToList();
|
||||
Assert.Contains("WsiAccount|user", accounts);
|
||||
Assert.Contains("kioskUser0|user", accounts);
|
||||
Assert.Contains("defaultuser0", accounts);
|
||||
Assert.Contains("kiosk|user", accounts);
|
||||
Assert.Contains("legacy*", accounts);
|
||||
Assert.Contains("CONTOSO\\admin", accounts);
|
||||
Assert.Single(accounts, a => a == "Guest|user");
|
||||
Assert.DoesNotContain("Public", accounts);
|
||||
Assert.Contains(entries, e => e.Pattern == "All Users" && e.ProfileFolderOnly);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- scoring
|
||||
|
||||
[Fact]
|
||||
public void Score_follows_the_signals()
|
||||
{
|
||||
var none = new List<string>();
|
||||
var backdoor = new List<string> { "backdoor" };
|
||||
|
||||
Assert.Equal(0, LocalAdminAnalyzer.ComputeConfidenceScore(false, none, none, backdoor));
|
||||
Assert.Equal(20, LocalAdminAnalyzer.ComputeConfidenceScore(true, none, none, none));
|
||||
Assert.Equal(40, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, none, none));
|
||||
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, backdoor, none));
|
||||
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, none, new List<string> { "BACKDOOR" }));
|
||||
Assert.Equal(100, LocalAdminAnalyzer.ComputeConfidenceScore(true, backdoor, backdoor, backdoor));
|
||||
}
|
||||
}
|
||||
}
|
||||
+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;
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
#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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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.
|
||||
|
||||
+237
-127
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Management;
|
||||
@@ -36,21 +37,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// (<c>builtin_administrator_enabled</c>) but not scored: whether OOBE enables it
|
||||
/// transiently is not established, so the field first collects evidence.
|
||||
///
|
||||
/// Built-in accounts are recognised by well-known RID (500/501/503/504 on the machine SID)
|
||||
/// in addition to their English names, so localized installs (Gast, Invité, Administrateur)
|
||||
/// do not flag their own disabled Guest/Administrator.
|
||||
/// Allowed list (<see cref="LocalAdminAllowList"/> built-ins plus the tenant's entries): an entry
|
||||
/// allows an account in any role, or — <c>name|user</c> — only while it is not an
|
||||
/// Administrators member. A restriction always wins over an entry that allows more. The four
|
||||
/// built-in accounts are decided by their well-known RID alone (500 any role; 501/503/504 never
|
||||
/// an administrator), so localized names (Gast, Administrateur) are recognised and no name entry
|
||||
/// can lift or impose a role on them. Profile folders have no role.
|
||||
///
|
||||
/// At shutdown the logged-in users are allowed dynamically — but only identities that are
|
||||
/// NOT local SAM accounts (Entra / domain users, whose profile folder is the only artifact
|
||||
/// they leave). A logged-in local account is exactly the delta this analyzer exists to
|
||||
/// catch and is never exempted by its own login (CWE-807: the login state is under the
|
||||
/// At shutdown the logged-in users are allowed dynamically — as profile folders only, and only
|
||||
/// identities that are NOT local SAM accounts (Entra / domain users, whose profile folder is the
|
||||
/// only artifact they leave). A logged-in local account is exactly the delta this analyzer exists
|
||||
/// to catch and is never exempted by its own login (CWE-807: the login state is under the
|
||||
/// adversary's control); it is reported as <c>logged_in_local_accounts</c>.
|
||||
///
|
||||
/// Emits a single "local_admin_analysis" event at startup and at shutdown,
|
||||
/// enabling delta detection between pre- and post-enrollment state.
|
||||
///
|
||||
/// Tenant-supplied allowed accounts may contain glob wildcards (e.g. "adm-*");
|
||||
/// see <see cref="MatchesAllowedEntry"/>.
|
||||
/// </summary>
|
||||
public class LocalAdminAnalyzer : IAgentAnalyzer
|
||||
{
|
||||
@@ -58,26 +59,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly List<string> _allowedAccounts;
|
||||
|
||||
// Built-in accounts and profile folders always present on a freshly imaged Windows device.
|
||||
// "Public", "Default", "Default User", "All Users" are folders/junctions in C:\Users, not user accounts.
|
||||
// "defaultuser0" is a temporary OOBE/Autopilot system account created during enrollment.
|
||||
private static readonly List<string> BuiltInAllowedAccounts = new List<string>
|
||||
{
|
||||
"Administrator",
|
||||
"Guest",
|
||||
"DefaultAccount",
|
||||
"WDAGUtilityAccount",
|
||||
"defaultuser0", // Temporary OOBE/Autopilot system account, present during enrollment
|
||||
"defaultuser1", // Sometimes seen in OOBE, but not always present
|
||||
"defaultuser2", // Sometimes seen in OOBE, but not always present
|
||||
"kioskUser0", // Assigned Access (kiosk profile) autologon account, created by Windows
|
||||
"Public", // Profile folder (not a user account)
|
||||
"Default", // Default user profile template
|
||||
"Default User", // Symlink to Default in some Windows versions
|
||||
"All Users" // Junction pointing to C:\ProgramData, always present
|
||||
};
|
||||
private readonly List<AllowedEntry> _allowedEntries;
|
||||
|
||||
public string Name => "LocalAdminAnalyzer";
|
||||
|
||||
@@ -97,19 +79,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_startupGate = startupGate;
|
||||
|
||||
// Tenant-supplied accounts are additive (union with built-ins, not replacement)
|
||||
_allowedAccounts = new List<string>(BuiltInAllowedAccounts);
|
||||
if (allowedAccounts != null)
|
||||
{
|
||||
foreach (var account in allowedAccounts)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(account) &&
|
||||
!_allowedAccounts.Any(a => string.Equals(a, account, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_allowedAccounts.Add(account);
|
||||
}
|
||||
}
|
||||
}
|
||||
var ignored = new List<string>();
|
||||
_allowedEntries = BuildAllowedEntries(allowedAccounts, ignored);
|
||||
foreach (var entry in ignored)
|
||||
_logger.Warning($"{Name}: Allowed-list entry '{entry}' is not valid (expected 'name' or 'name|user') — ignored, it allows nothing");
|
||||
}
|
||||
|
||||
public void AnalyzeAtStartup()
|
||||
@@ -149,46 +122,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
foreach (var user in loggedIn.LocalAccounts)
|
||||
_logger.Warning($"{Name}: Logged-in user '{user}' is a local account — not allowed dynamically");
|
||||
|
||||
// Build effective allowed list: static + dynamic (non-local logged-in users at shutdown)
|
||||
var effectiveAllowed = _allowedAccounts;
|
||||
// Effective list: static entries + the profile folders of non-local logged-in users at
|
||||
// shutdown. Folder-only: a login never allows a local ACCOUNT — not even when the WMI
|
||||
// inventory failed and the split above could not recognise a local name.
|
||||
var effectiveEntries = _allowedEntries;
|
||||
if (loggedIn.Allowed.Count > 0)
|
||||
{
|
||||
effectiveAllowed = new List<string>(_allowedAccounts);
|
||||
effectiveEntries = new List<AllowedEntry>(_allowedEntries);
|
||||
foreach (var user in loggedIn.Allowed)
|
||||
{
|
||||
if (!effectiveAllowed.Any(a => string.Equals(a, user, StringComparison.OrdinalIgnoreCase)))
|
||||
if (!effectiveEntries.Any(e => string.Equals(e.Pattern, user, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
effectiveAllowed.Add(user);
|
||||
_logger.Info($"{Name}: Dynamically allowing logged-in user: {user}");
|
||||
effectiveEntries.Add(new AllowedEntry(user, LocalAccountRole.Any, profileFolderOnly: true));
|
||||
_logger.Info($"{Name}: Dynamically allowing profile folder of logged-in user: {user}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var bypassNroResult = CheckBypassNroRegistry();
|
||||
var accountsResult = CheckLocalAdminAccounts(localAccounts, effectiveAllowed);
|
||||
var profilesResult = CheckUserProfiles(effectiveAllowed);
|
||||
var accountsResult = CheckLocalAdminAccounts(localAccounts, effectiveEntries);
|
||||
var profilesResult = CheckUserProfiles(effectiveEntries);
|
||||
|
||||
int confidenceScore = 0;
|
||||
|
||||
if (bypassNroResult.Value == 1)
|
||||
confidenceScore += 20;
|
||||
|
||||
if (accountsResult.Unexpected.Count > 0)
|
||||
confidenceScore += 40;
|
||||
|
||||
// An unexpected account that is also a member of Administrators is the
|
||||
// actual backdoor (enabled or dormant) — high indicator on its own.
|
||||
if (accountsResult.UnexpectedAdminMembers.Count > 0)
|
||||
confidenceScore += 40;
|
||||
|
||||
// Profile overlap: unexpected account AND matching C:\Users folder
|
||||
bool profileOverlap = accountsResult.Unexpected.Any(a =>
|
||||
profilesResult.Unexpected.Any(p =>
|
||||
string.Equals(a, p, StringComparison.OrdinalIgnoreCase)));
|
||||
if (profileOverlap)
|
||||
confidenceScore += 40;
|
||||
|
||||
confidenceScore = Math.Min(confidenceScore, 100);
|
||||
var confidenceScore = ComputeConfidenceScore(
|
||||
bypassNroResult.Value == 1, accountsResult.Unexpected, accountsResult.UnexpectedAdminMembers, profilesResult.Unexpected);
|
||||
|
||||
EventSeverity severity;
|
||||
string findingLabel;
|
||||
@@ -219,6 +175,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
$"bypassNro={bypassNroResult.Value}, " +
|
||||
$"unexpectedAccounts={accountsResult.Unexpected.Count}, " +
|
||||
$"unexpectedAdminMembers={accountsResult.UnexpectedAdminMembers.Count}, " +
|
||||
$"roleViolations={accountsResult.RoleViolations.Count}, " +
|
||||
$"adminGroupEnumerated={accountsResult.AdministratorsGroupEnumerated}, " +
|
||||
$"builtinAdministratorEnabled={accountsResult.BuiltInAdministratorEnabled?.ToString() ?? "unknown"}, " +
|
||||
$"unexpectedProfiles={profilesResult.Unexpected.Count}");
|
||||
@@ -230,7 +187,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
{ "finding", findingLabel },
|
||||
{ "triggered_at", trigger },
|
||||
{ "enrollment_phase_at_check", phase.ToString() },
|
||||
{ "allowed_accounts", effectiveAllowed },
|
||||
// Account entries in canonical form ("Guest|user"); the folder-only entries are
|
||||
// constant or listed in dynamically_allowed_users.
|
||||
{ "allowed_accounts", _allowedEntries.Where(e => !e.ProfileFolderOnly).Select(e => e.Display).ToList() },
|
||||
{ "dynamically_allowed_users", loggedIn.Allowed },
|
||||
{ "logged_in_local_accounts", loggedIn.LocalAccounts },
|
||||
{ "checks", new Dictionary<string, object>
|
||||
@@ -243,6 +202,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
},
|
||||
{ "unexpected_accounts", accountsResult.Unexpected },
|
||||
{ "unexpected_admin_members", accountsResult.UnexpectedAdminMembers },
|
||||
{ "role_violations", accountsResult.RoleViolations },
|
||||
{ "unexpected_profiles", profilesResult.Unexpected },
|
||||
{ "accounts_checked", accountsResult.AllChecked },
|
||||
{ "account_details", accountsResult.AccountDetails },
|
||||
@@ -299,31 +259,53 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence 0-100: BypassNRO +20, any unexpected account +40, any unexpected Administrators
|
||||
/// member +40 (the actual backdoor, enabled or dormant; a role violation is one), an unexpected
|
||||
/// account with a C:\Users folder of the same name +40. Pure; internal for unit-testing.
|
||||
/// </summary>
|
||||
internal static int ComputeConfidenceScore(
|
||||
bool bypassNro, IList<string> unexpectedAccounts, IList<string> unexpectedAdminMembers, IList<string> unexpectedProfiles)
|
||||
{
|
||||
var score = 0;
|
||||
if (bypassNro)
|
||||
score += 20;
|
||||
if (unexpectedAccounts.Count > 0)
|
||||
score += 40;
|
||||
if (unexpectedAdminMembers.Count > 0)
|
||||
score += 40;
|
||||
if (unexpectedAccounts.Any(a => unexpectedProfiles.Any(p => string.Equals(a, p, StringComparison.OrdinalIgnoreCase))))
|
||||
score += 40;
|
||||
return Math.Min(score, 100);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Allowed-list matching
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Matches an account or profile-folder name against one allowed-list entry.
|
||||
/// Entries containing <c>*</c> or <c>?</c> are glob patterns (same semantics as the
|
||||
/// Matches an account or profile-folder name against one allowed-list pattern.
|
||||
/// Patterns containing <c>*</c> or <c>?</c> are globs (same semantics as the
|
||||
/// hardware whitelist: <c>*</c> = any run, <c>?</c> = one char, case-insensitive);
|
||||
/// all other entries match exactly. No ambiguity with literal names — <c>*</c> and
|
||||
/// all other patterns match exactly. No ambiguity with literal names — <c>*</c> and
|
||||
/// <c>?</c> are invalid characters in Windows account names.
|
||||
/// Internal for direct unit-testing via InternalsVisibleTo.
|
||||
/// </summary>
|
||||
internal static bool MatchesAllowedEntry(string name, string entry)
|
||||
internal static bool MatchesAllowedEntry(string name, string pattern)
|
||||
{
|
||||
if (entry.IndexOf('*') < 0 && entry.IndexOf('?') < 0)
|
||||
return string.Equals(entry, name, StringComparison.OrdinalIgnoreCase);
|
||||
if (pattern.IndexOf('*') < 0 && pattern.IndexOf('?') < 0)
|
||||
return string.Equals(pattern, name, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var regexPattern = "^" + Regex.Escape(entry)
|
||||
var regexPattern = "^" + Regex.Escape(pattern)
|
||||
.Replace("\\*", ".*")
|
||||
.Replace("\\?", ".") + "$";
|
||||
|
||||
try
|
||||
{
|
||||
// Timeout guards against pathological patterns (ReDoS)
|
||||
return Regex.IsMatch(name, regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100));
|
||||
// Timeout guards against pathological patterns (ReDoS). CultureInvariant: under a
|
||||
// Turkish culture "i" and "I" are no case pair, so "WLapsAdmin*" missed "WLAPSADMIN01".
|
||||
return Regex.IsMatch(name, regexPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
|
||||
TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
@@ -331,39 +313,107 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAllowed(string name, List<string> allowedAccounts)
|
||||
/// <summary>
|
||||
/// The effective static list: the built-in account-name and profile-folder entries of
|
||||
/// <see cref="LocalAdminAllowList.BuiltIns"/> (RID entries are decided by RID, see
|
||||
/// <see cref="Decide"/>) plus the tenant's entries. An entry that does not parse is added to
|
||||
/// <paramref name="ignored"/> and allows nothing. Pure; internal for unit-testing.
|
||||
/// </summary>
|
||||
internal static List<AllowedEntry> BuildAllowedEntries(IEnumerable<string> tenantEntries, List<string> ignored)
|
||||
{
|
||||
return allowedAccounts.Any(a => MatchesAllowedEntry(name, a));
|
||||
var entries = new List<AllowedEntry>();
|
||||
foreach (var builtIn in LocalAdminAllowList.BuiltIns)
|
||||
{
|
||||
if (builtIn.Match == LocalAdminBuiltInMatch.AccountName)
|
||||
AddDistinct(entries, new AllowedEntry(builtIn.Name, builtIn.Role, profileFolderOnly: false));
|
||||
else if (builtIn.Match == LocalAdminBuiltInMatch.ProfileFolder)
|
||||
AddDistinct(entries, new AllowedEntry(builtIn.Name, LocalAccountRole.Any, profileFolderOnly: true));
|
||||
}
|
||||
|
||||
foreach (var raw in tenantEntries ?? Enumerable.Empty<string>())
|
||||
{
|
||||
if (LocalAdminAllowList.TryParseEntry(raw, out var pattern, out var role))
|
||||
AddDistinct(entries, new AllowedEntry(pattern, role, profileFolderOnly: false));
|
||||
else if (!string.IsNullOrWhiteSpace(raw))
|
||||
ignored.Add(raw);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static void AddDistinct(List<AllowedEntry> entries, AllowedEntry entry)
|
||||
{
|
||||
if (!entries.Any(e => e.ProfileFolderOnly == entry.ProfileFolderOnly
|
||||
&& string.Equals(e.Display, entry.Display, StringComparison.OrdinalIgnoreCase)))
|
||||
entries.Add(entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known built-in accounts of the local machine domain, recognised by RID so that
|
||||
/// localized names (Gast, Invité, Administrateur, …) are not flagged:
|
||||
/// 500 Administrator, 501 Guest, 503 DefaultAccount, 504 WDAGUtilityAccount.
|
||||
/// Only S-1-5-21-… SIDs qualify (the WMI query is already restricted to local accounts).
|
||||
/// Decides one local account. The built-in accounts are decided by their RID alone: 500 in any
|
||||
/// role, 501/503/504 never as an administrator — no name entry, built-in or tenant, lifts or
|
||||
/// imposes a role on them. Without a SID (a WMI corner case) the English name stands in for the
|
||||
/// RID. Every other account is allowed when an account entry matches it, unless it is an
|
||||
/// administrator and ANY matching entry is standard-user only: a restriction always wins.
|
||||
/// Pure; internal for unit-testing.
|
||||
/// </summary>
|
||||
internal static AccountDecision Decide(string name, string sid, bool isAdmin, IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
var builtIn = FindBuiltInRidEntry(name, sid);
|
||||
if (builtIn != null)
|
||||
return isAdmin && builtIn.Role == LocalAccountRole.StandardUser ? AccountDecision.RoleViolation : AccountDecision.Allowed;
|
||||
|
||||
var covered = false;
|
||||
var restricted = false;
|
||||
foreach (var entry in allowed)
|
||||
{
|
||||
if (entry.ProfileFolderOnly || !MatchesAllowedEntry(name, entry.Pattern))
|
||||
continue;
|
||||
covered = true;
|
||||
if (entry.Role == LocalAccountRole.StandardUser)
|
||||
restricted = true;
|
||||
}
|
||||
|
||||
if (!covered)
|
||||
return AccountDecision.Unlisted;
|
||||
return isAdmin && restricted ? AccountDecision.RoleViolation : AccountDecision.Allowed;
|
||||
}
|
||||
|
||||
private static LocalAdminBuiltInEntry FindBuiltInRidEntry(string name, string sid)
|
||||
{
|
||||
var rid = MachineAccountRid(sid);
|
||||
foreach (var builtIn in LocalAdminAllowList.BuiltIns)
|
||||
{
|
||||
if (builtIn.Match != LocalAdminBuiltInMatch.Rid)
|
||||
continue;
|
||||
var matches = rid.HasValue
|
||||
? builtIn.Rid == rid.Value
|
||||
: string.IsNullOrEmpty(sid) && string.Equals(builtIn.Name, name, StringComparison.OrdinalIgnoreCase);
|
||||
if (matches)
|
||||
return builtIn;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RID (the last sub-authority) of a local-machine account SID (<c>S-1-5-21-…</c>; the WMI query
|
||||
/// and the member filter already restrict to local accounts), else null.
|
||||
/// Internal for direct unit-testing via InternalsVisibleTo.
|
||||
/// </summary>
|
||||
internal static bool IsWellKnownBuiltInRid(string sid)
|
||||
internal static int? MachineAccountRid(string sid)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sid) || !sid.StartsWith("S-1-5-21-", StringComparison.Ordinal))
|
||||
return false;
|
||||
return null;
|
||||
|
||||
var dash = sid.LastIndexOf('-');
|
||||
if (dash < 0 || dash == sid.Length - 1)
|
||||
return false;
|
||||
|
||||
switch (sid.Substring(dash + 1))
|
||||
{
|
||||
case "500":
|
||||
case "501":
|
||||
case "503":
|
||||
case "504":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return int.TryParse(sid.Substring(dash + 1), NumberStyles.None, CultureInfo.InvariantCulture, out var rid)
|
||||
? rid
|
||||
: (int?)null;
|
||||
}
|
||||
|
||||
/// <summary>A profile folder has no role: any entry of the same name allows it.</summary>
|
||||
private static bool IsAllowedFolder(string folderName, IReadOnlyList<AllowedEntry> allowed)
|
||||
=> allowed.Any(e => MatchesAllowedEntry(folderName, e.Pattern));
|
||||
|
||||
/// <summary>
|
||||
/// Splits the logged-in user names into those that may be allowed dynamically (not a
|
||||
/// local SAM account — Entra / domain identities whose only artifact is a profile
|
||||
@@ -481,7 +531,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
return accounts;
|
||||
}
|
||||
|
||||
private LocalAccountCheckResult CheckLocalAdminAccounts(List<LocalAccountInfo> accounts, List<string> allowedAccounts)
|
||||
private LocalAccountCheckResult CheckLocalAdminAccounts(List<LocalAccountInfo> accounts, IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
// Administrators-group membership is read from the SAM, independent of WMI and of
|
||||
// the enabled state — a dormant backdoor with admin rights is still a member.
|
||||
@@ -489,13 +539,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
if (!group.Succeeded)
|
||||
_logger.Warning($"{Name}: Failed to enumerate members of '{group.GroupName}': NET_API_STATUS={group.ErrorCode}");
|
||||
|
||||
var result = EvaluateAccounts(accounts, group.Members, group.Succeeded, Environment.MachineName, allowedAccounts);
|
||||
var result = EvaluateAccounts(accounts, group.Members, group.Succeeded, Environment.MachineName, allowed);
|
||||
result.AdministratorsGroupErrorCode = group.ErrorCode;
|
||||
|
||||
foreach (var name in result.Unexpected)
|
||||
_logger.Debug($"{Name}: Unexpected local account: {name}");
|
||||
foreach (var name in result.UnexpectedAdminMembers)
|
||||
_logger.Debug($"{Name}: Unexpected Administrators member: {name}");
|
||||
foreach (var name in result.RoleViolations)
|
||||
_logger.Debug($"{Name}: Allowed only as a standard user but an Administrators member: {name}");
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -508,10 +560,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// - every local account is checked, disabled ones included (state is reported);
|
||||
/// - a local account is an Administrators member when its SID matches a group member,
|
||||
/// or (SID unavailable) its name matches a member of the local machine domain;
|
||||
/// - an account is allowed by name (allowed list, globs) or by well-known RID
|
||||
/// (<see cref="IsWellKnownBuiltInRid"/>) — localized built-ins are never flagged;
|
||||
/// - <c>UnexpectedAdminMembers</c> = unexpected accounts holding membership, plus local
|
||||
/// members not on the allowed list that the WMI inventory did not return at all;
|
||||
/// - each account is decided by <see cref="Decide"/>; an account no entry allows, or one
|
||||
/// that breaks a standard-user entry, is unexpected (and an unexpected admin when it
|
||||
/// holds membership; the latter also a role violation);
|
||||
/// - local members the WMI inventory did not return at all are decided as administrators
|
||||
/// and land in both unexpected lists, like any unknown administrator;
|
||||
/// - with membership unknown (enumeration failed) nobody counts as an administrator, so a
|
||||
/// standard-user entry acts like a plain one;
|
||||
/// - members outside the local machine domain (Entra role SIDs, domain groups) are
|
||||
/// listed for delta comparison but never flagged — they are expected on joined devices.
|
||||
/// </summary>
|
||||
@@ -520,7 +575,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
IList<LocalGroupMember> adminMembers,
|
||||
bool adminGroupEnumerated,
|
||||
string machineName,
|
||||
List<string> allowedAccounts)
|
||||
IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
var result = new LocalAccountCheckResult { AdministratorsGroupEnumerated = adminGroupEnumerated };
|
||||
|
||||
@@ -539,45 +594,65 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
}
|
||||
|
||||
var inventorySids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var inventoryNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var account in accounts ?? new List<LocalAccountInfo>())
|
||||
{
|
||||
var isAdmin = (!string.IsNullOrEmpty(account.Sid) && adminSids.Contains(account.Sid))
|
||||
|| adminLocalNames.Contains(account.Name);
|
||||
var rid = MachineAccountRid(account.Sid);
|
||||
|
||||
if (!string.IsNullOrEmpty(account.Sid))
|
||||
inventorySids.Add(account.Sid);
|
||||
inventoryNames.Add(account.Name);
|
||||
|
||||
result.AllChecked.Add(account.Name);
|
||||
result.AccountDetails.Add(new Dictionary<string, object>
|
||||
{
|
||||
{ "name", account.Name },
|
||||
{ "disabled", account.Disabled },
|
||||
{ "administrators_member", isAdmin }
|
||||
{ "administrators_member", isAdmin },
|
||||
{ "rid", rid }
|
||||
});
|
||||
|
||||
if (account.Sid != null && account.Sid.EndsWith("-500", StringComparison.Ordinal))
|
||||
if (rid == 500)
|
||||
result.BuiltInAdministratorEnabled = !account.Disabled;
|
||||
|
||||
if (IsWellKnownBuiltInRid(account.Sid) || IsAllowed(account.Name, allowedAccounts))
|
||||
continue;
|
||||
|
||||
result.Unexpected.Add(account.Name);
|
||||
if (isAdmin)
|
||||
result.UnexpectedAdminMembers.Add(account.Name);
|
||||
Record(result, account.Name, Decide(account.Name, account.Sid, isAdmin, allowed), isAdmin);
|
||||
}
|
||||
|
||||
// Local members the WMI inventory did not surface (WMI failure, or an account the
|
||||
// provider does not list) — still an unexpected admin when not on the allowed list.
|
||||
// provider does not list): the SAM is authoritative for membership.
|
||||
foreach (var member in adminLocalMembers)
|
||||
{
|
||||
var name = member.Name;
|
||||
if (IsWellKnownBuiltInRid(member.Sid) || IsAllowed(name, allowedAccounts))
|
||||
var returnedByWmi = !string.IsNullOrEmpty(member.Sid)
|
||||
? inventorySids.Contains(member.Sid)
|
||||
: inventoryNames.Contains(member.Name);
|
||||
if (returnedByWmi)
|
||||
continue;
|
||||
if (result.UnexpectedAdminMembers.Any(u => string.Equals(u, name, StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
result.UnexpectedAdminMembers.Add(name);
|
||||
Record(result, member.Name, Decide(member.Name, member.Sid, isAdmin: true, allowed), isAdmin: true);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void Record(LocalAccountCheckResult result, string name, AccountDecision decision, bool isAdmin)
|
||||
{
|
||||
if (decision == AccountDecision.Allowed)
|
||||
return;
|
||||
AddOnce(result.Unexpected, name);
|
||||
if (isAdmin)
|
||||
AddOnce(result.UnexpectedAdminMembers, name);
|
||||
if (decision == AccountDecision.RoleViolation)
|
||||
AddOnce(result.RoleViolations, name);
|
||||
}
|
||||
|
||||
private static void AddOnce(List<string> names, string name)
|
||||
{
|
||||
if (!names.Any(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase)))
|
||||
names.Add(name);
|
||||
}
|
||||
|
||||
private static bool IsLocalMember(LocalGroupMember member, string machineName)
|
||||
{
|
||||
var domain = member.Domain;
|
||||
@@ -586,7 +661,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
return string.Equals(domain, machineName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private UserProfileCheckResult CheckUserProfiles(List<string> allowedAccounts)
|
||||
private UserProfileCheckResult CheckUserProfiles(IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
var allFound = new List<string>();
|
||||
var unexpected = new List<string>();
|
||||
@@ -616,7 +691,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
|
||||
allFound.Add(folderName);
|
||||
|
||||
if (!IsAllowed(folderName, allowedAccounts))
|
||||
if (!IsAllowedFolder(folderName, allowed))
|
||||
{
|
||||
unexpected.Add(folderName);
|
||||
_logger.Debug($"{Name}: Unexpected profile folder: {folderName}");
|
||||
@@ -740,7 +815,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// <summary>Unexpected accounts that are members of the Administrators group.</summary>
|
||||
public List<string> UnexpectedAdminMembers { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>Per-account state: name, disabled, administrators_member.</summary>
|
||||
/// <summary>Administrators members that an entry allows only as a standard user (subset of <see cref="UnexpectedAdminMembers"/>).</summary>
|
||||
public List<string> RoleViolations { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>Per-account state: name, disabled, administrators_member, rid.</summary>
|
||||
public List<Dictionary<string, object>> AccountDetails { get; set; } = new List<Dictionary<string, object>>();
|
||||
|
||||
/// <summary>Every Administrators member (DOMAIN\name or SID), local or not — for delta comparison.</summary>
|
||||
@@ -753,6 +831,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
public bool? BuiltInAdministratorEnabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One effective allowed-list entry: a name or glob and what it may allow.</summary>
|
||||
internal sealed class AllowedEntry
|
||||
{
|
||||
public AllowedEntry(string pattern, LocalAccountRole role, bool profileFolderOnly)
|
||||
{
|
||||
Pattern = pattern;
|
||||
Role = role;
|
||||
ProfileFolderOnly = profileFolderOnly;
|
||||
}
|
||||
|
||||
public string Pattern { get; }
|
||||
|
||||
public LocalAccountRole Role { get; }
|
||||
|
||||
/// <summary>Allows the C:\Users folder of this name, never a local account.</summary>
|
||||
public bool ProfileFolderOnly { get; }
|
||||
|
||||
/// <summary>Canonical entry text as the event lists it ("Guest|user").</summary>
|
||||
public string Display => LocalAdminAllowList.Format(Pattern, Role);
|
||||
}
|
||||
|
||||
internal enum AccountDecision
|
||||
{
|
||||
Allowed,
|
||||
|
||||
/// <summary>No entry allows the account.</summary>
|
||||
Unlisted,
|
||||
|
||||
/// <summary>An administrator that a standard-user entry (or a built-in RID) allows only as a standard user.</summary>
|
||||
RoleViolation,
|
||||
}
|
||||
|
||||
/// <summary>Logged-in users at shutdown, split by whether the name is a local SAM account.</summary>
|
||||
internal sealed class LoggedInUserSplit
|
||||
{
|
||||
|
||||
+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);
|
||||
|
||||
+132
-52
@@ -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)
|
||||
@@ -128,39 +181,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
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 +231,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 +245,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 +268,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 +331,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;
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
+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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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);
|
||||
|
||||
@@ -93,6 +93,39 @@ public class AuthFunctionTests
|
||||
Assert.Equal(pending, (bool)ToDynamic(result.Body).dpaAcceptancePending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HappyPath_MemberWithARoleGetsTheCompanyName()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.CompanyName = " Contoso Ltd. ";
|
||||
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: ViewerRole(), mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("Contoso Ltd.", (string)ToDynamic(result.Body).companyName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HappyPath_RolelessMemberGetsNoCompanyName()
|
||||
{
|
||||
// Without a role the caller sees only the Progress Portal; the config GET would refuse them.
|
||||
var config = DefaultConfig();
|
||||
config.CompanyName = "Contoso Ltd.";
|
||||
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: null, mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Null((string?)ToDynamic(result.Body).companyName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("platform", "platform")]
|
||||
[InlineData("Platform", "platform")]
|
||||
|
||||
@@ -202,6 +202,28 @@ public class ConfigBackupHookTests
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoiseOnlyChange_SetupGuideMarkers_NoSnapshot()
|
||||
{
|
||||
// The setup guide's markers would otherwise push real settings out of the two backup slots.
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.SetupGuideSettingsReviewedAt = DateTime.UtcNow;
|
||||
incoming.SetupGuideHardwareReviewedAt = DateTime.UtcNow;
|
||||
incoming.SetupGuideDismissedAt = DateTime.UtcNow;
|
||||
incoming.SetupGuideDismissedBy = "admin@contoso.com";
|
||||
incoming.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "setup-guide", null);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts);
|
||||
harness.Backup.Verify(
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoiseOnlyChange_AddOnPermissionState_NoSnapshot()
|
||||
{
|
||||
|
||||
@@ -772,6 +772,35 @@ public class ConfigWireParityTests
|
||||
|
||||
// ---- UpdateTenantConfiguration -------------------------------------------------------
|
||||
|
||||
// ---- RecordSetupGuideStep ------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SetupGuideStateResponse_matches_the_marker_shape()
|
||||
{
|
||||
DateTime? settingsReviewedAt = new DateTime(2026, 11, 9, 8, 0, 0, DateTimeKind.Utc);
|
||||
DateTime? hardwareReviewedAt = settingsReviewedAt.Value.AddMinutes(3);
|
||||
DateTime? dismissedAt = null;
|
||||
string? dismissedBy = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
settingsReviewedAt,
|
||||
hardwareReviewedAt,
|
||||
dismissedAt,
|
||||
dismissedBy
|
||||
},
|
||||
new SetupGuideStateResponse
|
||||
{
|
||||
Success = true,
|
||||
SettingsReviewedAt = settingsReviewedAt,
|
||||
HardwareReviewedAt = hardwareReviewedAt,
|
||||
DismissedAt = null,
|
||||
DismissedBy = null
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.ApplicationInsights.DependencyCollector;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Credential URLs never reach a dependency row (K20, D-424): inside a request of a marked client the row
|
||||
/// keeps scheme + host and the method, whatever host the URL names; outside it nothing changes; the mark
|
||||
/// does not leak into the caller's flow; the real collector pipeline carries the mark to the processor;
|
||||
/// and both credential clients (webhooks, push) carry the mark.
|
||||
/// </summary>
|
||||
public class CredentialUrlRedactionProcessorTests
|
||||
{
|
||||
private sealed class CapturingProcessor : ITelemetryProcessor
|
||||
{
|
||||
public List<ITelemetry> Received { get; } = new();
|
||||
public void Process(ITelemetry item) => Received.Add(item);
|
||||
}
|
||||
|
||||
private sealed class CapturingChannel : ITelemetryChannel
|
||||
{
|
||||
private readonly List<ITelemetry> _items = new();
|
||||
public IReadOnlyList<ITelemetry> Items { get { lock (_items) return _items.ToList(); } }
|
||||
public bool? DeveloperMode { get; set; }
|
||||
public string? EndpointAddress { get; set; }
|
||||
public void Send(ITelemetry item) { lock (_items) _items.Add(item); }
|
||||
public void Flush() { }
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>Stands in for the collector: tracks a dependency row from inside the request's flow.</summary>
|
||||
private sealed class TrackingInnerHandler(ITelemetryProcessor processor, DependencyTelemetry row) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
processor.Process(row);
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||
}
|
||||
}
|
||||
|
||||
private static DependencyTelemetry Row(string url, string method = "POST")
|
||||
=> new() { Type = "Http", Data = url, Name = $"{method} {new Uri(url).AbsolutePath}", Target = new Uri(url).Authority };
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://web.push.apple.com/QF3abc/device-token-123", "https://web.push.apple.com")]
|
||||
[InlineData("https://fcm.googleapis.com/fcm/send/abc:APA91", "https://fcm.googleapis.com")]
|
||||
[InlineData("https://updates.push.services.mozilla.com/wpush/v2/gAAAA", "https://updates.push.services.mozilla.com")]
|
||||
[InlineData("https://wns2-db5p.notify.windows.com/w/?token=abc", "https://wns2-db5p.notify.windows.com")]
|
||||
[InlineData("https://discord.com/api/webhooks/123456/discord-token", "https://discord.com")]
|
||||
[InlineData("https://hooks.slack.com/services/T000/B000/slack-secret", "https://hooks.slack.com")]
|
||||
[InlineData("https://default00000000.00.environment.api.powerplatform.com:443/powerautomate/automations/direct/workflows/abc/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers&sv=1.0&sig=signature", "https://default00000000.00.environment.api.powerplatform.com")]
|
||||
[InlineData("https://tickets.example.test/hooks/intake?apikey=generic-secret", "https://tickets.example.test")]
|
||||
public void Redact_keeps_scheme_host_and_method_only(string url, string expected)
|
||||
{
|
||||
var dependency = Row(url);
|
||||
|
||||
CredentialUrlRedactionProcessor.Redact(dependency);
|
||||
|
||||
Assert.Equal(expected, dependency.Data);
|
||||
Assert.Equal("POST " + expected, dependency.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Redact_of_an_unparseable_url_keeps_only_the_target_host()
|
||||
{
|
||||
var dependency = new DependencyTelemetry { Data = "not a url /api/webhooks/1/token", Name = "POST /api/webhooks/1/token", Target = "discord.com" };
|
||||
|
||||
CredentialUrlRedactionProcessor.Redact(dependency);
|
||||
|
||||
Assert.Equal("discord.com", dependency.Data);
|
||||
Assert.Equal("POST discord.com", dependency.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rows_outside_a_marked_request_are_untouched()
|
||||
{
|
||||
var next = new CapturingProcessor();
|
||||
var processor = new CredentialUrlRedactionProcessor(next);
|
||||
var dependency = Row("https://graph.microsoft.com/v1.0/devices?$filter=x", "GET");
|
||||
|
||||
processor.Process(dependency);
|
||||
|
||||
Assert.Same(dependency, Assert.Single(next.Received));
|
||||
Assert.Equal("https://graph.microsoft.com/v1.0/devices?$filter=x", dependency.Data);
|
||||
Assert.Equal("GET /v1.0/devices", dependency.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_mark_covers_the_request_and_ends_with_it()
|
||||
{
|
||||
var next = new CapturingProcessor();
|
||||
var processor = new CredentialUrlRedactionProcessor(next);
|
||||
var inside = Row("https://discord.com/api/webhooks/123456/discord-token");
|
||||
using var client = new HttpClient(new CredentialUrlRedactionProcessor.MarkingHandler
|
||||
{
|
||||
InnerHandler = new TrackingInnerHandler(processor, inside),
|
||||
});
|
||||
|
||||
await client.PostAsync("https://discord.com/api/webhooks/123456/discord-token", new StringContent("{}"));
|
||||
var after = Row("https://graph.microsoft.com/v1.0/devices", "GET");
|
||||
processor.Process(after);
|
||||
|
||||
Assert.Equal("https://discord.com", inside.Data);
|
||||
Assert.Equal("https://graph.microsoft.com/v1.0/devices", after.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_real_collector_pipeline_carries_the_mark_to_the_processor()
|
||||
{
|
||||
var channel = new CapturingChannel();
|
||||
using var configuration = new TelemetryConfiguration
|
||||
{
|
||||
TelemetryChannel = channel,
|
||||
ConnectionString = "InstrumentationKey=00000000-0000-0000-0000-000000000000",
|
||||
};
|
||||
configuration.TelemetryProcessorChainBuilder.Use(next => new CredentialUrlRedactionProcessor(next)).Build();
|
||||
using var collector = new DependencyTrackingTelemetryModule();
|
||||
collector.Initialize(configuration);
|
||||
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var server = AnswerNoContentAsync(listener, connections: 2);
|
||||
var secretPath = $"http://127.0.0.1:{port}/api/webhooks/123456/discord-token";
|
||||
|
||||
using (var marked = new HttpClient(new CredentialUrlRedactionProcessor.MarkingHandler { InnerHandler = new SocketsHttpHandler() }))
|
||||
(await marked.PostAsync(secretPath, new StringContent("{}"))).Dispose();
|
||||
using (var plain = new HttpClient(new SocketsHttpHandler()))
|
||||
(await plain.PostAsync(secretPath + "?unmarked=1", new StringContent("{}"))).Dispose();
|
||||
await server;
|
||||
|
||||
var rows = channel.Items.OfType<DependencyTelemetry>().Where(d => d.Target?.EndsWith(":" + port) == true).ToList();
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.Contains(rows, d => d.Data == "http://127.0.0.1" && d.Name == "POST http://127.0.0.1");
|
||||
Assert.Contains(rows, d => d.Data == secretPath + "?unmarked=1");
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(WebhookNotificationService))]
|
||||
[InlineData(PushHttpClientRegistration.ClientName)]
|
||||
public void Both_credential_clients_carry_the_mark(string clientName)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<ResiliencePolicies>();
|
||||
services.AddWebhookNotificationHttpClient();
|
||||
services.AddPushSender();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var handlers = new List<HttpMessageHandler>();
|
||||
for (HttpMessageHandler? handler = provider.GetRequiredService<IHttpMessageHandlerFactory>().CreateHandler(clientName);
|
||||
handler != null;
|
||||
handler = (handler as DelegatingHandler)?.InnerHandler)
|
||||
{
|
||||
handlers.Add(handler);
|
||||
}
|
||||
|
||||
Assert.Single(handlers.OfType<CredentialUrlRedactionProcessor.MarkingHandler>());
|
||||
}
|
||||
|
||||
/// <summary>Answers each of <paramref name="connections"/> requests with 204 and closes the connection.</summary>
|
||||
private static async Task AnswerNoContentAsync(TcpListener listener, int connections)
|
||||
{
|
||||
for (var i = 0; i < connections; i++)
|
||||
{
|
||||
using var socket = await listener.AcceptTcpClientAsync();
|
||||
var stream = socket.GetStream();
|
||||
var buffer = new byte[8192];
|
||||
var received = new StringBuilder();
|
||||
while (!received.ToString().Contains("\r\n\r\n", StringComparison.Ordinal))
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer);
|
||||
if (read == 0) break;
|
||||
received.Append(Encoding.ASCII.GetString(buffer, 0, read));
|
||||
}
|
||||
// The request body ("{}") follows the headers; the 204 does not wait for it.
|
||||
await stream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -640,4 +640,116 @@ public class DistressValidationTests
|
||||
{
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), ReportDistressFunction.MaxTimestampFuture);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ResolveErrorType — the agent reports every backend 403 as DeviceNotRegistered; the
|
||||
// hardware whitelist runs before the registration lookup, so a 403 for hardware outside
|
||||
// the whitelist is stored as HardwareNotAllowed.
|
||||
// =========================================================================
|
||||
|
||||
private const string ResolveTenant = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
private static TenantConfiguration ValidatingTenant(Action<TenantConfiguration>? change = null)
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault(ResolveTenant);
|
||||
config.ValidateAutopilotDevice = true; // CreateDefault starts with every validation off
|
||||
change?.Invoke(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
private static DistressErrorType Resolve(
|
||||
TenantConfiguration config, string? manufacturer = "VMware, Inc.", string? model = "VMware20,1",
|
||||
DistressErrorType reported = DistressErrorType.DeviceNotRegistered, int? http = 403,
|
||||
DistressCertSourceState? certState = null)
|
||||
=> ReportDistressFunction.ResolveErrorType(reported, http, manufacturer, model, certState, config);
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_ManufacturerOutsideTheWhitelist_IsHardwareNotAllowed()
|
||||
{
|
||||
Assert.Equal(DistressErrorType.HardwareNotAllowed, Resolve(ValidatingTenant()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_ModelOutsideTheWhitelist_IsHardwareNotAllowed()
|
||||
{
|
||||
var config = ValidatingTenant(c => c.ModelWhitelist = "Latitude*");
|
||||
|
||||
Assert.Equal(DistressErrorType.HardwareNotAllowed, Resolve(config, "Dell Inc.", "OptiPlex 7090"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Dell Inc.", "Latitude 5440")]
|
||||
[InlineData("LENOVO", "21HD")] // wildcards match case-insensitively
|
||||
[InlineData("HP", "EliteBook 840 G10")]
|
||||
[InlineData("Microsoft Corporation", "Surface Laptop 5")]
|
||||
public void ResolveErrorType_WhitelistedHardware_StaysDeviceNotRegistered(string manufacturer, string model)
|
||||
{
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(ValidatingTenant(), manufacturer, model));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_ManufacturerAddedToTheWhitelist_StaysDeviceNotRegistered()
|
||||
{
|
||||
var config = ValidatingTenant(c => c.ManufacturerWhitelist += ",VMware*");
|
||||
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(config));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "VMware20,1")]
|
||||
[InlineData("VMware, Inc.", null)]
|
||||
[InlineData("", "")]
|
||||
public void ResolveErrorType_WithoutManufacturerOrModel_StaysAsReported(string? manufacturer, string? model)
|
||||
{
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(ValidatingTenant(), manufacturer, model));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_SuspendedTenant_StaysAsReported()
|
||||
{
|
||||
// The validator refuses a suspended tenant before it looks at the hardware.
|
||||
var config = ValidatingTenant(c => c.Disabled = true);
|
||||
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_TenantWithoutValidation_StaysAsReported()
|
||||
{
|
||||
var config = ValidatingTenant(c => c.ValidateAutopilotDevice = false);
|
||||
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_TestTenantWithoutValidationButInsecureRequests_IsHardwareNotAllowed()
|
||||
{
|
||||
var config = ValidatingTenant(c =>
|
||||
{
|
||||
c.ValidateAutopilotDevice = false;
|
||||
c.AllowInsecureAgentRequests = true;
|
||||
});
|
||||
|
||||
Assert.Equal(DistressErrorType.HardwareNotAllowed, Resolve(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_NoCertificateInStore_StaysAsReported()
|
||||
{
|
||||
// Without a certificate the agent authenticates with a bootstrap token, which skips the
|
||||
// hardware check.
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered,
|
||||
Resolve(ValidatingTenant(), certState: DistressCertSourceState.NoCertInStore));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DistressErrorType.AuthCertificateRejected, 401)]
|
||||
[InlineData(DistressErrorType.TpmPssUnsupported, 403)]
|
||||
[InlineData(DistressErrorType.ClientCertificateNotReceived, 403)]
|
||||
[InlineData(DistressErrorType.DeviceNotRegistered, 401)]
|
||||
[InlineData(DistressErrorType.DeviceNotRegistered, null)]
|
||||
public void ResolveErrorType_OtherTypesAndStatusCodes_StayAsReported(DistressErrorType reported, int? http)
|
||||
{
|
||||
Assert.Equal(reported, Resolve(ValidatingTenant(), reported: reported, http: http));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ public class GetHardwareRejectedFunctionTests
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
MakeEntry("HardwareNotAllowed", "Dell", "Latitude", "SN1", T0),
|
||||
MakeEntry("CertificateExpired", "Dell", "Latitude", "SN2", T0.AddMinutes(1)),
|
||||
MakeEntry("Timeout", "HP", "EliteBook", "SN3", T0.AddMinutes(2)),
|
||||
MakeEntry("DeviceNotRegistered", "Dell", "Latitude", "SN2", T0.AddMinutes(1)),
|
||||
MakeEntry("TpmPssUnsupported", "HP", "EliteBook", "SN3", T0.AddMinutes(2)),
|
||||
};
|
||||
|
||||
var (aggregated, totalRawReports) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
@@ -64,6 +64,11 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
"graphAccessRejected",
|
||||
"intuneDeviceBindingPermissionMissing",
|
||||
"isTrial",
|
||||
// Non-sensitive by review: booleans only; who hid the guide and when stays in the
|
||||
// admin-gated config. hasEnrollmentSessions is absent here: unknown → omitted.
|
||||
"setupGuideDismissed",
|
||||
"setupGuideHardwareDone",
|
||||
"setupGuideSettingsDone",
|
||||
"showScriptOutput",
|
||||
"trialAvailable",
|
||||
// trialExpiresUtc is absent here: the default tenant is not on a trial and the
|
||||
@@ -520,4 +525,53 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
|
||||
Assert.DoesNotContain("Secret Holdings", json);
|
||||
}
|
||||
|
||||
// ── setup guide ──
|
||||
|
||||
[Fact]
|
||||
public void Payload_SetupGuide_NewTenantHasEveryStepOpen()
|
||||
{
|
||||
var payload = GetTenantFeatureFlagsFunction.BuildPayload(TenantConfiguration.CreateDefault("t"), Now, hasEnrollmentSessions: false);
|
||||
|
||||
Assert.False(payload.SetupGuideSettingsDone);
|
||||
Assert.False(payload.SetupGuideHardwareDone);
|
||||
Assert.False(payload.SetupGuideDismissed);
|
||||
Assert.False(payload.HasEnrollmentSessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_SetupGuide_ReflectsMarkersAndDismissal()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.SetupGuideSettingsReviewedAt = Now;
|
||||
config.SetupGuideHardwareReviewedAt = Now;
|
||||
config.SetupGuideDismissedAt = Now;
|
||||
config.SetupGuideDismissedBy = "admin@contoso.com";
|
||||
|
||||
var payload = GetTenantFeatureFlagsFunction.BuildPayload(config, Now);
|
||||
|
||||
Assert.True(payload.SetupGuideSettingsDone);
|
||||
Assert.True(payload.SetupGuideHardwareDone);
|
||||
Assert.True(payload.SetupGuideDismissed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_SetupGuide_UnknownEnrollmentHistoryIsOmitted()
|
||||
{
|
||||
var element = Serialize(TenantConfiguration.CreateDefault("t"));
|
||||
|
||||
Assert.False(element.TryGetProperty("hasEnrollmentSessions", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_SetupGuide_NeverLeaksWhoDismissedIt()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.SetupGuideDismissedAt = Now;
|
||||
config.SetupGuideDismissedBy = "hidden.admin@contoso.com";
|
||||
|
||||
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
|
||||
|
||||
Assert.DoesNotContain("hidden.admin", json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,8 @@ public class InfrastructureWireParityTests
|
||||
mcpClientRegistrationEnabled = true,
|
||||
whatsNewSeenPlatformUtc = platformSeen,
|
||||
whatsNewSeenAgentUtc = agentSeen,
|
||||
dpaAcceptancePending = true
|
||||
dpaAcceptancePending = true,
|
||||
companyName = "Contoso Ltd."
|
||||
},
|
||||
new AuthMeResponse
|
||||
{
|
||||
@@ -74,7 +75,8 @@ public class InfrastructureWireParityTests
|
||||
McpClientRegistrationEnabled = true,
|
||||
WhatsNewSeenPlatformUtc = platformSeen,
|
||||
WhatsNewSeenAgentUtc = agentSeen,
|
||||
DpaAcceptancePending = true
|
||||
DpaAcceptancePending = true,
|
||||
CompanyName = "Contoso Ltd."
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>tests/fixtures/local-admin-allow-list/cases.json</c> is the one definition of the allowed-list entry
|
||||
/// grammar; the agent tests run its parse cases against the netstandard2.0 build, the portal runs the file
|
||||
/// against <c>allowedAccounts.ts</c>. Plus the list-level write rules of
|
||||
/// <see cref="TenantConfigValidation.ValidateLocalAdminAllowedAccounts"/>.
|
||||
/// </summary>
|
||||
public class LocalAdminAllowListParityTests
|
||||
{
|
||||
private static readonly Lazy<JObject> Fixture = new(() =>
|
||||
JObject.Parse(File.ReadAllText(Path.Combine(FindRepoRoot(), "tests", "fixtures", "local-admin-allow-list", "cases.json"))));
|
||||
|
||||
public static IEnumerable<object[]> ParseEntries() => Fixture.Value["parse"]!.Select(c => new object[] { c.Value<string>("entry")! });
|
||||
|
||||
public static IEnumerable<object[]> WriteEntries() => Fixture.Value["write"]!.Select(c => new object[] { c.Value<string>("entry")! });
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ParseEntries))]
|
||||
public void Parse_matches_the_shared_case(string entry)
|
||||
{
|
||||
var c = Fixture.Value["parse"]!.First(x => x.Value<string>("entry") == entry);
|
||||
|
||||
var parsed = LocalAdminAllowList.TryParseEntry(entry, out var pattern, out var role);
|
||||
|
||||
if (c.Value<bool?>("ignored") == true)
|
||||
{
|
||||
Assert.False(parsed);
|
||||
return;
|
||||
}
|
||||
Assert.True(parsed);
|
||||
Assert.Equal(c.Value<string>("pattern"), pattern);
|
||||
Assert.Equal(c.Value<string>("role") == "user" ? LocalAccountRole.StandardUser : LocalAccountRole.Any, role);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(WriteEntries))]
|
||||
public void New_entry_validation_matches_the_shared_case(string entry)
|
||||
{
|
||||
var c = Fixture.Value["write"]!.First(x => x.Value<string>("entry") == entry);
|
||||
|
||||
var error = LocalAdminAllowList.ValidateNewEntry(entry);
|
||||
|
||||
if (c.Value<bool>("valid"))
|
||||
Assert.Null(error);
|
||||
else
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shared_cases_are_present()
|
||||
{
|
||||
Assert.True(Fixture.Value["parse"]!.Count() >= 15, "cases.json lost parse cases.");
|
||||
Assert.True(Fixture.Value["write"]!.Count() >= 25, "cases.json lost write cases.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builtin_catalog_keeps_the_four_rid_accounts_and_the_standard_user_system_accounts()
|
||||
{
|
||||
var rid = LocalAdminAllowList.BuiltIns.Where(b => b.Match == LocalAdminBuiltInMatch.Rid).ToDictionary(b => b.Rid!.Value, b => b.Role);
|
||||
Assert.Equal(LocalAccountRole.Any, rid[500]);
|
||||
Assert.Equal(LocalAccountRole.StandardUser, rid[501]);
|
||||
Assert.Equal(LocalAccountRole.StandardUser, rid[503]);
|
||||
Assert.Equal(LocalAccountRole.StandardUser, rid[504]);
|
||||
|
||||
string RoleOf(string name) => LocalAdminAllowList.BuiltIns
|
||||
.Single(b => b.Match == LocalAdminBuiltInMatch.AccountName && b.Name == name).Role.ToString();
|
||||
Assert.Equal("StandardUser", RoleOf("WsiAccount"));
|
||||
Assert.Equal("StandardUser", RoleOf("kioskUser0"));
|
||||
Assert.Equal("StandardUser", RoleOf("Administrator"));
|
||||
Assert.Equal("Any", RoleOf("defaultuser0"));
|
||||
}
|
||||
|
||||
// ── list-level write rules ───────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "[\"adm-*\",\"kiosk|user\"]")]
|
||||
[InlineData("[\"adm-*\"]", "[\"adm-*\",\"kiosk|user\"]")]
|
||||
[InlineData("[\"adm-*\"]", "[]")]
|
||||
[InlineData("[\"adm-*\"]", "")]
|
||||
[InlineData("[\"*\"]", "[\"*\"]")]
|
||||
public void Valid_or_unchanged_lists_pass(string? stored, string candidate)
|
||||
{
|
||||
Assert.Null(TenantConfigValidation.ValidateLocalAdminAllowedAccounts(candidate, stored));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stored_entry_is_never_rechecked_when_another_entry_is_added()
|
||||
{
|
||||
// Lists written before the grammar existed (a bare "*", an Entra SID) stay saveable.
|
||||
Assert.Null(TenantConfigValidation.ValidateLocalAdminAllowedAccounts(
|
||||
"[\"*\",\"S-1-12-1-1-2-3-4\",\"WsiAccount\",\"kiosk|user\"]",
|
||||
"[\"*\",\"S-1-12-1-1-2-3-4\",\"WsiAccount\"]"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"a\":1}", "JSON array")]
|
||||
[InlineData("[1,2]", "JSON array")]
|
||||
[InlineData("not json", "JSON array")]
|
||||
[InlineData("[\"*\"]", "too broad")]
|
||||
[InlineData("[\"kiosk|admin\"]", "is not valid")]
|
||||
[InlineData("[\"kiosk\",\"kiosk|user\"]", "listed twice")]
|
||||
[InlineData("[\"Kiosk\",\"kiosk\"]", "listed twice")]
|
||||
public void Invalid_new_entries_are_rejected(string candidate, string expected)
|
||||
{
|
||||
var error = TenantConfigValidation.ValidateLocalAdminAllowedAccounts(candidate, null);
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains(expected, error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_entry_repeating_a_stored_pattern_is_rejected()
|
||||
{
|
||||
var error = TenantConfigValidation.ValidateLocalAdminAllowedAccounts("[\"WsiAccount\",\"WsiAccount|user\"]", "[\"WsiAccount\"]");
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("listed twice", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_entry_count_is_capped()
|
||||
{
|
||||
var entries = Enumerable.Range(0, LocalAdminAllowList.MaxEntries + 1).Select(i => $"\"svc{i:D3}\"");
|
||||
|
||||
var error = TenantConfigValidation.ValidateLocalAdminAllowedAccounts("[" + string.Join(",", entries) + "]", null);
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains($"at most {LocalAdminAllowList.MaxEntries}", error);
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (dir != null && !File.Exists(Path.Combine(dir, "AutopilotMonitor.sln")))
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
return dir ?? throw new DirectoryNotFoundException("Could not locate repo root (AutopilotMonitor.sln).");
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,7 @@ public class PerPartitionFanOutMergeTests
|
||||
[InlineData(5)]
|
||||
[InlineData(15)]
|
||||
[InlineData(100)]
|
||||
[InlineData(1000)]
|
||||
public async Task Walk_returns_every_row_once_in_global_order_and_never_an_empty_page(int pageSize)
|
||||
{
|
||||
// Uneven partitions: one empty, one dominant, interleaved timestamps and a tie.
|
||||
|
||||
@@ -158,6 +158,7 @@ internal sealed class InMemoryPushDeviceRepository : IPushDeviceRepository
|
||||
case "P256dh": d.P256dh = (string)value!; break;
|
||||
case "Auth": d.Auth = (string)value!; break;
|
||||
case "VapidKid": d.VapidKid = (string)value!; break;
|
||||
case "Label": d.Label = (string)value!; break;
|
||||
default: throw new InvalidOperationException($"unexpected patch key {key}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,10 @@ using AutopilotMonitor.Functions.Functions.Raw;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
@@ -18,7 +16,7 @@ namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
/// Push as a channel provider: the dispatcher's Push branch (scope passed through, never the
|
||||
/// webhook path, one fan-out per send), the validation rules (no destination, open to tenant
|
||||
/// admins — only E-mail stays behind the GA gate — and one Push channel per scope),
|
||||
/// the raw-table deny-list and the dependency redaction.
|
||||
/// and the raw-table deny-list. The dependency redaction lives in CredentialUrlRedactionProcessorTests.
|
||||
/// </summary>
|
||||
public class PushChannelRoutingTests
|
||||
{
|
||||
@@ -182,30 +180,4 @@ public class PushChannelRoutingTests
|
||||
Assert.Contains(Constants.TableNames.PushDevices, Constants.TableNames.CredentialBearing);
|
||||
Assert.Contains(Constants.TableNames.PushPairingGrants, Constants.TableNames.CredentialBearing);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://web.push.apple.com/QF3abc/device-token-123", "https://web.push.apple.com")]
|
||||
[InlineData("https://fcm.googleapis.com/fcm/send/abc:APA91", "https://fcm.googleapis.com")]
|
||||
[InlineData("https://updates.push.services.mozilla.com/wpush/v2/gAAAA", "https://updates.push.services.mozilla.com")]
|
||||
[InlineData("https://wns2-db5p.notify.windows.com/w/?token=abc", "https://wns2-db5p.notify.windows.com")]
|
||||
public void Dependency_rows_for_push_services_keep_scheme_and_host_only(string endpoint, string expected)
|
||||
{
|
||||
var dependency = new DependencyTelemetry { Data = endpoint, Name = "POST " + endpoint, Target = new Uri(endpoint).Host };
|
||||
|
||||
PushDependencyRedactionProcessor.Redact(dependency);
|
||||
|
||||
Assert.Equal(expected, dependency.Data);
|
||||
Assert.Equal("POST " + expected, dependency.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dependency_rows_for_other_hosts_are_untouched()
|
||||
{
|
||||
var dependency = new DependencyTelemetry { Data = "https://graph.microsoft.com/v1.0/devices/abc", Name = "GET /v1.0/devices/abc" };
|
||||
|
||||
PushDependencyRedactionProcessor.Redact(dependency);
|
||||
|
||||
Assert.Equal("https://graph.microsoft.com/v1.0/devices/abc", dependency.Data);
|
||||
Assert.Equal("GET /v1.0/devices/abc", dependency.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,43 @@ public class PushDeliveryServiceTests
|
||||
Assert.Equal((int)status, row.LastStatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Start_alert_goes_out_at_high_urgency(bool isResume)
|
||||
{
|
||||
// D-424: an Info start below "high" waits at FCM while the phone dozes, and the verdict sharing
|
||||
// its session Topic then replaces it — the start never reaches the phone.
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
var alert = NotificationAlertBuilder.BuildEnrollmentStartedAlert(
|
||||
"DESKTOP-4711", "SN-0001", "Dell Inc.", "Latitude 7450", isResume, Constants.PortalSessionUrl("session-1"));
|
||||
|
||||
await h.Sut.SendCoreAsync(Tenant, alert);
|
||||
|
||||
var request = Assert.Single(h.Http.Requests);
|
||||
Assert.Equal("high", Assert.Single(request.Headers.GetValues("Urgency")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationSeverity.Error, "high")]
|
||||
[InlineData(NotificationSeverity.Warning, "high")]
|
||||
[InlineData(NotificationSeverity.Success, "normal")]
|
||||
[InlineData(NotificationSeverity.Info, "low")]
|
||||
public async Task Other_alerts_take_their_urgency_from_the_severity(NotificationSeverity severity, string expected)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
var alert = Alert();
|
||||
alert.EventType = "whats_new";
|
||||
alert.Severity = severity;
|
||||
|
||||
await h.Sut.SendCoreAsync(Tenant, alert);
|
||||
|
||||
var request = Assert.Single(h.Http.Requests);
|
||||
Assert.Equal(expected, Assert.Single(request.Headers.GetValues("Urgency")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flood_window_mutes_after_the_cap_with_one_summary_push()
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
/// <summary>
|
||||
/// The 403 a refused pairing answers with: neutral, naming the role the scope needs — the member
|
||||
/// list of the tenant on the tenant routes, the Global Administrator role on the platform routes.
|
||||
/// Plus the label bodies of the portal routes: optional on confirm, required on rename.
|
||||
/// </summary>
|
||||
public class PushPairingFunctionsTests
|
||||
{
|
||||
@@ -39,6 +40,59 @@ public class PushPairingFunctionsTests
|
||||
await AssertForbiddenAsync(response, "Push notifications on the platform need the Global Administrator role.");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "iPhone")] // a portal without the name field sends no body
|
||||
[InlineData("{\"label\":\"\"}", "iPhone")]
|
||||
[InlineData("{\"label\":\"iPhone Otto\"}", "iPhone Otto")]
|
||||
public async Task Confirm_reads_an_optional_name(string? body, string expectedLabel)
|
||||
{
|
||||
var h = new PushPairingServiceTests.Harness();
|
||||
var sut = new PushPairingFunctions(h.Sut, NullLogger<PushPairingFunctions>.Instance);
|
||||
var pairingId = await RedeemedPairingAsync(h, sut);
|
||||
|
||||
var response = await sut.Confirm(EndpointHarness.Request(TenantId, jsonBody: body).Req, pairingId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var device = h.Repo.Devices.Values.Single();
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, device.Status);
|
||||
Assert.Equal(expectedLabel, device.Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rename_answers_204_and_maps_a_blank_name_to_400_and_an_unknown_device_to_404()
|
||||
{
|
||||
var h = new PushPairingServiceTests.Harness();
|
||||
var pairing = new PushPairingFunctions(h.Sut, NullLogger<PushPairingFunctions>.Instance);
|
||||
var pairingId = await RedeemedPairingAsync(h, pairing);
|
||||
await pairing.Confirm(EndpointHarness.Request(TenantId).Req, pairingId);
|
||||
var deviceId = h.Repo.Devices.Values.Single().DeviceId;
|
||||
var sut = new PushDevicesFunctions(h.Sut, NullLogger<PushDevicesFunctions>.Instance);
|
||||
|
||||
var renamed = await sut.Rename(EndpointHarness.Request(TenantId, jsonBody: "{\"label\":\"iPhone IT\"}").Req, deviceId);
|
||||
Assert.Equal(HttpStatusCode.NoContent, renamed.StatusCode);
|
||||
Assert.Equal("iPhone IT", h.Repo.Devices.Values.Single().Label);
|
||||
|
||||
var blank = await sut.Rename(EndpointHarness.Request(TenantId, jsonBody: "{\"label\":\" \"}").Req, deviceId);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, blank.StatusCode);
|
||||
var noBody = await sut.Rename(EndpointHarness.Request(TenantId).Req, deviceId);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, noBody.StatusCode);
|
||||
Assert.Equal("iPhone IT", h.Repo.Devices.Values.Single().Label);
|
||||
|
||||
var unknown = await sut.Rename(EndpointHarness.Request(TenantId, jsonBody: "{\"label\":\"x\"}").Req, "unknownDevice");
|
||||
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>A pairing created through the tenant route by the harness caller and redeemed by a device named "iPhone".</summary>
|
||||
private static async Task<string> RedeemedPairingAsync(PushPairingServiceTests.Harness h, PushPairingFunctions sut)
|
||||
{
|
||||
var created = await sut.Create(EndpointHarness.Request(TenantId).Req);
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
created.Body.Position = 0;
|
||||
var pairing = (await JsonSerializer.DeserializeAsync<CreatePairingResponse>(created.Body, ApiJsonOptions.Read))!;
|
||||
Assert.True((await h.Sut.RedeemAsync(h.RedeemRequest(pairing.Code, label: "iPhone"))).Ok);
|
||||
return pairing.PairingId;
|
||||
}
|
||||
|
||||
private static PushPairingFunctions RefusingFunctions()
|
||||
{
|
||||
var pairing = new PushPairingServiceTests.Harness();
|
||||
|
||||
@@ -400,6 +400,54 @@ public class PushPairingServiceTests
|
||||
Assert.Empty(h.Repo.Devices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_takes_the_name_chosen_on_the_computer_and_a_blank_one_keeps_the_devices()
|
||||
{
|
||||
var h = new Harness();
|
||||
var renamed = await h.Sut.CreateAsync(Tenant, Admin);
|
||||
await h.Sut.RedeemAsync(h.RedeemRequest(renamed.Value!.Code, label: "iPhone"));
|
||||
Assert.True((await h.Sut.ConfirmAsync(Tenant, renamed.Value.PairingId, Admin, " iPhone \u0007IT ")).Ok);
|
||||
var device = h.Repo.Devices.Values.Single();
|
||||
Assert.Equal("iPhone IT", device.Label);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, device.Status);
|
||||
h.Maintenance.Verify(m => m.LogAuditEntryAsync(TenantId, "CREATE", "PushDevice", device.DeviceId, Admin.Upn,
|
||||
It.Is<Dictionary<string, string>>(d => d["label"] == "iPhone IT" && !d.ContainsKey("previousLabel"))), Times.Once);
|
||||
|
||||
var kept = await h.Sut.CreateAsync(Tenant, Operator);
|
||||
await h.Sut.RedeemAsync(h.RedeemRequest(kept.Value!.Code, label: "ops phone"));
|
||||
Assert.True((await h.Sut.ConfirmAsync(Tenant, kept.Value.PairingId, Operator, " \u0007 ")).Ok);
|
||||
Assert.Equal("ops phone", h.Repo.Devices.Values.Single(d => d.OwnerUpn == Operator.Upn).Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rename_follows_the_remove_rule_cleans_the_name_and_audits_only_a_change()
|
||||
{
|
||||
var h = new Harness();
|
||||
var adminDevice = await PairAsync(h, Admin);
|
||||
var opsDevice = await PairAsync(h, Operator);
|
||||
|
||||
// An Operator renames only their own device; someone else's answers like a missing one.
|
||||
Assert.Equal(PushOpError.NotFound, await h.Sut.RenameAsync(Tenant, adminDevice, "taken over", Operator));
|
||||
Assert.Equal(PushOpError.None, await h.Sut.RenameAsync(Tenant, opsDevice, "Ops phone", Operator));
|
||||
Assert.Equal(PushOpError.NotFound, await h.Sut.RenameAsync(Tenant, "missing", "x", Admin));
|
||||
|
||||
// A scope admin renames every device of the scope.
|
||||
Assert.Equal(PushOpError.None, await h.Sut.RenameAsync(Tenant, opsDevice, " iPhone\u0007 Otto " + new string('z', 60), Admin));
|
||||
var renamed = h.Repo.Devices[(TenantId, opsDevice)];
|
||||
Assert.StartsWith("iPhone Otto zz", renamed.Label);
|
||||
Assert.Equal(Constants.Push.MaxLabelLength, renamed.Label.Length);
|
||||
Assert.Equal(renamed.Label, (await h.Sut.ListAsync(Tenant, Admin)).Devices.Single(d => d.DeviceId == opsDevice).Label);
|
||||
h.Maintenance.Verify(m => m.LogAuditEntryAsync(TenantId, "UPDATE", "PushDevice", opsDevice, Admin.Upn,
|
||||
It.Is<Dictionary<string, string>>(d => d["label"] == renamed.Label && d["previousLabel"] == "Ops phone")), Times.Once);
|
||||
|
||||
// Blank after cleaning is refused; the same name again writes nothing and audits nothing.
|
||||
Assert.Equal(PushOpError.InvalidLabel, await h.Sut.RenameAsync(Tenant, opsDevice, " \u0007 ", Admin));
|
||||
Assert.Equal(PushOpError.InvalidLabel, await h.Sut.RenameAsync(Tenant, opsDevice, null, Admin));
|
||||
Assert.Equal(PushOpError.None, await h.Sut.RenameAsync(Tenant, opsDevice, renamed.Label, Admin));
|
||||
h.Maintenance.Verify(m => m.LogAuditEntryAsync(It.IsAny<string>(), "UPDATE", It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
/// <summary>Create → redeem → confirm: one Active device of <paramref name="owner"/>; returns its id.</summary>
|
||||
private static async Task<string> PairAsync(Harness h, PushCaller owner)
|
||||
{
|
||||
@@ -467,6 +515,8 @@ public class PushPairingServiceTests
|
||||
public VapidKeyRing Keys { get; }
|
||||
public Mock<PushEligibility> Eligibility { get; }
|
||||
public Mock<OpsEventService> OpsEvents { get; }
|
||||
/// <summary>The tenant audit log the pairing service writes to.</summary>
|
||||
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
|
||||
public RecordingHandler Http { get; } = new();
|
||||
public PushPairingService Sut { get; }
|
||||
public PushDeliveryService Delivery { get; }
|
||||
@@ -499,7 +549,7 @@ public class PushPairingServiceTests
|
||||
.ReturnsAsync((new AutopilotMonitor.Shared.Models.TenantConfiguration { TenantId = TenantId, CompanyName = "Contoso" }, true));
|
||||
|
||||
Sut = new PushPairingService(Repo, Eligibility.Object, settings, Delivery, OpsEvents.Object,
|
||||
Mock.Of<IMaintenanceRepository>(), tenantConfig.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<PushPairingService>.Instance, telemetry);
|
||||
Maintenance.Object, tenantConfig.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<PushPairingService>.Instance, telemetry);
|
||||
|
||||
// A browser-shaped subscription: a fresh P-256 point + 16-byte auth secret.
|
||||
using var ua = System.Security.Cryptography.ECDiffieHellman.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the Web Push surface of the route policy catalog: the tier and tenant scoping of every push
|
||||
/// route, that the surface is exactly these 22 routes (a 23rd cannot appear unnoticed), that only the
|
||||
/// route, that the surface is exactly these 24 routes (a 25th cannot appear unnoticed), that only the
|
||||
/// five receiver routes skip JWT validation (the phone never signs in), that no push route carries a
|
||||
/// grant-widening opt-in, and that every push path is stamped <c>Cache-Control: no-store</c>.
|
||||
/// </summary>
|
||||
@@ -14,7 +14,7 @@ public class PushRoutePolicyTests
|
||||
{
|
||||
private const string SampleId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
/// <summary>The 22 push routes, as "METHOD template" — the one list the surface test compares against.</summary>
|
||||
/// <summary>The 24 push routes, as "METHOD template" — the one list the surface test compares against.</summary>
|
||||
private static readonly string[] PinnedRoutes =
|
||||
{
|
||||
// Receiver (the phone): one-shot pairing code, then the device token in X-Push-Device-Token.
|
||||
@@ -29,6 +29,7 @@ public class PushRoutePolicyTests
|
||||
"POST push/pairings/{pairingId}/confirm",
|
||||
"POST push/pairings/{pairingId}/reject",
|
||||
"GET push/devices",
|
||||
"PATCH push/devices/{deviceId}",
|
||||
"DELETE push/devices/{deviceId}",
|
||||
"POST push/devices/{deviceId}/test",
|
||||
// Session watches: ?tenantId= like the annotations route.
|
||||
@@ -41,6 +42,7 @@ public class PushRoutePolicyTests
|
||||
"POST global/push/pairings/{pairingId}/confirm",
|
||||
"POST global/push/pairings/{pairingId}/reject",
|
||||
"GET global/push/devices",
|
||||
"PATCH global/push/devices/{deviceId}",
|
||||
"DELETE global/push/devices/{deviceId}",
|
||||
"POST global/push/devices/{deviceId}/test",
|
||||
};
|
||||
@@ -60,6 +62,7 @@ public class PushRoutePolicyTests
|
||||
[InlineData("POST", "push/pairings/{pairingId}/confirm", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("POST", "push/pairings/{pairingId}/reject", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("GET", "push/devices", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("PATCH", "push/devices/{deviceId}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("DELETE", "push/devices/{deviceId}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("POST", "push/devices/{deviceId}/test", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
// Session watches: ?tenantId= (optional, falls back to the JWT tenant; middleware cross-tenant check).
|
||||
@@ -72,6 +75,7 @@ public class PushRoutePolicyTests
|
||||
[InlineData("POST", "global/push/pairings/{pairingId}/confirm", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("POST", "global/push/pairings/{pairingId}/reject", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("GET", "global/push/devices", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("PATCH", "global/push/devices/{deviceId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("DELETE", "global/push/devices/{deviceId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("POST", "global/push/devices/{deviceId}/test", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
public void Push_route_resolves_to_its_tier_and_tenant_scoping(string method, string template, EndpointPolicy policy, TenantScoping scoping)
|
||||
@@ -85,7 +89,7 @@ public class PushRoutePolicyTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_push_surface_is_exactly_the_22_pinned_routes()
|
||||
public void The_push_surface_is_exactly_the_24_pinned_routes()
|
||||
{
|
||||
var expected = PinnedRoutes.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
var actual = PushSurface().Select(Key).OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The setup guide's markers (POST config/{tenantId}/setup-guide) and the done rules the feature
|
||||
/// flags derive from the row.
|
||||
/// </summary>
|
||||
public class SetupGuideFunctionTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 11, 9, 12, 0, 0, DateTimeKind.Utc);
|
||||
private const string Admin = "admin@contoso.com";
|
||||
|
||||
// ── step parsing ──
|
||||
|
||||
[Theory]
|
||||
[InlineData("settings", "settings")]
|
||||
[InlineData(" Hardware ", "hardware")]
|
||||
[InlineData("DISMISS", "dismiss")]
|
||||
[InlineData("restore", "restore")]
|
||||
[InlineData("validation", null)]
|
||||
[InlineData("", null)]
|
||||
[InlineData(null, null)]
|
||||
public void NormalizeStep_accepts_only_the_four_steps(string? input, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, SetupGuideFunction.NormalizeStep(input));
|
||||
}
|
||||
|
||||
// ── ApplyStep ──
|
||||
|
||||
[Fact]
|
||||
public void Settings_step_is_stamped_once()
|
||||
{
|
||||
var row = new TenantConfiguration();
|
||||
|
||||
Assert.True(SetupGuideFunction.ApplyStep(row, "settings", Admin, Now));
|
||||
Assert.Equal(Now, row.SetupGuideSettingsReviewedAt);
|
||||
Assert.Equal(Admin, row.UpdatedBy);
|
||||
|
||||
// A second completion keeps the first one on record and writes nothing.
|
||||
Assert.False(SetupGuideFunction.ApplyStep(row, "settings", "other@contoso.com", Now.AddHours(1)));
|
||||
Assert.Equal(Now, row.SetupGuideSettingsReviewedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hardware_step_is_stamped_once()
|
||||
{
|
||||
var row = new TenantConfiguration();
|
||||
|
||||
Assert.True(SetupGuideFunction.ApplyStep(row, "hardware", Admin, Now));
|
||||
Assert.Equal(Now, row.SetupGuideHardwareReviewedAt);
|
||||
Assert.False(SetupGuideFunction.ApplyStep(row, "hardware", Admin, Now.AddMinutes(5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dismiss_records_who_and_restore_clears_it()
|
||||
{
|
||||
var row = new TenantConfiguration();
|
||||
|
||||
Assert.True(SetupGuideFunction.ApplyStep(row, "dismiss", Admin, Now));
|
||||
Assert.Equal(Now, row.SetupGuideDismissedAt);
|
||||
Assert.Equal(Admin, row.SetupGuideDismissedBy);
|
||||
Assert.False(SetupGuideFunction.ApplyStep(row, "dismiss", Admin, Now.AddMinutes(1)));
|
||||
|
||||
Assert.True(SetupGuideFunction.ApplyStep(row, "restore", Admin, Now.AddMinutes(2)));
|
||||
Assert.Null(row.SetupGuideDismissedAt);
|
||||
Assert.Null(row.SetupGuideDismissedBy);
|
||||
Assert.False(SetupGuideFunction.ApplyStep(row, "restore", Admin, Now.AddMinutes(3)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_step_writes_nothing()
|
||||
{
|
||||
var row = new TenantConfiguration { UpdatedBy = "earlier" };
|
||||
|
||||
Assert.False(SetupGuideFunction.ApplyStep(row, "validation", Admin, Now));
|
||||
Assert.Equal("earlier", row.UpdatedBy);
|
||||
}
|
||||
|
||||
// ── SetupGuideState.SettingsDone ──
|
||||
|
||||
[Fact]
|
||||
public void Settings_are_open_on_a_new_tenant()
|
||||
{
|
||||
Assert.False(SetupGuideState.SettingsDone(TenantConfiguration.CreateDefault("t")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_are_done_once_reviewed_even_if_nothing_was_applied()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.SetupGuideSettingsReviewedAt = Now;
|
||||
|
||||
Assert.True(SetupGuideState.SettingsDone(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_are_done_when_all_three_are_already_on()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.EnableTimezoneAutoSet = true;
|
||||
config.EnableSoftwareInventoryAnalyzer = true;
|
||||
config.DiagnosticsUploadDestination = "Hosted";
|
||||
config.DiagnosticsUploadMode = "OnFailure";
|
||||
|
||||
Assert.True(SetupGuideState.SettingsDone(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_stay_open_while_one_of_the_three_is_off()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.EnableTimezoneAutoSet = true;
|
||||
config.EnableSoftwareInventoryAnalyzer = true;
|
||||
config.DiagnosticsUploadDestination = "Hosted";
|
||||
config.DiagnosticsUploadMode = "Off";
|
||||
|
||||
Assert.False(SetupGuideState.SettingsDone(config));
|
||||
}
|
||||
|
||||
// ── SetupGuideState.HardwareDone ──
|
||||
|
||||
[Fact]
|
||||
public void Hardware_is_open_with_the_default_whitelist()
|
||||
{
|
||||
Assert.False(SetupGuideState.HardwareDone(TenantConfiguration.CreateDefault("t")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hardware_is_done_once_reviewed()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.SetupGuideHardwareReviewedAt = Now;
|
||||
|
||||
Assert.True(SetupGuideState.HardwareDone(config));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Dell*,HP*,Lenovo*,Microsoft Corporation,VMware*", "*")]
|
||||
[InlineData("*", "*")]
|
||||
[InlineData("Dell*,HP*,Lenovo*,Microsoft Corporation", "Latitude*")]
|
||||
public void Hardware_is_done_when_the_whitelist_differs_from_the_default(string manufacturers, string models)
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.ManufacturerWhitelist = manufacturers;
|
||||
config.ModelWhitelist = models;
|
||||
|
||||
Assert.True(SetupGuideState.HardwareDone(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hardware_ignores_order_case_and_spacing_of_the_default_entries()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.ManufacturerWhitelist = "microsoft corporation, lenovo*,HP*, dell*";
|
||||
|
||||
Assert.False(SetupGuideState.HardwareDone(config));
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,30 @@ public sealed class SharedManifestParityTests
|
||||
// Limits of feedback and reports (text lengths, the report body cap, the daily feedback cap):
|
||||
// the portal's counters and upload budget read the same numbers the backend enforces.
|
||||
["submissionLimits"] = ConstInts(typeof(Constants.SubmissionLimits)),
|
||||
// Local Admin Analyzer allowed list: the built-in entries and the entry grammar. The portal's
|
||||
// Allowed Local Accounts card renders and checks against these; agent and backend use the class.
|
||||
["localAdminAllowList"] = new Dictionary<string, object?>
|
||||
{
|
||||
["roleSeparator"] = LocalAdminAllowList.RoleSeparator.ToString(),
|
||||
["standardUserQualifier"] = LocalAdminAllowList.StandardUserQualifier,
|
||||
["maxEntries"] = LocalAdminAllowList.MaxEntries,
|
||||
["maxEntryLength"] = LocalAdminAllowList.MaxEntryLength,
|
||||
["minPatternLiterals"] = LocalAdminAllowList.MinPatternLiterals,
|
||||
["forbiddenNameCharacters"] = LocalAdminAllowList.ForbiddenNameCharacters,
|
||||
["builtIns"] = LocalAdminAllowList.BuiltIns.Select(b => new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = b.Name,
|
||||
["match"] = b.Match switch
|
||||
{
|
||||
LocalAdminBuiltInMatch.Rid => "rid",
|
||||
LocalAdminBuiltInMatch.AccountName => "account",
|
||||
LocalAdminBuiltInMatch.ProfileFolder => "folder",
|
||||
_ => throw new InvalidOperationException($"Unmapped built-in match {b.Match}"),
|
||||
},
|
||||
["role"] = b.Role == LocalAccountRole.StandardUser ? "user" : "any",
|
||||
["rid"] = b.Rid,
|
||||
}).ToArray(),
|
||||
},
|
||||
// Every IApiResponse + IApiRequest implementer + [WireContract] type, transitively closed —
|
||||
// the source of lib/generated/wire-types.generated.ts. See WireTypeManifestBuilder.
|
||||
["types"] = WireTypeManifestBuilder.BuildTypesSection(),
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Azure Tables refuses <c>$top</c> above 1000 with 400 InvalidInput. A computed page size
|
||||
/// handed to <c>maxPerPage</c> unguarded reaches that limit only at the edge (a fan-out reader
|
||||
/// that asked for <c>pageSize + 1</c> answered every <c>pageSize=1000</c> request with an empty
|
||||
/// page). Ratchet: every <c>maxPerPage</c> argument is a literal of at most 1000 or goes through
|
||||
/// <see cref="AzureTablesPaginator.ServerPageSize"/> / <c>Math.Min(…, 1000)</c>.
|
||||
/// </summary>
|
||||
public class TableServerPageSizeGuardTests
|
||||
{
|
||||
private static readonly Regex AllowedForm = new(
|
||||
@"^(\d+|(AzureTablesPaginator\.)?ServerPageSize\(.+\)|Math\.Min\(.+,\s*1000\s*\))$",
|
||||
RegexOptions.Compiled | RegexOptions.Singleline);
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 1)]
|
||||
[InlineData(16, 16)]
|
||||
[InlineData(1000, 1000)]
|
||||
[InlineData(1001, 1000)]
|
||||
[InlineData(int.MaxValue, 1000)]
|
||||
public void ServerPageSize_never_exceeds_the_Azure_limit(int rows, int expected)
|
||||
{
|
||||
Assert.Equal(expected, AzureTablesPaginator.ServerPageSize(rows));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_maxPerPage_argument_stays_within_the_Azure_limit()
|
||||
{
|
||||
var root = FunctionsRoot();
|
||||
var offenders = new List<string>();
|
||||
var found = 0;
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
var rel = Path.GetRelativePath(root, path).Replace('\\', '/');
|
||||
if (rel.StartsWith("bin/", StringComparison.Ordinal) || rel.StartsWith("obj/", StringComparison.Ordinal)) continue;
|
||||
|
||||
var source = File.ReadAllText(path);
|
||||
foreach (var arg in MaxPerPageArguments(source))
|
||||
{
|
||||
found++;
|
||||
if (!IsAllowed(arg)) offenders.Add($"{rel}: maxPerPage: {arg}");
|
||||
}
|
||||
}
|
||||
|
||||
// Positive control: the scan must see the known call sites, or it proves nothing.
|
||||
Assert.True(found > 20, $"Only {found} maxPerPage arguments found — the scan lost its call sites.");
|
||||
Assert.True(offenders.Count == 0,
|
||||
"maxPerPage beyond Azure's $top limit is possible — pass AzureTablesPaginator.ServerPageSize(n) " +
|
||||
"or a literal of at most 1000:\n " + string.Join("\n ", offenders));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1000", true)]
|
||||
[InlineData("25", true)]
|
||||
[InlineData("1001", false)]
|
||||
[InlineData("limit", false)]
|
||||
[InlineData("pageSize + 1", false)]
|
||||
[InlineData("AzureTablesPaginator.ServerPageSize(limit)", true)]
|
||||
[InlineData("Math.Min(maxResults + 1, 1000)", true)]
|
||||
[InlineData("Math.Min(maxResults, 5000)", false)]
|
||||
public void IsAllowed_accepts_only_bounded_forms(string argument, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, IsAllowed(argument));
|
||||
}
|
||||
|
||||
private static bool IsAllowed(string argument)
|
||||
{
|
||||
if (!AllowedForm.IsMatch(argument)) return false;
|
||||
return !int.TryParse(argument, out var literal) || literal <= AzureTablesPaginator.MaxServerPageSize;
|
||||
}
|
||||
|
||||
/// <summary>The argument text after each <c>maxPerPage:</c>, up to the comma or parenthesis that ends it.</summary>
|
||||
private static IEnumerable<string> MaxPerPageArguments(string source)
|
||||
{
|
||||
const string marker = "maxPerPage:";
|
||||
var at = 0;
|
||||
while ((at = source.IndexOf(marker, at, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
var i = at + marker.Length;
|
||||
at = i;
|
||||
// A mention inside a comment ("(maxPerPage: 1)") is not a call site.
|
||||
var lineStart = source.LastIndexOf('\n', i) + 1;
|
||||
if (source.AsSpan(lineStart, i - lineStart).TrimStart().StartsWith("//")) continue;
|
||||
|
||||
var depth = 0;
|
||||
var start = i;
|
||||
for (; i < source.Length; i++)
|
||||
{
|
||||
var c = source[i];
|
||||
if (c == '(') depth++;
|
||||
else if (c == ')') { if (depth == 0) break; depth--; }
|
||||
else if (c == ',' && depth == 0) break;
|
||||
}
|
||||
yield return source[start..i].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
private static string FunctionsRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
return Path.Combine(dir!.FullName, "src", "Backend", "AutopilotMonitor.Functions");
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,10 @@ public class TenantConfigPatchServiceTests
|
||||
[InlineData("intuneDeviceBindingPermissionMissingConfirmedAt")]
|
||||
[InlineData("cloudPcPermissionMissingSince")]
|
||||
[InlineData("cloudPcPermissionMissingConfirmedAt")]
|
||||
[InlineData("setupGuideSettingsReviewedAt")]
|
||||
[InlineData("setupGuideHardwareReviewedAt")]
|
||||
[InlineData("setupGuideDismissedAt")]
|
||||
[InlineData("setupGuideDismissedBy")]
|
||||
public async Task Patch_DeniedField_Returns400WithFieldName(string field)
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
@@ -485,6 +489,36 @@ public class TenantConfigPatchServiceTests
|
||||
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[\"*\"]")]
|
||||
[InlineData("[\"kiosk|admin\"]")]
|
||||
[InlineData("[\"S-1-12-1-1-2-3-4\"]")]
|
||||
[InlineData("\"kiosk\"")]
|
||||
public async Task Patch_InvalidAllowedLocalAccount_ValidationFails_NoWrite(string json)
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("localAdminAllowedAccountsJson", json)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
|
||||
Assert.Contains("allowed local accounts", outcome.Error);
|
||||
Assert.Equal(0, harness.ReplaceCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_StandardUserAllowedLocalAccount_Applies()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("localAdminAllowedAccountsJson", "[\"adm-*\",\"kiosk|user\"]")), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.True(outcome.Success, outcome.Error);
|
||||
Assert.Equal("[\"adm-*\",\"kiosk|user\"]", harness.Current!.LocalAdminAllowedAccountsJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_UnrestrictedModeWithoutGate_ExplicitError_NotSilentFlip()
|
||||
{
|
||||
|
||||
@@ -247,6 +247,34 @@ public class TenantConfigTableSerializationTests
|
||||
Assert.Null(mapped.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Null(mapped.IntuneDeviceBindingPermissionMissingSince);
|
||||
Assert.Null(mapped.CloudPcPermissionMissingConfirmedAt);
|
||||
Assert.Null(mapped.SetupGuideSettingsReviewedAt);
|
||||
Assert.Null(mapped.SetupGuideHardwareReviewedAt);
|
||||
Assert.Null(mapped.SetupGuideDismissedAt);
|
||||
Assert.Null(mapped.SetupGuideDismissedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_SetupGuideFields_SurviveStoreAndMap()
|
||||
{
|
||||
var at = new DateTime(2026, 11, 9, 8, 0, 0, DateTimeKind.Utc);
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
DomainName = "contoso.com",
|
||||
UpdatedBy = "admin@contoso.com",
|
||||
SetupGuideSettingsReviewedAt = at,
|
||||
SetupGuideHardwareReviewedAt = at.AddMinutes(2),
|
||||
SetupGuideDismissedAt = at.AddDays(1),
|
||||
SetupGuideDismissedBy = "admin@contoso.com",
|
||||
};
|
||||
|
||||
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(config));
|
||||
|
||||
Assert.Equal(at, mapped.SetupGuideSettingsReviewedAt);
|
||||
Assert.Equal(at.AddMinutes(2), mapped.SetupGuideHardwareReviewedAt);
|
||||
Assert.Equal(at.AddDays(1), mapped.SetupGuideDismissedAt);
|
||||
Assert.Equal("admin@contoso.com", mapped.SetupGuideDismissedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -393,6 +393,10 @@ public class TypedContractCompletionWireParityTests
|
||||
trialAvailable = true,
|
||||
contactEmailSet = false,
|
||||
companyNameSet = false,
|
||||
setupGuideSettingsDone = true,
|
||||
setupGuideHardwareDone = false,
|
||||
setupGuideDismissed = false,
|
||||
hasEnrollmentSessions = false,
|
||||
entitlements = new
|
||||
{
|
||||
retentionCapDays = 90,
|
||||
@@ -424,6 +428,10 @@ public class TypedContractCompletionWireParityTests
|
||||
TrialAvailable = true,
|
||||
ContactEmailSet = false,
|
||||
CompanyNameSet = false,
|
||||
SetupGuideSettingsDone = true,
|
||||
SetupGuideHardwareDone = false,
|
||||
SetupGuideDismissed = false,
|
||||
HasEnrollmentSessions = false,
|
||||
Entitlements = new TenantFeatureEntitlements
|
||||
{
|
||||
RetentionCapDays = 90,
|
||||
|
||||
+5
@@ -34,6 +34,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
/// exactly when more than <c>pageSize</c> were read in total: if no partition reached its limit,
|
||||
/// every partition is exhausted.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// That limit is a row count, not an Azure page size: Azure Tables refuses <c>$top</c> above
|
||||
/// 1000, so a reader passes <c>AzureTablesPaginator.ServerPageSize(limit)</c> as
|
||||
/// <c>maxPerPage</c> and keeps reading across server pages until it has its limit.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class PerPartitionFanOutMerge
|
||||
{
|
||||
|
||||
+9
@@ -42,6 +42,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
"GraphAccessRejectedSince", "GraphAccessRejectedCode", "GraphAccessRejectionConfirmedAt",
|
||||
"IntuneDeviceBindingPermissionMissingSince", "IntuneDeviceBindingPermissionMissingConfirmedAt",
|
||||
"CloudPcPermissionMissingSince", "CloudPcPermissionMissingConfirmedAt",
|
||||
"SetupGuideSettingsReviewedAt", "SetupGuideHardwareReviewedAt", "SetupGuideDismissedAt", "SetupGuideDismissedBy",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> AdminBackupNoiseProperties = new(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -743,6 +744,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "DpaAcceptancePending", config.DpaAcceptancePending },
|
||||
{ "DpaAcceptedBy", config.DpaAcceptedBy },
|
||||
{ "DpaAcceptedAt", config.DpaAcceptedAt },
|
||||
{ "SetupGuideSettingsReviewedAt", config.SetupGuideSettingsReviewedAt },
|
||||
{ "SetupGuideHardwareReviewedAt", config.SetupGuideHardwareReviewedAt },
|
||||
{ "SetupGuideDismissedAt", config.SetupGuideDismissedAt },
|
||||
{ "SetupGuideDismissedBy", config.SetupGuideDismissedBy },
|
||||
{ "ContactEmail", config.ContactEmail },
|
||||
{ "CompanyName", config.CompanyName },
|
||||
{ "Disabled", config.Disabled },
|
||||
@@ -865,6 +870,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
DpaAcceptancePending = entity.GetBoolean("DpaAcceptancePending") ?? false,
|
||||
DpaAcceptedBy = entity.GetString("DpaAcceptedBy"),
|
||||
DpaAcceptedAt = entity.GetDateTime("DpaAcceptedAt"),
|
||||
SetupGuideSettingsReviewedAt = entity.GetDateTime("SetupGuideSettingsReviewedAt"),
|
||||
SetupGuideHardwareReviewedAt = entity.GetDateTime("SetupGuideHardwareReviewedAt"),
|
||||
SetupGuideDismissedAt = entity.GetDateTime("SetupGuideDismissedAt"),
|
||||
SetupGuideDismissedBy = entity.GetString("SetupGuideDismissedBy"),
|
||||
ContactEmail = entity.GetString("ContactEmail"),
|
||||
CompanyName = entity.GetString("CompanyName"),
|
||||
Disabled = entity.GetBoolean("Disabled") ?? false,
|
||||
|
||||
+5
-2
@@ -3,9 +3,11 @@ using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
@@ -63,12 +65,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<List<DistressReportEntry>> GetDistressReportsAsync(string tenantId, int maxResults = 100)
|
||||
public async Task<List<DistressReportEntry>> GetDistressReportsAsync(string tenantId, DistressErrorType errorType, int maxResults = 100)
|
||||
{
|
||||
var pk = tenantId.ToLowerInvariant();
|
||||
var result = new List<DistressReportEntry>();
|
||||
var filter = TableClient.CreateQueryFilter($"PartitionKey eq {pk} and ErrorType eq {errorType.ToString()}");
|
||||
|
||||
await foreach (var entity in _table.QueryAsync<TableEntity>(e => e.PartitionKey == pk, maxPerPage: maxResults))
|
||||
await foreach (var entity in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: AzureTablesPaginator.ServerPageSize(maxResults)))
|
||||
{
|
||||
result.Add(MapToEntry(entity));
|
||||
if (result.Count >= maxResults)
|
||||
|
||||
+3
@@ -66,6 +66,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<TenantStats?> GetTenantStatsAsync(string tenantId)
|
||||
=> _storage.GetTenantStatsAsync(tenantId);
|
||||
|
||||
public Task<bool> TenantHasEnrollmentsAsync(string tenantId)
|
||||
=> _storage.TenantHasEnrollmentsAsync(tenantId);
|
||||
|
||||
public Task<List<TenantStats>> GetAllTenantStatsAsync()
|
||||
=> _storage.GetAllTenantStatsAsync();
|
||||
|
||||
|
||||
+20
-28
@@ -86,36 +86,28 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
OpsEventQueryFilters? filters = null)
|
||||
{
|
||||
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||
try
|
||||
// Single-category path: PK-targeted query, RowKey is reverse-tick →
|
||||
// Azure-native (PK asc, RK asc) already yields newest-first. No re-sort.
|
||||
if (!string.IsNullOrEmpty(category))
|
||||
{
|
||||
// Single-category path: PK-targeted query, RowKey is reverse-tick →
|
||||
// Azure-native (PK asc, RK asc) already yields newest-first. No re-sort.
|
||||
if (!string.IsNullOrEmpty(category))
|
||||
{
|
||||
var filter = BuildFilter(category, dateFrom, dateTo, filters);
|
||||
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: _table,
|
||||
filter: filter,
|
||||
pageSize: pageSize,
|
||||
continuation: continuation);
|
||||
var filter = BuildFilter(category, dateFrom, dateTo, filters);
|
||||
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: _table,
|
||||
filter: filter,
|
||||
pageSize: pageSize,
|
||||
continuation: continuation);
|
||||
|
||||
var page = new List<OpsEventEntry>(entities.Count);
|
||||
foreach (var entity in entities) page.Add(MapToEntry(entity));
|
||||
return new RawPage<OpsEventEntry>(page, nextRawToken);
|
||||
}
|
||||
var page = new List<OpsEventEntry>(entities.Count);
|
||||
foreach (var entity in entities) page.Add(MapToEntry(entity));
|
||||
return new RawPage<OpsEventEntry>(page, nextRawToken);
|
||||
}
|
||||
|
||||
// All-category path: per-partition fan-out + merge-sort. Azure pages
|
||||
// cross-partition queries by (PK asc, RK asc), so without this fan-out
|
||||
// the first page would come entirely from the alphabetically-first
|
||||
// category — defeating "newest first globally". Mirrors the same
|
||||
// pattern used for cross-tenant session queries.
|
||||
return await FanOutAcrossCategoriesAsync(dateFrom, dateTo, pageSize, continuation, filters);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get ops events page");
|
||||
return RawPage<OpsEventEntry>.Empty;
|
||||
}
|
||||
// All-category path: per-partition fan-out + merge-sort. Azure pages
|
||||
// cross-partition queries by (PK asc, RK asc), so without this fan-out
|
||||
// the first page would come entirely from the alphabetically-first
|
||||
// category — defeating "newest first globally". Mirrors the same
|
||||
// pattern used for cross-tenant session queries.
|
||||
return await FanOutAcrossCategoriesAsync(dateFrom, dateTo, pageSize, continuation, filters);
|
||||
}
|
||||
|
||||
// Fixed list, owned by OpsEventCategory.All so it cannot drift from the vocabulary. It did
|
||||
@@ -135,7 +127,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
var filter = BuildFilterWithRowKeyBound(category, dateFrom, dateTo, bound, filters);
|
||||
var read = new List<(string RowKey, OpsEventEntry Item)>();
|
||||
await foreach (var e in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: limit, cancellationToken: ct))
|
||||
await foreach (var e in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: AzureTablesPaginator.ServerPageSize(limit), cancellationToken: ct))
|
||||
{
|
||||
read.Add((e.RowKey, MapToEntry(e)));
|
||||
if (read.Count >= limit) break;
|
||||
|
||||
+43
-3
@@ -1,9 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -16,15 +18,25 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
private readonly ILogger<GetTenantFeatureFlagsFunction> _logger;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AppHomingService _appHomingService;
|
||||
private readonly IMetricsRepository _metricsRepository;
|
||||
|
||||
/// <summary>
|
||||
/// Tenants whose enrollment counter was seen above zero. The counter only grows, so a seen
|
||||
/// "true" costs no further read on this instance. An offboarded and re-onboarded tenant starts
|
||||
/// at zero again; until this instance recycles, its setup guide then stays hidden here.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<string, bool> KnownEnrolledTenants = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public GetTenantFeatureFlagsFunction(
|
||||
ILogger<GetTenantFeatureFlagsFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
AppHomingService appHomingService)
|
||||
AppHomingService appHomingService,
|
||||
IMetricsRepository metricsRepository)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_appHomingService = appHomingService;
|
||||
_metricsRepository = metricsRepository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -50,9 +62,13 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
|
||||
var config = 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
|
||||
? await HasEnrollmentSessionsAsync(requestCtx.TargetTenantId)
|
||||
: null;
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(BuildPayload(config, DateTime.UtcNow, appHomingFunnelActive));
|
||||
await response.WriteAsJsonAsync(BuildPayload(config, DateTime.UtcNow, appHomingFunnelActive, hasEnrollmentSessions));
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -61,13 +77,31 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Null when the counter could not be read: the guide then stays hidden rather than guessing.</summary>
|
||||
private async Task<bool?> HasEnrollmentSessionsAsync(string tenantId)
|
||||
{
|
||||
if (KnownEnrolledTenants.ContainsKey(tenantId)) return true;
|
||||
try
|
||||
{
|
||||
var has = await _metricsRepository.TenantHasEnrollmentsAsync(tenantId);
|
||||
if (has) KnownEnrolledTenants.TryAdd(tenantId, true);
|
||||
return has;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Feature flags: enrollment history of tenant {TenantId} unknown", tenantId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects the member-readable subset of <see cref="TenantConfiguration"/>.
|
||||
/// Pulled out as a static method so unit tests can verify field-level mapping
|
||||
/// without standing up an HttpRequestData mock. <paramref name="nowUtc"/> feeds the
|
||||
/// read-time edition resolution (trial expiry degrades automatically).
|
||||
/// </summary>
|
||||
internal static TenantFeatureFlagsResponse BuildPayload(TenantConfiguration config, DateTime nowUtc, bool appHomingFunnelActive = false)
|
||||
internal static TenantFeatureFlagsResponse BuildPayload(
|
||||
TenantConfiguration config, DateTime nowUtc, bool appHomingFunnelActive = false, bool? hasEnrollmentSessions = null)
|
||||
{
|
||||
// Edition + source in one resolution: Pro from the plan, from a trial, or conferred by the
|
||||
// managing tenant ("msp" — shown as "Pro (MSP)", entitlements without the delegation right).
|
||||
@@ -126,6 +160,12 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
ContactEmailSet = !string.IsNullOrWhiteSpace(config.ContactEmail),
|
||||
// Same contract for the second half of the contact profile: boolean only.
|
||||
CompanyNameSet = !string.IsNullOrWhiteSpace(config.CompanyName),
|
||||
// Setup guide checklist (dashboard card, sidebar entry): done states and the
|
||||
// dismissal. Booleans only, derived from the row and the enrollment counter.
|
||||
SetupGuideSettingsDone = SetupGuideState.SettingsDone(config),
|
||||
SetupGuideHardwareDone = SetupGuideState.HardwareDone(config),
|
||||
SetupGuideDismissed = config.SetupGuideDismissedAt != null,
|
||||
HasEnrollmentSessions = hasEnrollmentSessions,
|
||||
Entitlements = new TenantFeatureEntitlements
|
||||
{
|
||||
RetentionCapDays = entitlements.RetentionCapDays,
|
||||
|
||||
@@ -223,7 +223,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
return true;
|
||||
}, "plan", "plan tier change");
|
||||
|
||||
if (await UpdateFailureAsync(req, update) is { } failure)
|
||||
if (await TenantConfigUpdateResponses.FailureAsync(req, update) is { } failure)
|
||||
return failure;
|
||||
var config = update.Config!;
|
||||
|
||||
@@ -406,28 +406,6 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
private static IEnumerable<string> KnownUsagePlanNames(IEnumerable<PlanTierDefinition> definitions)
|
||||
=> definitions.Select(d => TenantEntitlementService.NormalizePlanName(d.Name)).Where(n => n != null).Select(n => n!).OrderBy(n => n, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// The response for an <see cref="TenantConfigurationService.UpdateAsync"/> outcome that wrote
|
||||
/// nothing for a reason other than the caller's own decision; null for Updated and Declined.
|
||||
/// </summary>
|
||||
private static async Task<HttpResponseData?> UpdateFailureAsync(HttpRequestData req, TenantConfigUpdate update)
|
||||
{
|
||||
switch (update.Status)
|
||||
{
|
||||
case TenantConfigUpdateStatus.NotFound:
|
||||
return await req.NotFoundAsync("Tenant not found");
|
||||
case TenantConfigUpdateStatus.OffboardingInProgress:
|
||||
// The answer the suspension gate gives once its cache has caught up with the tombstone.
|
||||
return await req.ErrorAsync(HttpStatusCode.Forbidden,
|
||||
Constants.ApiErrorCodes.TenantSuspended, TenantOffboardFunction.OffboardingDisabledReason);
|
||||
case TenantConfigUpdateStatus.Conflict:
|
||||
return await req.ErrorAsync(HttpStatusCode.Conflict,
|
||||
Constants.ApiErrorCodes.Conflict, "The tenant configuration was changed concurrently. Retry shortly.");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure verdict for the self-service trial start; null = allowed. Order matters and is
|
||||
/// test-pinned: the terminal conditions (trial consumed, already Pro) win over the
|
||||
@@ -511,7 +489,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
return true;
|
||||
}, "plan", "self-service trial start");
|
||||
|
||||
if (await UpdateFailureAsync(req, update) is { } failure)
|
||||
if (await TenantConfigUpdateResponses.FailureAsync(req, update) is { } failure)
|
||||
return failure;
|
||||
if (deny is { } refused)
|
||||
return await req.ErrorAsync(HttpStatusCode.Conflict, refused.Code, refused.Message);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
/// <summary>
|
||||
/// The setup guide's markers on the tenant row. The guide applies settings through the field
|
||||
/// PATCH like any settings card; this endpoint only stamps what the PATCH cannot: that an admin
|
||||
/// finished a step, or hid the guide for the whole tenant. Server time and the caller's UPN.
|
||||
/// </summary>
|
||||
public class SetupGuideFunction
|
||||
{
|
||||
internal const string StepSettings = "settings";
|
||||
internal const string StepHardware = "hardware";
|
||||
internal const string StepDismiss = "dismiss";
|
||||
internal const string StepRestore = "restore";
|
||||
|
||||
private readonly ILogger<SetupGuideFunction> _logger;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
|
||||
public SetupGuideFunction(
|
||||
ILogger<SetupGuideFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
IMaintenanceRepository maintenanceRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/config/{tenantId}/setup-guide — TenantAdminOrGA (catalog-enforced), refused in
|
||||
/// view-as. Body <c>{ "step": "settings" | "hardware" | "dismiss" | "restore" }</c>. A step that
|
||||
/// is already recorded writes nothing and answers with the current markers.
|
||||
/// </summary>
|
||||
[Function("RecordSetupGuideStep")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "config/{tenantId}/setup-guide")] HttpRequestData req,
|
||||
string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var read = await req.ReadAsync<SetupGuideStepRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
|
||||
var step = NormalizeStep(read.Value!.Step);
|
||||
if (step == null)
|
||||
return await req.BadRequestAsync("step must be one of: settings, hardware, dismiss, restore.");
|
||||
|
||||
var caller = requestCtx.UserPrincipalName ?? "Unknown";
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var update = await _configService.UpdateAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
row => ApplyStep(row, step, caller, nowUtc),
|
||||
"setup-guide",
|
||||
$"setup guide: {step}");
|
||||
|
||||
if (await TenantConfigUpdateResponses.FailureAsync(req, update) is { } failure)
|
||||
return failure;
|
||||
var config = update.Config!;
|
||||
|
||||
if (update.Status == TenantConfigUpdateStatus.Updated)
|
||||
{
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
"UPDATE",
|
||||
"SetupGuide",
|
||||
requestCtx.TargetTenantId,
|
||||
caller,
|
||||
new Dictionary<string, string> { { "Step", step } });
|
||||
}
|
||||
|
||||
return await req.OkAsync(new SetupGuideStateResponse
|
||||
{
|
||||
Success = true,
|
||||
SettingsReviewedAt = config.SetupGuideSettingsReviewedAt,
|
||||
HardwareReviewedAt = config.SetupGuideHardwareReviewedAt,
|
||||
DismissedAt = config.SetupGuideDismissedAt,
|
||||
DismissedBy = config.SetupGuideDismissedBy,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "RecordSetupGuideStep");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The known step in lower case, or null.</summary>
|
||||
internal static string? NormalizeStep(string? step)
|
||||
{
|
||||
var value = step?.Trim().ToLowerInvariant();
|
||||
return value is StepSettings or StepHardware or StepDismiss or StepRestore ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stamps <paramref name="step"/> on the fresh row inside the conditional write. False (nothing to
|
||||
/// write) when the step is already recorded — the first completion stays the one on record.
|
||||
/// </summary>
|
||||
internal static bool ApplyStep(TenantConfiguration row, string step, string caller, DateTime nowUtc)
|
||||
{
|
||||
switch (step)
|
||||
{
|
||||
case StepSettings:
|
||||
if (row.SetupGuideSettingsReviewedAt != null) return false;
|
||||
row.SetupGuideSettingsReviewedAt = nowUtc;
|
||||
break;
|
||||
case StepHardware:
|
||||
if (row.SetupGuideHardwareReviewedAt != null) return false;
|
||||
row.SetupGuideHardwareReviewedAt = nowUtc;
|
||||
break;
|
||||
case StepDismiss:
|
||||
if (row.SetupGuideDismissedAt != null) return false;
|
||||
row.SetupGuideDismissedAt = nowUtc;
|
||||
row.SetupGuideDismissedBy = caller;
|
||||
break;
|
||||
case StepRestore:
|
||||
if (row.SetupGuideDismissedAt == null) return false;
|
||||
row.SetupGuideDismissedAt = null;
|
||||
row.SetupGuideDismissedBy = null;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
row.UpdatedBy = caller;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -890,6 +890,12 @@ public class AuthFunction
|
||||
WhatsNewSeenPlatformUtc = whatsNewSeenPlatformUtc,
|
||||
WhatsNewSeenAgentUtc = whatsNewSeenAgentUtc,
|
||||
DpaAcceptancePending = tenantConfig.DpaAcceptancePending,
|
||||
// Navbar label, for the callers the config GET serves too (a tenant role, or a Global
|
||||
// Admin); a roleless member only gets the Progress Portal. In view-as tenantConfig is
|
||||
// the viewed tenant's row and the role the emulated one.
|
||||
CompanyName = (role != null || isGlobalAdmin) && !string.IsNullOrWhiteSpace(tenantConfig.CompanyName)
|
||||
? tenantConfig.CompanyName.Trim()
|
||||
: null,
|
||||
ViewAs = viewAs
|
||||
}, needsAutoAdmin);
|
||||
}
|
||||
|
||||
@@ -148,8 +148,9 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
if (!rateLimitResult.IsAllowed)
|
||||
return req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
// Gate 5: Tenant existence check (cached — cheap O(1) lookup)
|
||||
var (_, exists) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
|
||||
// Gate 5: Tenant existence check (cached — cheap O(1) lookup). The config also decides
|
||||
// the effective error type below.
|
||||
var (tenantConfig, exists) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
|
||||
if (!exists)
|
||||
return req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
@@ -223,11 +224,14 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
var espPhase = SanitizeToken(report.EspPhase, MaxEspPhaseLength, EspPhasePattern);
|
||||
var deviceBootUtc = SanitizeDeviceBoot(report.DeviceBootUtc, report.Timestamp);
|
||||
|
||||
var errorType = ResolveErrorType(
|
||||
report.ErrorType, report.HttpStatusCode, manufacturer, model, report.CertSourceState, tenantConfig);
|
||||
|
||||
// Persist to Table Storage
|
||||
var entry = new DistressReportEntry
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ErrorType = report.ErrorType.ToString(),
|
||||
ErrorType = errorType.ToString(),
|
||||
Manufacturer = manufacturer,
|
||||
Model = model,
|
||||
SerialNumber = serialNumber,
|
||||
@@ -256,28 +260,24 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
// Notify tenant channels for hardware whitelist rejections (opt-in). Durable via
|
||||
// the notification-dispatch queue — a fire-and-forget send after the response was
|
||||
// not counted as open work by the host and could be dropped on scale-in.
|
||||
if (report.ErrorType == DistressErrorType.HardwareNotAllowed
|
||||
if (errorType == DistressErrorType.HardwareNotAllowed
|
||||
&& _hardwareThrottle.ShouldNotify(tenantId, manufacturer, model))
|
||||
{
|
||||
try
|
||||
{
|
||||
var (config, configExists) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
|
||||
if (configExists)
|
||||
var hwChannels = tenantConfig.GetNotificationChannels()
|
||||
.Where(c => c.Enabled && c.NotifyOnHardwareRejection)
|
||||
.ToList();
|
||||
if (hwChannels.Count > 0)
|
||||
{
|
||||
var hwChannels = config!.GetNotificationChannels()
|
||||
.Where(c => c.Enabled && c.NotifyOnHardwareRejection)
|
||||
.ToList();
|
||||
if (hwChannels.Count > 0)
|
||||
var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert(manufacturer, model, serialNumber);
|
||||
await _notificationProducer.EnqueueAsync(new NotificationDispatchEnvelope
|
||||
{
|
||||
var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert(manufacturer, model, serialNumber);
|
||||
await _notificationProducer.EnqueueAsync(new NotificationDispatchEnvelope
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ChannelIds = hwChannels.Select(c => c.Id).ToList(),
|
||||
Alert = alert,
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
TenantId = tenantId,
|
||||
ChannelIds = hwChannels.Select(c => c.Id).ToList(),
|
||||
Alert = alert,
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception notifyEx)
|
||||
@@ -289,7 +289,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
// In-app bell notification on first-ever hardware rejection per (tenant, manufacturer, model).
|
||||
// Lifetime dedup via tracker table — independent of the webhook 24h in-memory throttle so
|
||||
// tenant admins see a one-shot persistent notification even if the webhook is not configured.
|
||||
if (report.ErrorType == DistressErrorType.HardwareNotAllowed
|
||||
if (errorType == DistressErrorType.HardwareNotAllowed
|
||||
&& !string.IsNullOrEmpty(manufacturer) && !string.IsNullOrEmpty(model))
|
||||
{
|
||||
try
|
||||
@@ -318,7 +318,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
// No webhook — volume is tiny and the remediation is not time-critical. Reports
|
||||
// without a serial number cannot be deduped and fire no bell; they still appear
|
||||
// in the panel.
|
||||
if (report.ErrorType == DistressErrorType.TpmPssUnsupported
|
||||
if (errorType == DistressErrorType.TpmPssUnsupported
|
||||
&& !string.IsNullOrEmpty(serialNumber))
|
||||
{
|
||||
try
|
||||
@@ -343,8 +343,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
|
||||
// Structured log (Warning, not Critical — data is unverified)
|
||||
_logger.LogWarning(
|
||||
"AgentDistress [{ErrorType}] tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} cloudPc={IsCloudPc} http={HttpStatusCode} ver={AgentVersion} os={OsBuild} esp={EspPhase} oobe={OobeState} boot={DeviceBootUtc} tpm={TpmVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
|
||||
report.ErrorType, tenantId, manufacturer, model, serialNumber, report.IsCloudPc,
|
||||
"AgentDistress [{ErrorType}] reported={ReportedErrorType} tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} cloudPc={IsCloudPc} http={HttpStatusCode} ver={AgentVersion} os={OsBuild} esp={EspPhase} oobe={OobeState} boot={DeviceBootUtc} tpm={TpmVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
|
||||
errorType, report.ErrorType, tenantId, manufacturer, model, serialNumber, report.IsCloudPc,
|
||||
report.HttpStatusCode, agentVersion, osBuild ?? "n/a", espPhase ?? "n/a", oobeState ?? "n/a",
|
||||
deviceBootUtc?.ToString("O") ?? "n/a", tpmVersion ?? "n/a",
|
||||
certSourceState ?? "n/a", certThumbprint ?? "n/a", message);
|
||||
@@ -354,7 +354,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
_telemetryClient.TrackEvent("AgentDistressReport", new Dictionary<string, string>
|
||||
{
|
||||
["TenantId"] = tenantId,
|
||||
["ErrorType"] = report.ErrorType.ToString(),
|
||||
["ErrorType"] = errorType.ToString(),
|
||||
["ReportedErrorType"] = report.ErrorType.ToString(),
|
||||
["Manufacturer"] = manufacturer ?? string.Empty,
|
||||
["Model"] = model ?? string.Empty,
|
||||
["SerialNumber"] = serialNumber ?? string.Empty,
|
||||
@@ -387,6 +388,31 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The error type to store and act on. The agent reports every backend 403 as
|
||||
/// <see cref="DistressErrorType.DeviceNotRegistered"/>, but <c>SecurityValidator</c> checks the
|
||||
/// hardware whitelist before it looks up the device's registration: a 403 for hardware
|
||||
/// outside the whitelist is a hardware rejection, and only as one does it reach the bell,
|
||||
/// the webhook and the rejected-hardware list. The conditions follow the validator's order:
|
||||
/// a suspended tenant, a tenant without any validation and bootstrap-token auth (no
|
||||
/// certificate) are refused or let through before the hardware check, so their 403 stays as
|
||||
/// reported. If that order changes, this has to change with it.
|
||||
/// </summary>
|
||||
internal static DistressErrorType ResolveErrorType(
|
||||
DistressErrorType reported, int? httpStatusCode, string? manufacturer, string? model,
|
||||
DistressCertSourceState? certSourceState, TenantConfiguration config)
|
||||
{
|
||||
if (reported != DistressErrorType.DeviceNotRegistered || httpStatusCode != 403) return reported;
|
||||
if (string.IsNullOrEmpty(manufacturer) || string.IsNullOrEmpty(model)) return reported;
|
||||
if (config.IsCurrentlyDisabled()) return reported;
|
||||
if (!config.HasAnyDeviceValidation() && !config.AllowInsecureAgentRequests) return reported;
|
||||
if (certSourceState == DistressCertSourceState.NoCertInStore) return reported;
|
||||
|
||||
var hardware = HardwareWhitelistValidator.ValidateHardware(
|
||||
manufacturer, model, config.GetManufacturerWhitelist(), config.GetModelWhitelist());
|
||||
return hardware.IsValid ? reported : DistressErrorType.HardwareNotAllowed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a distress report timestamp is within acceptable range.
|
||||
/// Rejects timestamps older than 24 hours or more than 5 minutes in the future.
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -10,7 +11,7 @@ namespace AutopilotMonitor.Functions.Functions.Push;
|
||||
|
||||
/// <summary>
|
||||
/// Device management from the portal: list (own devices for everyone on the tier, every device
|
||||
/// of the scope for its admins), remove (wipe push, then the row is gone) and test. Tenant routes
|
||||
/// of the scope for its admins), rename, remove (wipe push, then the row is gone) and test. Tenant routes
|
||||
/// under <c>push/devices…</c> (TenantAdminOrOperator), platform routes under
|
||||
/// <c>global/push/devices…</c> (GlobalAdminOnly).
|
||||
/// </summary>
|
||||
@@ -36,6 +37,12 @@ public class PushDevicesFunctions
|
||||
string deviceId)
|
||||
=> DeleteCoreAsync(req, deviceId, PushFunctionSupport.TenantContext(req));
|
||||
|
||||
[Function("RenamePushDevice")]
|
||||
public Task<HttpResponseData> Rename(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "push/devices/{deviceId}")] HttpRequestData req,
|
||||
string deviceId)
|
||||
=> RenameCoreAsync(req, deviceId, PushFunctionSupport.TenantContext(req));
|
||||
|
||||
[Function("TestPushDevice")]
|
||||
public Task<HttpResponseData> Test(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "push/devices/{deviceId}/test")] HttpRequestData req,
|
||||
@@ -53,6 +60,12 @@ public class PushDevicesFunctions
|
||||
string deviceId)
|
||||
=> DeleteCoreAsync(req, deviceId, PushFunctionSupport.PlatformContext(req));
|
||||
|
||||
[Function("RenamePlatformPushDevice")]
|
||||
public Task<HttpResponseData> RenamePlatform(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "global/push/devices/{deviceId}")] HttpRequestData req,
|
||||
string deviceId)
|
||||
=> RenameCoreAsync(req, deviceId, PushFunctionSupport.PlatformContext(req));
|
||||
|
||||
[Function("TestPlatformPushDevice")]
|
||||
public Task<HttpResponseData> TestPlatform(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/push/devices/{deviceId}/test")] HttpRequestData req,
|
||||
@@ -88,6 +101,25 @@ public class PushDevicesFunctions
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpResponseData> RenameCoreAsync(HttpRequestData req, string deviceId, (NotificationScope Scope, PushCaller Caller) ctx)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!PushFunctionSupport.IsPlausibleId(deviceId))
|
||||
return await req.NotFoundAsync("Device not found.");
|
||||
var read = await req.ReadAsync<RenamePushDeviceRequest>(PushFunctionSupport.LabelBodyMaxBytes);
|
||||
if (read.Error != null) return read.Error;
|
||||
var error = await _pairing.RenameAsync(ctx.Scope, deviceId, read.Value!.Label, ctx.Caller);
|
||||
if (error != PushOpError.None)
|
||||
return await PushFunctionSupport.ErrorAsync(req, error, error == PushOpError.NotFound ? "Device not found." : null);
|
||||
return req.CreateResponse(HttpStatusCode.NoContent);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "RenamePushDevice");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpResponseData> TestCoreAsync(HttpRequestData req, string deviceId, (NotificationScope Scope, PushCaller Caller) ctx)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -15,6 +15,9 @@ namespace AutopilotMonitor.Functions.Functions.Push;
|
||||
/// </summary>
|
||||
internal static class PushFunctionSupport
|
||||
{
|
||||
/// <summary>Body cap of the portal routes that carry only a device label.</summary>
|
||||
public const long LabelBodyMaxBytes = 1024;
|
||||
|
||||
/// <summary>
|
||||
/// The tenant scope of a <c>push/…</c> route: the caller's own tenant. A Global Admin passes
|
||||
/// the TenantAdminOrOperator tier by bypass, so pairing re-checks the member role (K2/K5) and
|
||||
@@ -57,6 +60,7 @@ internal static class PushFunctionSupport
|
||||
PushOpError.NotConfigured => req.ErrorAsync(HttpStatusCode.ServiceUnavailable, Constants.ApiErrorCodes.ServiceUnavailable,
|
||||
"The push channel is not configured on the platform."),
|
||||
PushOpError.Conflict => req.ConflictAsync(message ?? "The operation conflicts with the current state."),
|
||||
PushOpError.InvalidLabel => req.BadRequestAsync(message ?? "Enter a device name."),
|
||||
_ => req.InternalServerErrorAsync(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance,
|
||||
new InvalidOperationException("unmapped push error"), "Push"),
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -114,7 +115,10 @@ public class PushPairingFunctions
|
||||
{
|
||||
if (!PushFunctionSupport.IsPlausibleId(pairingId))
|
||||
return await req.NotFoundAsync("Pairing not found.");
|
||||
var result = await _pairing.ConfirmAsync(ctx.Scope, pairingId, ctx.Caller);
|
||||
// The body is optional: without one the device keeps the name it sent.
|
||||
var read = await req.ReadOptionalAsync<ConfirmPairingRequest>(PushFunctionSupport.LabelBodyMaxBytes);
|
||||
if (read.Error != null) return read.Error;
|
||||
var result = await _pairing.ConfirmAsync(ctx.Scope, pairingId, ctx.Caller, read.Value?.Label);
|
||||
return result.Ok
|
||||
? await req.OkAsync(result.Value!)
|
||||
: await PushFunctionSupport.ErrorAsync(req, result.Error, result.Message ?? "Pairing not found.");
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
{
|
||||
string tenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var reports = await _repository.GetDistressReportsAsync(tenantId, maxResults: 200);
|
||||
var reports = await _repository.GetDistressReportsAsync(tenantId, DistressErrorType.DeviceNotRegistered, maxResults: 200);
|
||||
var (aggregated, totalRawReports) = BuildAggregatedResult(reports);
|
||||
|
||||
return await req.OkAsync(new DeviceNotRegisteredResponse
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
{
|
||||
string tenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var reports = await _repository.GetDistressReportsAsync(tenantId, maxResults: 200);
|
||||
var reports = await _repository.GetDistressReportsAsync(tenantId, DistressErrorType.HardwareNotAllowed, maxResults: 200);
|
||||
var (aggregated, totalRawReports) = BuildAggregatedResult(reports);
|
||||
|
||||
return await req.OkAsync(new HardwareRejectedResponse
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
{
|
||||
string tenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var reports = await _repository.GetDistressReportsAsync(tenantId, maxResults: 200);
|
||||
var reports = await _repository.GetDistressReportsAsync(tenantId, DistressErrorType.TpmPssUnsupported, maxResults: 200);
|
||||
var tpmReports = reports.Where(IsTpmPssReport).ToList();
|
||||
|
||||
IReadOnlyDictionary<string, DateTime>? lastCertificateIngest = null;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the setup guide's two optional steps are done. A step counts as done when an admin
|
||||
/// finished it in the guide, or when the configuration already says so — a tenant that set
|
||||
/// things up by hand never sees those steps as open. The two required steps (a validation
|
||||
/// method, the first session) come from the configuration and the enrollment counter.
|
||||
/// </summary>
|
||||
internal static class SetupGuideState
|
||||
{
|
||||
private static readonly TenantConfiguration Defaults = new();
|
||||
|
||||
/// <summary>Recommended settings: timezone auto-set, the software inventory analyzer and a working diagnostics upload.</summary>
|
||||
internal static bool SettingsDone(TenantConfiguration config) =>
|
||||
config.SetupGuideSettingsReviewedAt != null
|
||||
|| (config.EnableTimezoneAutoSet == true
|
||||
&& config.EnableSoftwareInventoryAnalyzer == true
|
||||
&& DiagnosticsUploadConfigChange.IsConfigured(config));
|
||||
|
||||
/// <summary>Hardware: the whitelist was reviewed, or it differs from the default.</summary>
|
||||
internal static bool HardwareDone(TenantConfiguration config) =>
|
||||
config.SetupGuideHardwareReviewedAt != null
|
||||
|| !SameEntries(config.GetManufacturerWhitelist(), Defaults.GetManufacturerWhitelist())
|
||||
|| !SameEntries(config.GetModelWhitelist(), Defaults.GetModelWhitelist());
|
||||
|
||||
private static bool SameEntries(string[] a, string[] b) =>
|
||||
a.Length == b.Length
|
||||
&& a.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||
.SequenceEqual(b.OrderBy(x => x, StringComparer.OrdinalIgnoreCase), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers
|
||||
{
|
||||
/// <summary>HTTP answers for a <see cref="TenantConfigurationService.UpdateAsync"/> outcome.</summary>
|
||||
internal static class TenantConfigUpdateResponses
|
||||
{
|
||||
/// <summary>
|
||||
/// The response for an outcome that wrote nothing for a reason other than the caller's own
|
||||
/// decision; null for Updated and Declined.
|
||||
/// </summary>
|
||||
internal static async Task<HttpResponseData?> FailureAsync(HttpRequestData req, TenantConfigUpdate update)
|
||||
{
|
||||
switch (update.Status)
|
||||
{
|
||||
case TenantConfigUpdateStatus.NotFound:
|
||||
return await req.NotFoundAsync("Tenant not found");
|
||||
case TenantConfigUpdateStatus.OffboardingInProgress:
|
||||
// The answer the suspension gate gives once its cache has caught up with the tombstone.
|
||||
return await req.ErrorAsync(HttpStatusCode.Forbidden,
|
||||
Constants.ApiErrorCodes.TenantSuspended, TenantOffboardFunction.OffboardingDisabledReason);
|
||||
case TenantConfigUpdateStatus.Conflict:
|
||||
return await req.ErrorAsync(HttpStatusCode.Conflict,
|
||||
Constants.ApiErrorCodes.Conflict, "The tenant configuration was changed concurrently. Retry shortly.");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,78 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a change of the Local Admin Analyzer's allowed list
|
||||
/// (<see cref="TenantConfiguration.LocalAdminAllowedAccountsJson"/>). Only entries that are NEW
|
||||
/// against the stored list pass <see cref="LocalAdminAllowList.ValidateNewEntry"/>: a stored
|
||||
/// entry keeps its meaning, and the portal always sends the whole list. A new entry must also
|
||||
/// not repeat another entry's pattern ("X" next to "X|user" contradicts itself). Without this
|
||||
/// check the agent would ignore a malformed entry — and the MCP write path never saw the
|
||||
/// portal's "too broad" rule. Returns a user-facing reason, or null.
|
||||
/// </summary>
|
||||
internal static string? ValidateLocalAdminAllowedAccounts(string? candidateJson, string? existingJson)
|
||||
{
|
||||
if (string.Equals(candidateJson ?? string.Empty, existingJson ?? string.Empty, StringComparison.Ordinal)
|
||||
|| string.IsNullOrWhiteSpace(candidateJson))
|
||||
return null;
|
||||
|
||||
var candidate = ParseStringList(candidateJson);
|
||||
if (candidate == null)
|
||||
return "must be a JSON array of account names.";
|
||||
if (candidate.Count > LocalAdminAllowList.MaxEntries)
|
||||
return $"at most {LocalAdminAllowList.MaxEntries} entries are allowed.";
|
||||
|
||||
var stored = new HashSet<string>(ParseStringList(existingJson) ?? new List<string>(), StringComparer.Ordinal);
|
||||
for (var i = 0; i < candidate.Count; i++)
|
||||
{
|
||||
if (stored.Contains(candidate[i]))
|
||||
continue;
|
||||
|
||||
var entryError = LocalAdminAllowList.ValidateNewEntry(candidate[i]);
|
||||
if (entryError != null)
|
||||
return entryError;
|
||||
|
||||
var pattern = PatternOf(candidate[i]);
|
||||
for (var j = 0; j < candidate.Count; j++)
|
||||
{
|
||||
if (j != i && string.Equals(pattern, PatternOf(candidate[j]), StringComparison.OrdinalIgnoreCase))
|
||||
return $"\"{pattern}\" is listed twice.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string PatternOf(string entry)
|
||||
=> LocalAdminAllowList.TryParseEntry(entry, out var pattern, out _) ? pattern : entry;
|
||||
|
||||
/// <summary>A JSON array of strings, an empty list for blank input, or null when the input is anything else.</summary>
|
||||
private static List<string>? ParseStringList(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Array)
|
||||
return null;
|
||||
|
||||
var items = new List<string>();
|
||||
foreach (var item in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != System.Text.Json.JsonValueKind.String)
|
||||
return null;
|
||||
items.Add(item.GetString()!);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Generous for a CDN asset URL; bounds the command line the agent composes from it.
|
||||
internal const int MaxBrandingImageUrlLength = 2048;
|
||||
|
||||
@@ -223,6 +295,11 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
if (modelWhitelistError != null)
|
||||
return $"Invalid model whitelist: {modelWhitelistError}";
|
||||
|
||||
var allowedAccountsError = ValidateLocalAdminAllowedAccounts(
|
||||
candidate.LocalAdminAllowedAccountsJson, existing.LocalAdminAllowedAccountsJson);
|
||||
if (allowedAccountsError != null)
|
||||
return $"Invalid allowed local accounts: {allowedAccountsError}";
|
||||
|
||||
// Only validate the customer-supplied SAS URL when the tenant has actually selected
|
||||
// the CustomerSas destination — a stale value left over from a prior CustomerSas
|
||||
// configuration must not block a Hosted save (mirrors GetDiagnosticsUploadUrlFunction).
|
||||
|
||||
@@ -18,6 +18,16 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
/// </remarks>
|
||||
public static class AzureTablesPaginator
|
||||
{
|
||||
/// <summary>Largest <c>$top</c> Azure Tables accepts; a larger one is refused with 400 InvalidInput.</summary>
|
||||
public const int MaxServerPageSize = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>maxPerPage</c> for a read of up to <paramref name="rows"/> rows. Every query that
|
||||
/// passes a computed page size goes through here; the rows beyond one server page come
|
||||
/// from the following pages of the same enumeration.
|
||||
/// </summary>
|
||||
public static int ServerPageSize(int rows) => Math.Min(rows, MaxServerPageSize);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a single page of entities. Honours <paramref name="pageSize"/>
|
||||
/// as both the SDK <c>maxPerPage</c> and the page-size hint when
|
||||
@@ -52,7 +62,7 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
|
||||
var query = client.QueryAsync<T>(
|
||||
filter: filter,
|
||||
maxPerPage: pageSize,
|
||||
maxPerPage: ServerPageSize(pageSize),
|
||||
select: selectArr,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
|
||||
@@ -546,9 +546,9 @@ builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Push.PushMaint
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Push.PushSessionWatchService>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Push.IPushSessionWatchNotifier>(
|
||||
sp => sp.GetRequiredService<AutopilotMonitor.Functions.Services.Push.PushSessionWatchService>());
|
||||
// Push endpoints are per-device send capabilities: the dependency rows for the push services
|
||||
// keep scheme + host only (K20).
|
||||
builder.Services.AddApplicationInsightsTelemetryProcessor<AutopilotMonitor.Functions.Telemetry.PushDependencyRedactionProcessor>();
|
||||
// Push endpoints and webhook URLs are credentials: the dependency rows of the clients that send to
|
||||
// them keep scheme + host only (K20, D-424).
|
||||
builder.Services.AddApplicationInsightsTelemetryProcessor<AutopilotMonitor.Functions.Telemetry.CredentialUrlRedactionProcessor>();
|
||||
// Items a Global Admin's read-only "view as" request emits carry the viewed tenant as ViewAsTenantId.
|
||||
builder.Services.AddApplicationInsightsTelemetryProcessor<AutopilotMonitor.Functions.Telemetry.ViewAsTelemetryProcessor>();
|
||||
|
||||
|
||||
@@ -757,6 +757,7 @@ public static class EndpointAccessPolicyCatalog
|
||||
// Self-service Pro trial — a tenant admin may start their own tenant's one-time
|
||||
// trial; the handler enforces the once-per-tenant + already-Pro conflicts (409).
|
||||
new("POST", "config/{tenantId}/trial", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("POST", "config/{tenantId}/setup-guide", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("GET", "global/config/plan-tiers", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("PUT", "global/config/plan-tiers", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "feedback/all", EndpointPolicy.GlobalReadOrAdmin),
|
||||
@@ -778,6 +779,7 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("POST", "push/pairings/{pairingId}/confirm", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt),
|
||||
new("POST", "push/pairings/{pairingId}/reject", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt),
|
||||
new("GET", "push/devices", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt),
|
||||
new("PATCH", "push/devices/{deviceId}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt),
|
||||
new("DELETE", "push/devices/{deviceId}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt),
|
||||
new("POST", "push/devices/{deviceId}/test", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt),
|
||||
new("GET", "sessions/{sessionId}/watch", EndpointPolicy.TenantAdminOrOperator, TenantScoping.QueryParam),
|
||||
@@ -789,6 +791,7 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("POST", "global/push/pairings/{pairingId}/confirm", EndpointPolicy.GlobalAdminOnly),
|
||||
new("POST", "global/push/pairings/{pairingId}/reject", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "global/push/devices", EndpointPolicy.GlobalAdminOnly),
|
||||
new("PATCH", "global/push/devices/{deviceId}", EndpointPolicy.GlobalAdminOnly),
|
||||
new("DELETE", "global/push/devices/{deviceId}", EndpointPolicy.GlobalAdminOnly),
|
||||
new("POST", "global/push/devices/{deviceId}/test", EndpointPolicy.GlobalAdminOnly),
|
||||
new("POST", "notifications/dismiss-all", EndpointPolicy.TenantAdminOrGA, TenantScoping.Jwt),
|
||||
|
||||
+12
-1
@@ -13,6 +13,17 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
/// </summary>
|
||||
public static class NotificationAlertBuilder
|
||||
{
|
||||
public const string EnrollmentStartedEventType = "enrollment_started";
|
||||
public const string PreProvisioningResumedEventType = "preprovisioning_resumed";
|
||||
|
||||
/// <summary>
|
||||
/// Whether the alert announces that a run has just begun (<see cref="BuildEnrollmentStartedAlert"/>):
|
||||
/// it is worth something only while the run is still going.
|
||||
/// </summary>
|
||||
public static bool IsStartAlert(string? eventType)
|
||||
=> string.Equals(eventType, EnrollmentStartedEventType, StringComparison.Ordinal)
|
||||
|| string.Equals(eventType, PreProvisioningResumedEventType, StringComparison.Ordinal);
|
||||
|
||||
public static NotificationAlert BuildEnrollmentAlert(
|
||||
string? deviceName,
|
||||
string? serialNumber,
|
||||
@@ -87,7 +98,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
EventType = isResume ? "preprovisioning_resumed" : "enrollment_started",
|
||||
EventType = isResume ? PreProvisioningResumedEventType : EnrollmentStartedEventType,
|
||||
Title = title,
|
||||
Summary = summary,
|
||||
Severity = NotificationSeverity.Info,
|
||||
|
||||
+4
-1
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Net.Http;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
@@ -11,7 +12,8 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
/// <summary>
|
||||
/// Registers the typed HttpClient of <see cref="WebhookNotificationService"/>. Its primary
|
||||
/// handler connects only through <see cref="SsrfGuard.ConnectToPublicAddress"/>: the webhook
|
||||
/// SSRF gate lives on the connection, so it covers every request this client sends.
|
||||
/// SSRF gate lives on the connection, so it covers every request this client sends. A webhook
|
||||
/// URL is a credential, so the client's dependency rows keep scheme + host only (D-424).
|
||||
/// </summary>
|
||||
public static IHttpClientBuilder AddWebhookNotificationHttpClient(this IServiceCollection services) =>
|
||||
services.AddHttpClient<WebhookNotificationService>()
|
||||
@@ -22,6 +24,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
ConnectCallback = SsrfGuard.ConnectToPublicAddress,
|
||||
})
|
||||
.ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(15))
|
||||
.AddHttpMessageHandler(() => new CredentialUrlRedactionProcessor.MarkingHandler())
|
||||
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,15 +383,27 @@ public class PushDeliveryService : IPushChannelSender, IPushDeviceRevoker
|
||||
|
||||
// ── Transport ───────────────────────────────────────────────────────────
|
||||
|
||||
private PushSendOptions BuildOptions(NotificationAlert alert, PushPayload payload)
|
||||
/// <summary>
|
||||
/// Urgency follows severity, except for a start (D-424): it is Info, but worth something only while
|
||||
/// the run is still going. Below <c>high</c> FCM holds a push while the phone dozes, and the run's
|
||||
/// verdict, which shares its session Topic, then replaces the waiting start at the push service.
|
||||
/// </summary>
|
||||
internal static PushUrgency UrgencyFor(NotificationAlert alert)
|
||||
{
|
||||
var urgency = alert.Severity switch
|
||||
if (NotificationAlertBuilder.IsStartAlert(alert.EventType))
|
||||
return PushUrgency.High;
|
||||
return alert.Severity switch
|
||||
{
|
||||
NotificationSeverity.Error => PushUrgency.High,
|
||||
NotificationSeverity.Warning => PushUrgency.High,
|
||||
NotificationSeverity.Success => PushUrgency.Normal,
|
||||
_ => PushUrgency.Low,
|
||||
};
|
||||
}
|
||||
|
||||
private PushSendOptions BuildOptions(NotificationAlert alert, PushPayload payload)
|
||||
{
|
||||
var urgency = UrgencyFor(alert);
|
||||
var ttl = string.Equals(alert.EventType, "test", StringComparison.OrdinalIgnoreCase) ? TimeSpan.FromMinutes(5) : DefaultTtl;
|
||||
return new PushSendOptions(ttl, urgency, PushAlertProjector.BuildTopic(payload.PortalUrl, _topicKey));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using AutopilotMonitor.Push;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -15,7 +16,8 @@ public static class PushHttpClientRegistration
|
||||
/// retry policy — push services send Retry-After and throttle senders that re-POST in 2 s;
|
||||
/// the library classifies once and retries 5xx exactly once (K15). The sender is a singleton
|
||||
/// (it caches VAPID tokens per push-service origin), so the pooled connection lifetime keeps
|
||||
/// DNS changes from going unnoticed.
|
||||
/// DNS changes from going unnoticed. A push endpoint is a per-device send capability, so the
|
||||
/// client's dependency rows keep scheme + host only (K20, D-424).
|
||||
/// </summary>
|
||||
public static IServiceCollection AddPushSender(this IServiceCollection services)
|
||||
{
|
||||
@@ -27,7 +29,8 @@ public static class PushHttpClientRegistration
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
|
||||
ConnectCallback = SsrfGuard.ConnectToPublicAddress,
|
||||
})
|
||||
.ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(10));
|
||||
.ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(10))
|
||||
.AddHttpMessageHandler(() => new CredentialUrlRedactionProcessor.MarkingHandler());
|
||||
|
||||
services.AddSingleton(sp => new WebPushSender(sp.GetRequiredService<IHttpClientFactory>().CreateClient(ClientName)));
|
||||
return services;
|
||||
|
||||
@@ -33,6 +33,7 @@ public enum PushOpError
|
||||
InvalidSubscription,
|
||||
NotConfigured,
|
||||
Conflict,
|
||||
InvalidLabel,
|
||||
}
|
||||
|
||||
public sealed record PushOpResult<T>(T? Value, PushOpError Error, string? Message = null)
|
||||
@@ -158,7 +159,11 @@ public class PushPairingService
|
||||
return PushOpResult<PairingStatusResponse>.Success(new PairingStatusResponse { Status = status, Device = dto });
|
||||
}
|
||||
|
||||
public async Task<PushOpResult<ConfirmPairingResponse>> ConfirmAsync(NotificationScope scope, string pairingId, PushCaller caller)
|
||||
/// <summary>
|
||||
/// <paramref name="label"/> is the name the owner chose in the confirm dialog; blank keeps the
|
||||
/// name the device sent.
|
||||
/// </summary>
|
||||
public async Task<PushOpResult<ConfirmPairingResponse>> ConfirmAsync(NotificationScope scope, string pairingId, PushCaller caller, string? label = null)
|
||||
{
|
||||
var grant = await GetOwnedGrantAsync(scope, pairingId, caller).ConfigureAwait(false);
|
||||
if (grant == null || grant.DeviceId == null)
|
||||
@@ -176,14 +181,23 @@ public class PushPairingService
|
||||
if (!await _repo.TryUpdateGrantAsync(grant).ConfigureAwait(false))
|
||||
return PushOpResult<ConfirmPairingResponse>.Fail(PushOpError.Conflict, "The pairing changed concurrently — reload and try again.");
|
||||
|
||||
await _repo.MutateDeviceAsync(scope.Key, device.DeviceId, _ => new Dictionary<string, object?>
|
||||
var chosenLabel = PushAlertProjector.Clean(label, Constants.Push.MaxLabelLength);
|
||||
await _repo.MutateDeviceAsync(scope.Key, device.DeviceId, _ =>
|
||||
{
|
||||
["Status"] = Constants.Push.DeviceStatus.Active,
|
||||
["StatusReason"] = null,
|
||||
["ConfirmedUtc"] = now,
|
||||
["StatusChangedUtc"] = now,
|
||||
var patch = new Dictionary<string, object?>
|
||||
{
|
||||
["Status"] = Constants.Push.DeviceStatus.Active,
|
||||
["StatusReason"] = null,
|
||||
["ConfirmedUtc"] = now,
|
||||
["StatusChangedUtc"] = now,
|
||||
};
|
||||
if (chosenLabel.Length > 0)
|
||||
patch["Label"] = chosenLabel;
|
||||
return patch;
|
||||
}).ConfigureAwait(false);
|
||||
device.Status = Constants.Push.DeviceStatus.Active;
|
||||
if (chosenLabel.Length > 0)
|
||||
device.Label = chosenLabel;
|
||||
|
||||
var paired = PushAlertProjector.SystemMessage("push_paired", "Device paired",
|
||||
$"This device now receives Autopilot Monitor alerts for {await ScopeNameAsync(scope).ConfigureAwait(false)}.",
|
||||
@@ -452,6 +466,34 @@ public class PushPairingService
|
||||
return PushOpError.None;
|
||||
}
|
||||
|
||||
/// <summary>Same access rule as <see cref="DeleteAsync"/>: the owner, or an admin of the scope.</summary>
|
||||
public async Task<PushOpError> RenameAsync(NotificationScope scope, string deviceId, string? label, PushCaller caller)
|
||||
{
|
||||
var device = await _repo.GetDeviceAsync(scope.Key, deviceId).ConfigureAwait(false);
|
||||
if (device == null || !(caller.IsScopeAdmin || IsOwn(device, caller)))
|
||||
return PushOpError.NotFound;
|
||||
|
||||
var clean = PushAlertProjector.Clean(label, Constants.Push.MaxLabelLength);
|
||||
if (clean.Length == 0)
|
||||
return PushOpError.InvalidLabel;
|
||||
|
||||
string? previous = null;
|
||||
var written = await _repo.MutateDeviceAsync(scope.Key, device.DeviceId, current =>
|
||||
{
|
||||
if (string.Equals(current.Label, clean, StringComparison.Ordinal))
|
||||
return null;
|
||||
previous = current.Label;
|
||||
return new Dictionary<string, object?> { ["Label"] = clean };
|
||||
}).ConfigureAwait(false);
|
||||
if (!written)
|
||||
return PushOpError.None;
|
||||
|
||||
device.Label = clean;
|
||||
await AuditAsync(scope, "UPDATE", device, caller, previous).ConfigureAwait(false);
|
||||
Track("renamed", scope, device.Platform);
|
||||
return PushOpError.None;
|
||||
}
|
||||
|
||||
public async Task<PushOpResult<TestWebhookNotificationResponse>> TestAsync(NotificationScope scope, string deviceId, PushCaller caller)
|
||||
{
|
||||
var device = await _repo.GetDeviceAsync(scope.Key, deviceId).ConfigureAwait(false);
|
||||
@@ -565,12 +607,14 @@ public class PushPairingService
|
||||
: parseError ?? "The subscription keys have the wrong shape.";
|
||||
}
|
||||
|
||||
private Task AuditAsync(NotificationScope scope, string action, PushDevice device, PushCaller caller)
|
||||
private Task AuditAsync(NotificationScope scope, string action, PushDevice device, PushCaller caller, string? previousLabel = null)
|
||||
{
|
||||
if (scope.IsPlatform)
|
||||
return Task.CompletedTask;
|
||||
return _maintenanceRepo.LogAuditEntryAsync(scope.Key, action, "PushDevice", device.DeviceId, caller.Upn,
|
||||
new Dictionary<string, string> { ["platform"] = device.Platform, ["label"] = device.Label, ["owner"] = device.OwnerUpn });
|
||||
var details = new Dictionary<string, string> { ["platform"] = device.Platform, ["label"] = device.Label, ["owner"] = device.OwnerUpn };
|
||||
if (previousLabel != null)
|
||||
details["previousLabel"] = previousLabel;
|
||||
return _maintenanceRepo.LogAuditEntryAsync(scope.Key, action, "PushDevice", device.DeviceId, caller.Upn, details);
|
||||
}
|
||||
|
||||
private void Track(string action, NotificationScope scope, string? platform = null, string? reason = null)
|
||||
|
||||
+64
-80
@@ -235,52 +235,44 @@ namespace AutopilotMonitor.Functions.Services
|
||||
string? filter, int pageSize, string? continuation)
|
||||
{
|
||||
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs);
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs);
|
||||
|
||||
// RowKey scheme is `!{revtick}_{guid}` so Azure-native (PK asc, RK asc)
|
||||
// already yields newest-first within the partition. No re-sort needed.
|
||||
// Legacy entries (bare GUID RowKey) sort *after* all new entries because
|
||||
// '!' (0x21) precedes hex digits — they appear at the tail of the result
|
||||
// in undefined order until they age out of the date window.
|
||||
//
|
||||
// Back-fill loop: a server-side exclusion (e.g. "exclude
|
||||
// deletions") can make a single Azure page return far fewer than
|
||||
// pageSize matches — during a cleanup sweep the first scanned
|
||||
// rows may all be deletion bookkeeping. We follow the opaque
|
||||
// continuation until we have at least pageSize matches or the
|
||||
// partition is exhausted, then return the last consumed page's
|
||||
// token. Accumulating whole Azure pages (rather than trimming to
|
||||
// pageSize) keeps that token gap-free, so we may overshoot by up
|
||||
// to one Azure page — harmless for the caller. Without an
|
||||
// exclusion the first page already fills, so the loop exits
|
||||
// immediately and behaviour is unchanged.
|
||||
var logs = new List<AuditLogEntry>(pageSize);
|
||||
string? nextRawToken = continuation;
|
||||
var roundTrips = 0;
|
||||
do
|
||||
{
|
||||
var (entities, token) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: tableClient,
|
||||
filter: filter,
|
||||
pageSize: pageSize,
|
||||
continuation: nextRawToken);
|
||||
foreach (var entity in entities) logs.Add(MapToAuditLogEntry(entity));
|
||||
nextRawToken = token;
|
||||
roundTrips++;
|
||||
}
|
||||
while (logs.Count < pageSize
|
||||
&& !string.IsNullOrEmpty(nextRawToken)
|
||||
&& roundTrips < AuditLogMaxFillRoundTrips);
|
||||
|
||||
return new RawPage<AuditLogEntry>(logs, nextRawToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
// RowKey scheme is `!{revtick}_{guid}` so Azure-native (PK asc, RK asc)
|
||||
// already yields newest-first within the partition. No re-sort needed.
|
||||
// Legacy entries (bare GUID RowKey) sort *after* all new entries because
|
||||
// '!' (0x21) precedes hex digits — they appear at the tail of the result
|
||||
// in undefined order until they age out of the date window.
|
||||
//
|
||||
// Back-fill loop: a server-side exclusion (e.g. "exclude
|
||||
// deletions") can make a single Azure page return far fewer than
|
||||
// pageSize matches — during a cleanup sweep the first scanned
|
||||
// rows may all be deletion bookkeeping. We follow the opaque
|
||||
// continuation until we have at least pageSize matches or the
|
||||
// partition is exhausted, then return the last consumed page's
|
||||
// token. Accumulating whole Azure pages (rather than trimming to
|
||||
// pageSize) keeps that token gap-free, so we may overshoot by up
|
||||
// to one Azure page — harmless for the caller. Without an
|
||||
// exclusion the first page already fills, so the loop exits
|
||||
// immediately and behaviour is unchanged.
|
||||
var logs = new List<AuditLogEntry>(pageSize);
|
||||
string? nextRawToken = continuation;
|
||||
var roundTrips = 0;
|
||||
do
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get audit logs page");
|
||||
return RawPage<AuditLogEntry>.Empty;
|
||||
var (entities, token) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: tableClient,
|
||||
filter: filter,
|
||||
pageSize: pageSize,
|
||||
continuation: nextRawToken);
|
||||
foreach (var entity in entities) logs.Add(MapToAuditLogEntry(entity));
|
||||
nextRawToken = token;
|
||||
roundTrips++;
|
||||
}
|
||||
while (logs.Count < pageSize
|
||||
&& !string.IsNullOrEmpty(nextRawToken)
|
||||
&& roundTrips < AuditLogMaxFillRoundTrips);
|
||||
|
||||
return new RawPage<AuditLogEntry>(logs, nextRawToken);
|
||||
}
|
||||
|
||||
private async Task<RawPage<AuditLogEntry>> FetchAllAuditLogsPageAsync(
|
||||
@@ -288,43 +280,35 @@ namespace AutopilotMonitor.Functions.Services
|
||||
AuditLogQueryFilters? filters = null)
|
||||
{
|
||||
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||
try
|
||||
// Tenants come from TenantConfiguration (1 row per tenant — cheap).
|
||||
// PLUS the synthetic global-tenant partition (Constants.AuditGlobalTenantId)
|
||||
// where platform-action audits are written from TenantOffboardFunction,
|
||||
// VersionBlockFunction, PatchAdminConfigurationFunction, … TenantConfiguration
|
||||
// has no row for it because it's a virtual partition, so it'd be
|
||||
// silently skipped without this explicit add.
|
||||
var tenantIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Tenants come from TenantConfiguration (1 row per tenant — cheap).
|
||||
// PLUS the synthetic global-tenant partition (Constants.AuditGlobalTenantId)
|
||||
// where platform-action audits are written from TenantOffboardFunction,
|
||||
// VersionBlockFunction, PatchAdminConfigurationFunction, … TenantConfiguration
|
||||
// has no row for it because it's a virtual partition, so it'd be
|
||||
// silently skipped without this explicit add.
|
||||
var tenantIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
Constants.AuditGlobalTenantId,
|
||||
};
|
||||
foreach (var tenantId in await GetTenantIdsCachedAsync())
|
||||
{
|
||||
tenantIds.Add(tenantId);
|
||||
}
|
||||
Constants.AuditGlobalTenantId,
|
||||
};
|
||||
foreach (var tenantId in await GetTenantIdsCachedAsync())
|
||||
{
|
||||
tenantIds.Add(tenantId);
|
||||
}
|
||||
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs);
|
||||
return await PerPartitionFanOutMerge.FetchPageAsync<AuditLogEntry>(
|
||||
tenantIds, pageSize, continuation,
|
||||
async (tenantId, bound, limit, ct) =>
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs);
|
||||
return await PerPartitionFanOutMerge.FetchPageAsync<AuditLogEntry>(
|
||||
tenantIds, pageSize, continuation,
|
||||
async (tenantId, bound, limit, ct) =>
|
||||
{
|
||||
var filter = BuildAuditLogFilterWithRowKeyBound(tenantId, dateFrom, dateTo, bound, excludeDeletions, filters);
|
||||
var read = new List<(string RowKey, AuditLogEntry Item)>();
|
||||
await foreach (var e in tableClient.QueryAsync<TableEntity>(filter: filter, maxPerPage: AzureTablesPaginator.ServerPageSize(limit), cancellationToken: ct))
|
||||
{
|
||||
var filter = BuildAuditLogFilterWithRowKeyBound(tenantId, dateFrom, dateTo, bound, excludeDeletions, filters);
|
||||
var read = new List<(string RowKey, AuditLogEntry Item)>();
|
||||
await foreach (var e in tableClient.QueryAsync<TableEntity>(filter: filter, maxPerPage: limit, cancellationToken: ct))
|
||||
{
|
||||
read.Add((e.RowKey, MapToAuditLogEntry(e)));
|
||||
if (read.Count >= limit) break;
|
||||
}
|
||||
return read;
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get all audit logs page");
|
||||
return RawPage<AuditLogEntry>.Empty;
|
||||
}
|
||||
read.Add((e.RowKey, MapToAuditLogEntry(e)));
|
||||
if (read.Count >= limit) break;
|
||||
}
|
||||
return read;
|
||||
});
|
||||
}
|
||||
|
||||
// Suppress automated maintenance entries (SessionTimeout, DataRetentionCleanup, …)
|
||||
@@ -482,11 +466,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// Bound the server read: cap page size to maxResults and stop enumerating once we
|
||||
// have that many. Azure Tables has no server-side OrderBy, so this returns the first
|
||||
// maxResults rows in RowKey order — the same ordering as the previous full scan, it
|
||||
// just stops reading once the cap is reached. Unbounded callers pass int.MaxValue.
|
||||
// just stops reading once the cap is reached. Unbounded callers pass int.MaxValue,
|
||||
// which caps to the server default of 1000.
|
||||
// With excludeInFlightDeletions the scan continues past locked rows, so it may read
|
||||
// more pages than maxResults — bounded by the tenant's eligible backlog.
|
||||
int? maxPerPage = maxResults == int.MaxValue ? (int?)null : maxResults;
|
||||
var query = tableClient.QueryAsync<TableEntity>(filter: filter, maxPerPage: maxPerPage);
|
||||
var query = tableClient.QueryAsync<TableEntity>(filter: filter, maxPerPage: AzureTablesPaginator.ServerPageSize(maxResults));
|
||||
|
||||
var sessions = new List<SessionSummary>();
|
||||
int skippedInFlight = 0;
|
||||
|
||||
@@ -1279,6 +1279,18 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// <summary>
|
||||
/// Gets the cumulative per-tenant counters, or null if none were recorded yet.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Whether the tenant's enrollment counter is above zero. Unlike <see cref="GetTenantStatsAsync"/>,
|
||||
/// which answers null both for a missing row and for a failed read, a storage error throws here.
|
||||
/// </summary>
|
||||
public async Task<bool> TenantHasEnrollmentsAsync(string tenantId)
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PlatformStats);
|
||||
var response = await tableClient.GetEntityIfExistsAsync<TableEntity>(
|
||||
tenantId, TenantStatsRowKey, select: new[] { nameof(TenantStats.TotalEnrollments) });
|
||||
return response.HasValue && (response.Value!.GetInt64(nameof(TenantStats.TotalEnrollments)) ?? 0) > 0;
|
||||
}
|
||||
|
||||
public async Task<TenantStats?> GetTenantStatsAsync(string tenantId)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -3095,7 +3095,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
var query = tableClient.QueryAsync<TableEntity>(
|
||||
filter: $"PartitionKey eq '{partitionKey}'",
|
||||
maxPerPage: maxResults
|
||||
maxPerPage: AzureTablesPaginator.ServerPageSize(maxResults)
|
||||
);
|
||||
|
||||
await foreach (var entity in query)
|
||||
@@ -3167,7 +3167,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
var query = tableClient.QueryAsync<TableEntity>(
|
||||
filter: filter,
|
||||
maxPerPage: maxResults
|
||||
maxPerPage: AzureTablesPaginator.ServerPageSize(maxResults)
|
||||
);
|
||||
|
||||
await foreach (var entity in query)
|
||||
@@ -3316,7 +3316,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
var query = tableClient.QueryAsync<TableEntity>(
|
||||
filter: filter,
|
||||
maxPerPage: maxResults);
|
||||
maxPerPage: AzureTablesPaginator.ServerPageSize(maxResults));
|
||||
|
||||
await foreach (var entity in query)
|
||||
{
|
||||
|
||||
@@ -104,13 +104,15 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// with dedicated endpoints (plan/trial), and system-written provenance:
|
||||
/// HomedAppClientId caused the 2026-07-31 prod incident when a stale round-trip
|
||||
/// reverted it — only the app-homing flow writes it; LastAuthClientId*/OnboardedBy/
|
||||
/// OnboardedAt/DpaVersion/DpaAccept* are AuthFunction-owned; LastUpdated/UpdatedBy are stamped server-side.
|
||||
/// OnboardedAt/DpaVersion/DpaAccept* are AuthFunction-owned; SetupGuide* belong to the setup-guide
|
||||
/// endpoint; LastUpdated/UpdatedBy are stamped server-side.
|
||||
/// </summary>
|
||||
internal static readonly HashSet<string> BaseDeniedFields = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"TenantId", "DomainName", "PartitionKey", "RowKey", "Timestamp", "ETag",
|
||||
"LastUpdated", "UpdatedBy", "OnboardedAt", "OnboardedBy", "DpaVersion",
|
||||
"DpaAcceptancePending", "DpaAcceptedBy", "DpaAcceptedAt",
|
||||
"SetupGuideSettingsReviewedAt", "SetupGuideHardwareReviewedAt", "SetupGuideDismissedAt", "SetupGuideDismissedBy",
|
||||
"HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince",
|
||||
"GraphAccessRejectedSince", "GraphAccessRejectedCode", "GraphAccessRejectionConfirmedAt",
|
||||
"IntuneDeviceBindingPermissionMissingSince", "IntuneDeviceBindingPermissionMissingConfirmedAt",
|
||||
@@ -169,6 +171,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
"CloudPcPermissionMissingSince", "CloudPcPermissionMissingConfirmedAt",
|
||||
"OnboardedBy", "OnboardedAt", "DpaVersion",
|
||||
"DpaAcceptancePending", "DpaAcceptedBy", "DpaAcceptedAt",
|
||||
"SetupGuideSettingsReviewedAt", "SetupGuideHardwareReviewedAt", "SetupGuideDismissedAt", "SetupGuideDismissedBy",
|
||||
"PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy",
|
||||
"ProDowngradedUtc", "MaxDelegatedTenantsOverride", "McpUsagePlanOverride", "PayingCustomer",
|
||||
};
|
||||
@@ -518,6 +521,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
"LastUpdated" or "UpdatedBy" => "stamped server-side on every write",
|
||||
"OnboardedAt" or "OnboardedBy" or "DpaVersion" => "system-owned onboarding provenance (set at first login)",
|
||||
"DpaAcceptancePending" or "DpaAcceptedBy" or "DpaAcceptedAt" => "system-owned — written by the onboarding and the Terms + DPA acceptance",
|
||||
"SetupGuideSettingsReviewedAt" or "SetupGuideHardwareReviewedAt" or "SetupGuideDismissedAt" or "SetupGuideDismissedBy"
|
||||
=> "system-owned — written by the setup guide endpoint",
|
||||
"HomedAppClientId" => "system-owned — written only by the app-homing flow",
|
||||
"LastAuthClientId" or "LastAuthClientIdSince" => "system-owned auth provenance",
|
||||
"GraphAccessRejectedSince" or "GraphAccessRejectedCode" or "GraphAccessRejectionConfirmedAt"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Some outbound URLs are credentials: a Web Push endpoint is a per-device send capability (K20), and a
|
||||
/// webhook URL lets whoever holds it post into the customer's channel (Discord, Slack and Telegram carry
|
||||
/// the token in the path, a Teams workflow the signature in the query, a generic webhook anything). The
|
||||
/// HttpClients that send to such URLs carry <see cref="MarkingHandler"/>, which marks the async flow of
|
||||
/// each request; every dependency row emitted inside a marked flow keeps scheme + host only, in
|
||||
/// <c>Data</c> and <c>Name</c> (D-424). Target, ResultCode and Duration stay, which is all
|
||||
/// <c>/platform-health</c> reads. The mark sits on the client, as the SSRF gate sits on its connection:
|
||||
/// it covers every request the client sends, retries included, whichever host the URL names.
|
||||
/// </summary>
|
||||
public sealed class CredentialUrlRedactionProcessor : ITelemetryProcessor
|
||||
{
|
||||
private static readonly AsyncLocal<bool> InCredentialRequest = new();
|
||||
|
||||
private readonly ITelemetryProcessor _next;
|
||||
|
||||
public CredentialUrlRedactionProcessor(ITelemetryProcessor next) => _next = next;
|
||||
|
||||
public void Process(ITelemetry item)
|
||||
{
|
||||
if (InCredentialRequest.Value && item is DependencyTelemetry dependency)
|
||||
Redact(dependency);
|
||||
_next.Process(item);
|
||||
}
|
||||
|
||||
internal static void Redact(DependencyTelemetry dependency)
|
||||
{
|
||||
// A row whose URL does not parse keeps nothing of it: the host from Target, else nothing.
|
||||
var origin = Uri.TryCreate(dependency.Data, UriKind.Absolute, out var uri)
|
||||
? $"{uri.Scheme}://{uri.Host}"
|
||||
: dependency.Target ?? string.Empty;
|
||||
dependency.Data = origin;
|
||||
dependency.Name = MethodOf(dependency.Name) is { } method ? $"{method} {origin}" : origin;
|
||||
}
|
||||
|
||||
/// <summary>The HTTP method the collector put in front of the path ("POST /api/webhooks/…").</summary>
|
||||
private static string? MethodOf(string? name)
|
||||
{
|
||||
var space = name?.IndexOf(' ') ?? -1;
|
||||
return space > 0 ? name![..space] : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The mark for one credential-bearing HttpClient. The value set here flows into the inner handlers,
|
||||
/// where the collector tracks the dependency, and ends with the request (this override is async, and
|
||||
/// so is HttpClient's own send), so nothing the caller logs or sends afterwards is touched.
|
||||
/// </summary>
|
||||
public sealed class MarkingHandler : DelegatingHandler
|
||||
{
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
InCredentialRequest.Value = true;
|
||||
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// A Web Push endpoint is a per-device send capability, so the HttpClient dependency rows the
|
||||
/// worker emits for the push services must not carry it (K20): <c>Data</c> and <c>Name</c> are
|
||||
/// cut down to scheme + host for those hosts. Target, ResultCode and Duration stay, which is all
|
||||
/// <c>/platform-health</c> reads. Everything else passes through untouched.
|
||||
/// </summary>
|
||||
public sealed class PushDependencyRedactionProcessor : ITelemetryProcessor
|
||||
{
|
||||
private static readonly string[] PushHostSuffixes =
|
||||
{
|
||||
".push.apple.com",
|
||||
"fcm.googleapis.com",
|
||||
".push.services.mozilla.com",
|
||||
".notify.windows.com",
|
||||
};
|
||||
|
||||
private readonly ITelemetryProcessor _next;
|
||||
|
||||
public PushDependencyRedactionProcessor(ITelemetryProcessor next) => _next = next;
|
||||
|
||||
public void Process(ITelemetry item)
|
||||
{
|
||||
if (item is DependencyTelemetry dependency)
|
||||
Redact(dependency);
|
||||
_next.Process(item);
|
||||
}
|
||||
|
||||
internal static void Redact(DependencyTelemetry dependency)
|
||||
{
|
||||
if (!Uri.TryCreate(dependency.Data, UriKind.Absolute, out var uri) || !IsPushHost(uri.Host))
|
||||
return;
|
||||
|
||||
var origin = $"{uri.Scheme}://{uri.Host}";
|
||||
dependency.Data = origin;
|
||||
dependency.Name = $"POST {origin}";
|
||||
}
|
||||
|
||||
private static bool IsPushHost(string host)
|
||||
{
|
||||
foreach (var suffix in PushHostSuffixes)
|
||||
{
|
||||
if (suffix.StartsWith('.') ? host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)
|
||||
: host.Equals(suffix, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+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",
|
||||
|
||||
@@ -401,7 +401,7 @@ export interface AnalyzeRuleListResponse {
|
||||
export interface AnalyzerConfiguration {
|
||||
/** Whether to run the LocalAdminAnalyzer at startup and shutdown. Detects pre-enrollment local admin account creation (Autopilot bypass technique). Default: true */
|
||||
enableLocalAdminAnalyzer: boolean;
|
||||
/** Additional local account names considered expected on a newly enrolled device. These are merged (union) with the built-in defaults: Administrator, Guest, DefaultAccount, WDAGUtilityAccount, Public, Default. Any local account not in the merged list will be flagged. Default: empty list (built-in defaults only) */
|
||||
/** The tenant's allowed-list entries, merged with the built-in entries of LocalAdminAllowList: name (glob allowed) in any role, or name|user only while the account is not a member of the local Administrators group. A local account that no entry allows, or that breaks a standard-user entry, is flagged. Default: empty list (built-in entries only) */
|
||||
localAdminAllowedAccounts: string[];
|
||||
/** Whether to run the SoftwareInventoryAnalyzer at startup and shutdown. Collects installed software registry inventory, normalizes entries, and emits a baseline snapshot (startup) plus delta (shutdown) for vulnerability insight. Default: true */
|
||||
enableSoftwareInventoryAnalyzer: boolean;
|
||||
@@ -784,6 +784,8 @@ export interface AuthMeResponse {
|
||||
whatsNewSeenAgentUtc?: string;
|
||||
/** True while the caller's tenant still has to accept the Terms + DPA (its first sign-in skipped the get-started tick); the portal asks before it opens. */
|
||||
dpaAcceptancePending: boolean;
|
||||
/** The company name the tenant's admins entered under Contact, for callers with a tenant role or Global Admins; omitted otherwise or when none is set. Shown in the navbar. */
|
||||
companyName?: string;
|
||||
/** Set while a Global Admin views a tenant as one of its members (read-only "view as"): every other field then describes that member of TenantId. Null on an ordinary sign-in, and the key is omitted. */
|
||||
viewAs?: AuthMeViewAs;
|
||||
}
|
||||
@@ -1141,6 +1143,11 @@ export interface ConfidenceFactor {
|
||||
weight: number;
|
||||
}
|
||||
|
||||
/** POST push/pairings/{pairingId}/confirm — optional body; a blank label keeps the name the device sent. */
|
||||
export interface ConfirmPairingRequest {
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
/** POST push/pairings/{pairingId}/confirm — the device is Active from now on. */
|
||||
export interface ConfirmPairingResponse {
|
||||
deviceId: string;
|
||||
@@ -4219,6 +4226,11 @@ export interface RemoveManagedTenantRequest {
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
/** PATCH push/devices/{deviceId} · PATCH global/push/devices/{deviceId} — rename (owner or scope admin); 204. */
|
||||
export interface RenamePushDeviceRequest {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Response of POST rules/reseed-from-github: per-catalog reseed counters. */
|
||||
export interface ReseedFromGitHubResponse {
|
||||
success: boolean;
|
||||
@@ -5247,6 +5259,21 @@ export interface SetUsagePlanRequest {
|
||||
usagePlan?: string | null;
|
||||
}
|
||||
|
||||
/** Response of POST config/{tenantId}/setup-guide: the tenant's setup-guide markers after the write. */
|
||||
export interface SetupGuideStateResponse {
|
||||
success: boolean;
|
||||
settingsReviewedAt?: string;
|
||||
hardwareReviewedAt?: string;
|
||||
dismissedAt?: string;
|
||||
dismissedBy?: string;
|
||||
}
|
||||
|
||||
/** Body of POST config/{tenantId}/setup-guide: the setup-guide step a tenant admin finished. */
|
||||
export interface SetupGuideStepRequest {
|
||||
/** One of settings, hardware, dismiss (hide the guide for the tenant) or restore (show it again). */
|
||||
step?: string | null;
|
||||
}
|
||||
|
||||
/** Distinct-CVE counts grouped by their highest CVSS severity band. */
|
||||
export interface SeverityBreakdown {
|
||||
critical: number;
|
||||
@@ -5732,6 +5759,14 @@ export interface TenantConfiguration {
|
||||
dpaAcceptedBy?: string | null;
|
||||
/** When the Terms + DPA were accepted for this tenant; null when DpaVersion is null. */
|
||||
dpaAcceptedAt?: string | null;
|
||||
/** When a tenant admin finished the setup guide's recommended-settings step, applied or skipped. Written only by the setup-guide endpoint, never through the field patch. */
|
||||
setupGuideSettingsReviewedAt?: string | null;
|
||||
/** When a tenant admin finished the setup guide's hardware step; written like SetupGuideSettingsReviewedAt. */
|
||||
setupGuideHardwareReviewedAt?: string | null;
|
||||
/** When a tenant admin hid the setup guide from the dashboard for the whole tenant; null while it shows. The guide stays reachable from the help menu, which can bring it back. */
|
||||
setupGuideDismissedAt?: string | null;
|
||||
/** UPN of the admin who hid the setup guide; null when SetupGuideDismissedAt is null. */
|
||||
setupGuideDismissedBy?: string | null;
|
||||
/** Address used to reach this tenant about the service itself — a technical problem, a security matter, or a change that needs an administrator's attention. Editable by the tenant's own admins under Settings → Tenant → Contact. Seeded once at onboarding from the tenant's notification address if one was given, and never re-synced afterwards: from that point the value belongs to the tenant, and a later edit must not be overwritten by the onboarding source. Purpose-limited by design — service communication only. It is never used for marketing and never disclosed. Null means we have no way to reach this tenant, which is why enforcement actions cannot promise prior warning. */
|
||||
contactEmail?: string | null;
|
||||
/** Organization name behind this tenant, as the tenant's admins want it read by a support engineer — the counterpart of ContactEmail in the tenant's contact profile. Editable under Settings → Tenant → Contact. Optional on Community. Together with it is required at the self-service Pro entry point (trial start) so a paying tenant is reachable and identifiable for support; it is never a runtime gate on Pro features, and a GA plan assignment does not require it (the admin UI warns instead). Never derived from : the domain is a technical label, the company name is what the tenant tells us. Null means not provided. */
|
||||
@@ -5870,7 +5905,7 @@ export interface TenantConfiguration {
|
||||
showScriptOutput?: boolean | null;
|
||||
/** Whether the LocalAdminAnalyzer is enabled for this tenant's devices. null = use agent default (true). */
|
||||
enableLocalAdminAnalyzer?: boolean | null;
|
||||
/** Whether the SoftwareInventoryAnalyzer is enabled for this tenant's devices. null = use agent default (true). */
|
||||
/** Whether the SoftwareInventoryAnalyzer is enabled for this tenant's devices. null = off (the agent config and the feature flags both read null as false). */
|
||||
enableSoftwareInventoryAnalyzer?: boolean | null;
|
||||
/** Whether the IntegrityBypassAnalyzer is enabled for this tenant's devices. null = use agent default (true). */
|
||||
enableIntegrityBypassAnalyzer?: boolean | null;
|
||||
@@ -5882,7 +5917,7 @@ export interface TenantConfiguration {
|
||||
keepAwakeDuringUserEsp?: boolean | null;
|
||||
/** Whether to detect a SYSTEM console opened during enrollment (Shift+F10 OOBE bypass) for this tenant's devices. Gates the live ConsoleBypass watcher + the startup prefetch scanner. On by default (opt-out); tenants that knowingly use Shift+F10 for support can disable it. null = use agent default (true). */
|
||||
enableConsoleBypassDetection?: boolean | null;
|
||||
/** JSON-serialized list of additional local account names that are considered expected on a newly enrolled device (merged with built-in defaults on the agent). Example: ["SupportAdmin", "TechDesk"] */
|
||||
/** JSON-serialized list of additional local accounts that are expected on a newly enrolled device (merged with the built-in entries on the agent). An entry is an account name or a glob (*, ?), allowed in any role; name|user allows the account only while it is not a member of the local Administrators group. Grammar and built-ins: LocalAdminAllowList. Example: ["SupportAdmin", "adm-*", "kiosk|user"] */
|
||||
localAdminAllowedAccountsJson: string;
|
||||
/** Per-tenant ADDITIVE enable for OOBE Bootstrap Sessions (Global Admin only). The Pro plan includes the feature regardless of this flag; for Community tenants this is the on-request escape hatch. Effective value = plan-included OR this flag — resolved via TenantEntitlementService.IsBootstrapEnabled (backend); when effectively false, the feature is hidden in the UI and all bootstrap API endpoints reject requests. */
|
||||
bootstrapTokenEnabled: boolean;
|
||||
@@ -6003,6 +6038,14 @@ export interface TenantFeatureFlagsResponse {
|
||||
contactEmailSet: boolean;
|
||||
/** Whether a company name is stored (boolean only — the value stays admin-gated). */
|
||||
companyNameSet: boolean;
|
||||
/** Setup guide: the recommended-settings step is done (finished in the guide, or the settings are already on). */
|
||||
setupGuideSettingsDone: boolean;
|
||||
/** Setup guide: the hardware step is done (finished in the guide, or the whitelist differs from the default). */
|
||||
setupGuideHardwareDone: boolean;
|
||||
/** Setup guide: a tenant admin hid it from the dashboard for the whole tenant. */
|
||||
setupGuideDismissed: boolean;
|
||||
/** Whether the tenant ever registered an enrollment session. Omitted when unknown: not read while the setup guide is hidden, or the read failed. The guide shows only on an explicit false. */
|
||||
hasEnrollmentSessions?: boolean;
|
||||
entitlements: TenantFeatureEntitlements;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Shared.DataAccess
|
||||
{
|
||||
@@ -11,7 +12,11 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
public interface IDistressReportRepository
|
||||
{
|
||||
Task SaveDistressReportAsync(string tenantId, DistressReportEntry entry);
|
||||
Task<List<DistressReportEntry>> GetDistressReportsAsync(string tenantId, int maxResults = 100);
|
||||
/// <summary>
|
||||
/// The tenant's newest <paramref name="maxResults"/> reports of one type, newest first. The
|
||||
/// type is filtered on the server, so other report types never crowd the window.
|
||||
/// </summary>
|
||||
Task<List<DistressReportEntry>> GetDistressReportsAsync(string tenantId, DistressErrorType errorType, int maxResults = 100);
|
||||
/// <summary>
|
||||
/// The newest <paramref name="maxResults"/> reports across all tenants, newest first.
|
||||
/// Truncated is true when the table held more rows than were returned.
|
||||
|
||||
@@ -81,6 +81,11 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
// --- Tenant Stats (cumulative per-tenant counters, retention-independent) ---
|
||||
/// <summary>Gets the cumulative per-tenant counters, or null if none were recorded yet.</summary>
|
||||
Task<TenantStats?> GetTenantStatsAsync(string tenantId);
|
||||
/// <summary>
|
||||
/// Whether the tenant ever registered an enrollment session (its counter is above zero). No
|
||||
/// counter row is false; a storage error throws, so a caller can tell "never" from "unknown".
|
||||
/// </summary>
|
||||
Task<bool> TenantHasEnrollmentsAsync(string tenantId);
|
||||
/// <summary>Gets the cumulative counters of every tenant that has a row — the source of the platform rollup.</summary>
|
||||
Task<List<TenantStats>> GetAllTenantStatsAsync();
|
||||
/// <summary>Increments a cumulative per-tenant counter (ETag CAS with retries; fail-soft).</summary>
|
||||
|
||||
@@ -627,11 +627,11 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public bool EnableLocalAdminAnalyzer { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Additional local account names considered expected on a newly enrolled device.
|
||||
/// These are merged (union) with the built-in defaults:
|
||||
/// Administrator, Guest, DefaultAccount, WDAGUtilityAccount, Public, Default.
|
||||
/// Any local account not in the merged list will be flagged.
|
||||
/// Default: empty list (built-in defaults only)
|
||||
/// The tenant's allowed-list entries, merged with the built-in entries of
|
||||
/// <see cref="LocalAdminAllowList"/>: <c>name</c> (glob allowed) in any role, or <c>name|user</c>
|
||||
/// only while the account is not a member of the local Administrators group. A local account
|
||||
/// that no entry allows, or that breaks a standard-user entry, is flagged.
|
||||
/// Default: empty list (built-in entries only)
|
||||
/// </summary>
|
||||
public List<string> LocalAdminAllowedAccounts { get; set; } = new List<string>();
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
/// <summary>What an allowed-list entry lets a local account be.</summary>
|
||||
public enum LocalAccountRole
|
||||
{
|
||||
/// <summary>Any role, local administrator included — an entry without qualifier.</summary>
|
||||
Any = 0,
|
||||
|
||||
/// <summary>Only while the account is not a direct member of the local Administrators group (<c>name|user</c>).</summary>
|
||||
StandardUser = 1,
|
||||
}
|
||||
|
||||
/// <summary>What a built-in allowed-list entry is matched against.</summary>
|
||||
public enum LocalAdminBuiltInMatch
|
||||
{
|
||||
/// <summary>The well-known RID of a built-in account on the machine SID — the same in every language.</summary>
|
||||
Rid = 0,
|
||||
|
||||
/// <summary>An account name; the C:\Users folder of the same name is allowed with it.</summary>
|
||||
AccountName = 1,
|
||||
|
||||
/// <summary>A C:\Users folder that is never a local account.</summary>
|
||||
ProfileFolder = 2,
|
||||
}
|
||||
|
||||
/// <summary>One built-in entry of the Local Admin Analyzer's allowed list.</summary>
|
||||
public sealed class LocalAdminBuiltInEntry
|
||||
{
|
||||
public LocalAdminBuiltInEntry(string name, LocalAdminBuiltInMatch match, LocalAccountRole role, int? rid = null)
|
||||
{
|
||||
Name = name;
|
||||
Match = match;
|
||||
Role = role;
|
||||
Rid = rid;
|
||||
}
|
||||
|
||||
/// <summary>Account or folder name; for a RID entry the English name of the account.</summary>
|
||||
public string Name { get; }
|
||||
|
||||
public LocalAdminBuiltInMatch Match { get; }
|
||||
|
||||
public LocalAccountRole Role { get; }
|
||||
|
||||
/// <summary>The well-known RID of a RID entry, otherwise null.</summary>
|
||||
public int? Rid { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Local Admin Analyzer's allowed list: the built-in entries and the grammar of the tenant's
|
||||
/// entries (<see cref="TenantConfiguration.LocalAdminAllowedAccountsJson"/>). Single source for the
|
||||
/// agent's evaluation, the backend's write validation and the portal (shared manifest).
|
||||
/// <para>
|
||||
/// A tenant entry is an account name or a glob (<c>*</c>, <c>?</c>), optionally followed by
|
||||
/// <c>|user</c>: the account is then allowed only while it is not a member of the local
|
||||
/// Administrators group. Windows account names cannot contain '|', so the separator is unambiguous,
|
||||
/// and an agent that predates it matches such an entry against no account (fail-safe).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class LocalAdminAllowList
|
||||
{
|
||||
public const char RoleSeparator = '|';
|
||||
|
||||
public const string StandardUserQualifier = "user";
|
||||
|
||||
public const int MaxEntries = 200;
|
||||
|
||||
public const int MaxEntryLength = 64;
|
||||
|
||||
/// <summary>A glob needs this many characters besides <c>*</c> and <c>?</c> — a bare <c>*</c> would switch the analyzer off.</summary>
|
||||
public const int MinPatternLiterals = 2;
|
||||
|
||||
/// <summary>Characters Windows rejects in account names, apart from the separator and the glob characters.</summary>
|
||||
public const string ForbiddenNameCharacters = "\"/\\[]:;=,+<>";
|
||||
|
||||
/// <summary>
|
||||
/// The built-in entries. The four built-in accounts are decided by their RID alone: no name
|
||||
/// entry, built-in or tenant, can lift "Guest is never an administrator" or flag the real
|
||||
/// Administrator. The name entries cover OTHER accounts carrying a built-in name — a decoy without
|
||||
/// admin rights is fine, an administrator is not.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<LocalAdminBuiltInEntry> BuiltIns { get; } = new LocalAdminBuiltInEntry[]
|
||||
{
|
||||
new("Administrator", LocalAdminBuiltInMatch.Rid, LocalAccountRole.Any, 500),
|
||||
new("Guest", LocalAdminBuiltInMatch.Rid, LocalAccountRole.StandardUser, 501),
|
||||
new("DefaultAccount", LocalAdminBuiltInMatch.Rid, LocalAccountRole.StandardUser, 503),
|
||||
new("WDAGUtilityAccount", LocalAdminBuiltInMatch.Rid, LocalAccountRole.StandardUser, 504),
|
||||
|
||||
new("Administrator", LocalAdminBuiltInMatch.AccountName, LocalAccountRole.StandardUser),
|
||||
new("Guest", LocalAdminBuiltInMatch.AccountName, LocalAccountRole.StandardUser),
|
||||
new("DefaultAccount", LocalAdminBuiltInMatch.AccountName, LocalAccountRole.StandardUser),
|
||||
new("WDAGUtilityAccount", LocalAdminBuiltInMatch.AccountName, LocalAccountRole.StandardUser),
|
||||
|
||||
// The OOBE account is an Administrators member during the ESP.
|
||||
new("defaultuser0", LocalAdminBuiltInMatch.AccountName, LocalAccountRole.Any),
|
||||
new("defaultuser1", LocalAdminBuiltInMatch.AccountName, LocalAccountRole.Any),
|
||||
new("defaultuser2", LocalAdminBuiltInMatch.AccountName, LocalAccountRole.Any),
|
||||
|
||||
// Assigned Access autologon account: a local standard user by design.
|
||||
new("kioskUser0", LocalAdminBuiltInMatch.AccountName, LocalAccountRole.StandardUser),
|
||||
|
||||
// Web sign-in's system-managed account, enabled for each web sign-in.
|
||||
new("WsiAccount", LocalAdminBuiltInMatch.AccountName, LocalAccountRole.StandardUser),
|
||||
|
||||
new("Public", LocalAdminBuiltInMatch.ProfileFolder, LocalAccountRole.Any),
|
||||
new("Default", LocalAdminBuiltInMatch.ProfileFolder, LocalAccountRole.Any),
|
||||
new("Default User", LocalAdminBuiltInMatch.ProfileFolder, LocalAccountRole.Any),
|
||||
new("All Users", LocalAdminBuiltInMatch.ProfileFolder, LocalAccountRole.Any),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parses a tenant entry. An entry without <see cref="RoleSeparator"/> is the pattern verbatim
|
||||
/// (role Any) — exactly what it meant before qualifiers existed. With the separator, only
|
||||
/// <c>pattern|user</c> parses (qualifier case-insensitive, no surrounding spaces); anything else
|
||||
/// returns false and must allow nothing.
|
||||
/// </summary>
|
||||
public static bool TryParseEntry(string? raw, [NotNullWhen(true)] out string? pattern, out LocalAccountRole role)
|
||||
{
|
||||
pattern = null;
|
||||
role = LocalAccountRole.Any;
|
||||
if (raw is null || string.IsNullOrWhiteSpace(raw))
|
||||
return false;
|
||||
|
||||
var separator = raw.IndexOf(RoleSeparator);
|
||||
if (separator < 0)
|
||||
{
|
||||
pattern = raw;
|
||||
return true;
|
||||
}
|
||||
|
||||
var name = raw.Substring(0, separator);
|
||||
var qualifier = raw.Substring(separator + 1);
|
||||
if (name.Length == 0 || name.Trim().Length != name.Length
|
||||
|| !string.Equals(qualifier, StandardUserQualifier, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
pattern = name;
|
||||
role = LocalAccountRole.StandardUser;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The canonical entry text: the pattern, plus <c>|user</c> for a standard-user entry.</summary>
|
||||
public static string Format(string pattern, LocalAccountRole role)
|
||||
=> role == LocalAccountRole.StandardUser ? pattern + RoleSeparator + StandardUserQualifier : pattern;
|
||||
|
||||
/// <summary>True for a glob with fewer than <see cref="MinPatternLiterals"/> characters besides <c>*</c> and <c>?</c>.</summary>
|
||||
public static bool IsTooBroad(string pattern)
|
||||
{
|
||||
if (pattern.IndexOf('*') < 0 && pattern.IndexOf('?') < 0)
|
||||
return false;
|
||||
|
||||
var literals = 0;
|
||||
foreach (var ch in pattern)
|
||||
{
|
||||
if (ch != '*' && ch != '?')
|
||||
literals++;
|
||||
}
|
||||
return literals < MinPatternLiterals;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks an entry a caller is about to ADD. Returns a user-facing reason, or null when the entry
|
||||
/// may be stored. Stored entries are never re-checked: they keep their meaning even where they
|
||||
/// would not pass today.
|
||||
/// </summary>
|
||||
public static string? ValidateNewEntry(string? raw)
|
||||
{
|
||||
if (raw is null || string.IsNullOrWhiteSpace(raw))
|
||||
return "entries must not be empty.";
|
||||
if (raw.Length > MaxEntryLength)
|
||||
return $"each entry must be at most {MaxEntryLength} characters.";
|
||||
foreach (var ch in raw)
|
||||
{
|
||||
if (char.IsControl(ch))
|
||||
return "entries must not contain control characters.";
|
||||
}
|
||||
if (raw.Trim().Length != raw.Length)
|
||||
return $"\"{raw}\" must not start or end with a space.";
|
||||
if (!TryParseEntry(raw, out var pattern, out _))
|
||||
return $"\"{raw}\" is not valid: use an account name or pattern, optionally followed by \"{RoleSeparator}{StandardUserQualifier}\" (standard user only).";
|
||||
if (pattern.IndexOfAny(ForbiddenNameCharacters.ToCharArray()) >= 0)
|
||||
return $"\"{raw}\" contains a character account names cannot have ({string.Join(" ", ToStrings(ForbiddenNameCharacters))}).";
|
||||
if (LooksLikeSid(pattern))
|
||||
return $"\"{raw}\" is a SID — enter the account name instead.";
|
||||
if (IsTooBroad(pattern))
|
||||
return $"\"{raw}\" is too broad: a pattern needs at least {MinPatternLiterals} characters besides * and ?.";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool LooksLikeSid(string pattern)
|
||||
=> pattern.Length > 4
|
||||
&& pattern.StartsWith("S-1-", StringComparison.OrdinalIgnoreCase)
|
||||
&& char.IsDigit(pattern[4]);
|
||||
|
||||
private static IEnumerable<string> ToStrings(string characters)
|
||||
{
|
||||
foreach (var ch in characters)
|
||||
yield return ch.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,24 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// <summary>When the Terms + DPA were accepted for this tenant; null when <see cref="DpaVersion"/> is null.</summary>
|
||||
public DateTime? DpaAcceptedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When a tenant admin finished the setup guide's recommended-settings step, applied or
|
||||
/// skipped. Written only by the setup-guide endpoint, never through the field patch.
|
||||
/// </summary>
|
||||
public DateTime? SetupGuideSettingsReviewedAt { get; set; }
|
||||
|
||||
/// <summary>When a tenant admin finished the setup guide's hardware step; written like <see cref="SetupGuideSettingsReviewedAt"/>.</summary>
|
||||
public DateTime? SetupGuideHardwareReviewedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When a tenant admin hid the setup guide from the dashboard for the whole tenant; null
|
||||
/// while it shows. The guide stays reachable from the help menu, which can bring it back.
|
||||
/// </summary>
|
||||
public DateTime? SetupGuideDismissedAt { get; set; }
|
||||
|
||||
/// <summary>UPN of the admin who hid the setup guide; null when <see cref="SetupGuideDismissedAt"/> is null.</summary>
|
||||
public string? SetupGuideDismissedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Address used to reach this tenant about the service itself — a technical problem,
|
||||
/// a security matter, or a change that needs an administrator's attention. Editable by
|
||||
@@ -637,7 +655,7 @@ namespace AutopilotMonitor.Shared.Models
|
||||
|
||||
/// <summary>
|
||||
/// Whether the SoftwareInventoryAnalyzer is enabled for this tenant's devices.
|
||||
/// null = use agent default (true).
|
||||
/// null = off (the agent config and the feature flags both read null as false).
|
||||
/// </summary>
|
||||
public bool? EnableSoftwareInventoryAnalyzer { get; set; } = null;
|
||||
|
||||
@@ -678,14 +696,17 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public bool? EnableConsoleBypassDetection { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// JSON-serialized list of additional local account names that are considered expected
|
||||
/// on a newly enrolled device (merged with built-in defaults on the agent).
|
||||
/// Example: ["SupportAdmin", "TechDesk"]
|
||||
/// JSON-serialized list of additional local accounts that are expected on a newly enrolled
|
||||
/// device (merged with the built-in entries on the agent). An entry is an account name or a
|
||||
/// glob (<c>*</c>, <c>?</c>), allowed in any role; <c>name|user</c> allows the account only
|
||||
/// while it is not a member of the local Administrators group. Grammar and built-ins:
|
||||
/// <see cref="LocalAdminAllowList"/>.
|
||||
/// Example: ["SupportAdmin", "adm-*", "kiosk|user"]
|
||||
/// </summary>
|
||||
public string LocalAdminAllowedAccountsJson { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the deserialized list of additional allowed local admin account names.
|
||||
/// Returns the deserialized list of additional allowed local account entries.
|
||||
/// </summary>
|
||||
public List<string> GetLocalAdminAllowedAccounts()
|
||||
{
|
||||
|
||||
@@ -47,6 +47,17 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public bool ContactEmailSet { get; set; }
|
||||
/// <summary>Whether a company name is stored (boolean only — the value stays admin-gated).</summary>
|
||||
public bool CompanyNameSet { get; set; }
|
||||
/// <summary>Setup guide: the recommended-settings step is done (finished in the guide, or the settings are already on).</summary>
|
||||
public bool SetupGuideSettingsDone { get; set; }
|
||||
/// <summary>Setup guide: the hardware step is done (finished in the guide, or the whitelist differs from the default).</summary>
|
||||
public bool SetupGuideHardwareDone { get; set; }
|
||||
/// <summary>Setup guide: a tenant admin hid it from the dashboard for the whole tenant.</summary>
|
||||
public bool SetupGuideDismissed { get; set; }
|
||||
/// <summary>
|
||||
/// Whether the tenant ever registered an enrollment session. Omitted when unknown: not read
|
||||
/// while the setup guide is hidden, or the read failed. The guide shows only on an explicit false.
|
||||
/// </summary>
|
||||
public bool? HasEnrollmentSessions { get; set; }
|
||||
public TenantFeatureEntitlements Entitlements { get; set; } = default!;
|
||||
}
|
||||
|
||||
|
||||
@@ -270,6 +270,28 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string EffectiveEdition { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Body of POST config/{tenantId}/setup-guide: the setup-guide step a tenant admin finished.
|
||||
/// </summary>
|
||||
public class SetupGuideStepRequest : IApiRequest
|
||||
{
|
||||
/// <summary>One of <c>settings</c>, <c>hardware</c>, <c>dismiss</c> (hide the guide for the tenant) or <c>restore</c> (show it again).</summary>
|
||||
public string? Step { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response of POST config/{tenantId}/setup-guide: the tenant's setup-guide markers after the write.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class SetupGuideStateResponse : IApiResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public DateTime? SettingsReviewedAt { get; set; }
|
||||
public DateTime? HardwareReviewedAt { get; set; }
|
||||
public DateTime? DismissedAt { get; set; }
|
||||
public string? DismissedBy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response of GET and PUT global/config/plan-tiers: the global usage-plan tier definitions plus the
|
||||
/// built-in catalog values every unset definition field falls back to.
|
||||
|
||||
@@ -49,6 +49,9 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// <summary>True while the caller's tenant still has to accept the Terms + DPA (its first sign-in skipped the get-started tick); the portal asks before it opens.</summary>
|
||||
public bool DpaAcceptancePending { get; set; }
|
||||
|
||||
/// <summary>The company name the tenant's admins entered under Contact, for callers with a tenant role or Global Admins; omitted otherwise or when none is set. Shown in the navbar.</summary>
|
||||
public string? CompanyName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Set while a Global Admin views a tenant as one of its members (read-only "view as"): every other field
|
||||
/// then describes that member of <see cref="TenantId"/>. Null on an ordinary sign-in, and the key is omitted.
|
||||
|
||||
@@ -34,6 +34,12 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public DateTime RedeemedUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST push/pairings/{pairingId}/confirm — optional body; a blank label keeps the name the device sent.</summary>
|
||||
public class ConfirmPairingRequest : IApiRequest
|
||||
{
|
||||
public string? Label { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST push/pairings/{pairingId}/confirm — the device is Active from now on.</summary>
|
||||
public class ConfirmPairingResponse : IApiResponse
|
||||
{
|
||||
@@ -141,6 +147,12 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string Scope { get; set; } = "tenant";
|
||||
}
|
||||
|
||||
/// <summary>PATCH push/devices/{deviceId} · PATCH global/push/devices/{deviceId} — rename (owner or scope admin); 204.</summary>
|
||||
public class RenamePushDeviceRequest : IApiRequest
|
||||
{
|
||||
public string Label { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>GET/PUT/DELETE sessions/{sessionId}/watch — "notify my devices when this session ends".</summary>
|
||||
public class SessionWatchResponse : IApiResponse
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ export const ABOUT_FAQ: AboutFaqItem[] = [
|
||||
{
|
||||
question: "Can I follow a Windows Autopilot enrollment live?",
|
||||
answer:
|
||||
"Yes. Assign the Autopilot Monitor bootstrapper script to your Autopilot device groups in Intune. During enrollment the agent captures Enrollment Status Page (ESP) phases, app downloads and installs, errors, reboots, and performance snapshots and streams them to the portal as they happen. You see every device's progress without refreshing the page or touching the device.",
|
||||
"Yes. Assign the Autopilot Monitor loader script to your Autopilot device groups in Intune. During enrollment the agent captures Enrollment Status Page (ESP) phases, app downloads and installs, errors, reboots, and performance snapshots and streams them to the portal as they happen. You see every device's progress without refreshing the page or touching the device.",
|
||||
},
|
||||
{
|
||||
question: "Why did my Autopilot enrollment fail, and how do I find out without touching the device?",
|
||||
@@ -67,7 +67,7 @@ export const ABOUT_FAQ: AboutFaqItem[] = [
|
||||
{
|
||||
question: "How is the agent deployed, and does it stay on the device?",
|
||||
answer:
|
||||
"The agent is a small .NET binary installed by an Intune platform script (PowerShell bootstrapper); for Autopilot Device Preparation it ships as a thin MSI line-of-business app. It runs as a scheduled task, not a Windows service, authenticates with the existing Intune MDM device certificate, and exists only for the duration of the enrollment: by default it removes its task and files on completion, and it never runs longer than six hours.",
|
||||
"The agent is a small .NET binary installed by an Intune platform script (a PowerShell loader); for Autopilot Device Preparation it ships as a thin MSI line-of-business app. It runs as a scheduled task, not a Windows service, authenticates with the existing Intune MDM device certificate, and exists only for the duration of the enrollment: by default it removes its task and files on completion, and it never runs longer than six hours.",
|
||||
},
|
||||
{
|
||||
question: "Which Autopilot scenarios are supported?",
|
||||
|
||||
@@ -203,16 +203,16 @@ export default function AboutPage() {
|
||||
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">How It Works</h2>
|
||||
<p className="text-gray-700 leading-relaxed mb-5">
|
||||
Autopilot Monitor uses a lightweight .NET agent deployed to devices via an Intune bootstrapper
|
||||
script. The agent monitors the enrollment process in real time and streams telemetry events
|
||||
Autopilot Monitor uses a lightweight .NET agent deployed to devices by a loader script you
|
||||
assign in Intune. The agent monitors the enrollment process in real time and streams telemetry events
|
||||
— including ESP phases, app installs, performance snapshots, and custom gather rule data —
|
||||
to the Azure-hosted backend pipeline. The portal displays live session data, runs analyze rules
|
||||
automatically, and alerts on failure conditions.
|
||||
</p>
|
||||
<ol className="space-y-3">
|
||||
{[
|
||||
{ step: "1", text: "Assign the bootstrapper PowerShell script to your Autopilot device groups in Intune." },
|
||||
{ step: "2", text: "The bootstrapper installs the Autopilot Monitor Agent on each enrolling device." },
|
||||
{ step: "1", text: "Assign the loader script (PowerShell) to your Autopilot device groups in Intune." },
|
||||
{ step: "2", text: "The script installs the Autopilot Monitor Agent on each enrolling device." },
|
||||
{ step: "3", text: "The agent captures live enrollment events and uploads them to the backend pipeline." },
|
||||
{ step: "4", text: "The portal displays real-time session data, analyze rule results, and fleet health metrics." },
|
||||
{ step: "5", text: "On completion, the agent uploads a diagnostics bundle if configured, then removes itself." },
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ const SERVER_DENIED_FIELDS = new Set([
|
||||
"tenantId", "domainName", "partitionKey", "rowKey", "timestamp", "eTag",
|
||||
"lastUpdated", "updatedBy", "onboardedAt", "onboardedBy", "dpaVersion",
|
||||
"dpaAcceptancePending", "dpaAcceptedBy", "dpaAcceptedAt",
|
||||
"setupGuideSettingsReviewedAt", "setupGuideHardwareReviewedAt", "setupGuideDismissedAt", "setupGuideDismissedBy",
|
||||
"homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince",
|
||||
"graphAccessRejectedSince", "graphAccessRejectedCode", "graphAccessRejectionConfirmedAt",
|
||||
"intuneDeviceBindingPermissionMissingSince", "intuneDeviceBindingPermissionMissingConfirmedAt",
|
||||
|
||||
@@ -60,8 +60,7 @@ export function SectionAlerts() {
|
||||
/>
|
||||
{/* Platform Push devices (plan push-relay K4), once the config is loaded: the SAVED ops channel
|
||||
list says whether the channel is on — opsNotificationChannels mirrors the stored config, the
|
||||
editor above keeps its own draft. With the channel off the panel shows only while devices
|
||||
are left to remove. */}
|
||||
editor above keeps its own draft. Without the channel the panel says which channel to add. */}
|
||||
{adminConfig != null && (
|
||||
<PushDevicesPanel scope="platform" channelEnabled={hasEnabledPushChannel(opsNotificationChannels)} />
|
||||
)}
|
||||
|
||||
+6
-2
@@ -106,6 +106,10 @@ export const TENANT_FIELDS: Record<keyof TenantConfiguration, TenantRow | Exclud
|
||||
dpaAcceptancePending: { section: "Tenant Status", label: "DPA Acceptance Pending", informational: true },
|
||||
dpaAcceptedBy: { section: "Tenant Status", label: "DPA Accepted By", informational: true },
|
||||
dpaAcceptedAt: { section: "Tenant Status", label: "DPA Accepted At", kind: "date", informational: true },
|
||||
setupGuideSettingsReviewedAt: { section: "Tenant Status", label: "Setup Guide: Settings Reviewed At", kind: "date", informational: true },
|
||||
setupGuideHardwareReviewedAt: { section: "Tenant Status", label: "Setup Guide: Hardware Reviewed At", kind: "date", informational: true },
|
||||
setupGuideDismissedAt: { section: "Tenant Status", label: "Setup Guide Hidden At", kind: "date", informational: true },
|
||||
setupGuideDismissedBy: { section: "Tenant Status", label: "Setup Guide Hidden By", informational: true },
|
||||
contactEmail: { section: "Tenant Status", label: "Contact Email", informational: true },
|
||||
companyName: { section: "Tenant Status", label: "Company", informational: true },
|
||||
|
||||
@@ -192,7 +196,7 @@ export const TENANT_FIELDS: Record<keyof TenantConfiguration, TenantRow | Exclud
|
||||
|
||||
// Analyzers
|
||||
enableLocalAdminAnalyzer: { section: "Analyzers", label: "Local Admin Analyzer" },
|
||||
localAdminAllowedAccountsJson: { section: "Analyzers", label: "Allowed Local Admin Accounts" },
|
||||
localAdminAllowedAccountsJson: { section: "Analyzers", label: "Allowed Local Accounts" },
|
||||
enableSoftwareInventoryAnalyzer: { section: "Analyzers", label: "Software Inventory Analyzer" },
|
||||
enableIntegrityBypassAnalyzer: { section: "Analyzers", label: "Integrity Bypass Analyzer" },
|
||||
enableConsoleBypassDetection: { section: "Analyzers", label: "Console Bypass Detection" },
|
||||
@@ -345,7 +349,7 @@ export const COLLECTOR_FIELDS: Record<keyof CollectorConfiguration, RuntimeRow |
|
||||
|
||||
export const ANALYZER_FIELDS: Record<keyof AnalyzerConfiguration, RuntimeRow | Excluded> = {
|
||||
enableLocalAdminAnalyzer: { section: "Analyzers", label: "Local Admin Analyzer", source: "tenant" },
|
||||
localAdminAllowedAccounts: { section: "Analyzers", label: "Allowed Local Admin Accounts", source: "tenant" },
|
||||
localAdminAllowedAccounts: { section: "Analyzers", label: "Allowed Local Accounts", source: "tenant" },
|
||||
enableSoftwareInventoryAnalyzer: { section: "Analyzers", label: "Software Inventory Analyzer", source: "tenant" },
|
||||
enableIntegrityBypassAnalyzer: { section: "Analyzers", label: "Integrity Bypass Analyzer", source: "tenant" },
|
||||
enableRealmJoinWatcher: { section: "Analyzers", label: "Realm Join Watcher", source: "tenant" },
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useContinueSetup, type SetupChecklistState } from "@/hooks/useSetupChecklist";
|
||||
import { api } from "@/lib/api";
|
||||
import { apiErrorText, fetchJson } from "@/lib/apiClient";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import { DOCS_URL } from "@/lib/config";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
import type {
|
||||
DeviceNotRegisteredItem,
|
||||
DeviceNotRegisteredResponse,
|
||||
HardwareRejectedItem,
|
||||
HardwareRejectedResponse,
|
||||
TenantConfiguration,
|
||||
} from "@/lib/generated/wire-types.generated";
|
||||
import { addWhitelistEntry, reportedValueEntry } from "@/lib/hardwareWhitelist";
|
||||
import { route } from "@/lib/routes";
|
||||
import { SETUP_STEPS, type SetupStepId } from "@/lib/setupGuide";
|
||||
import { markSetup, openSetupGuide } from "@/lib/setupGuideStore";
|
||||
import { recordSetupGuideStep, writeGuideConfigFields } from "@/lib/setupGuideWrites";
|
||||
|
||||
const ROWS: Record<SetupStepId, { title: string; required: boolean; open: string; done: string; cta: string }> = {
|
||||
settings: {
|
||||
title: "Apply recommended settings",
|
||||
required: false,
|
||||
open: "Timezone, diagnostics package, software inventory.",
|
||||
done: "Recommended settings reviewed.",
|
||||
cta: "Review",
|
||||
},
|
||||
hardware: {
|
||||
title: "Check your hardware",
|
||||
required: false,
|
||||
open: "Dell, HP, Lenovo and Microsoft are accepted. Using other brands or virtual machines?",
|
||||
done: "Hardware whitelist checked.",
|
||||
cta: "Check",
|
||||
},
|
||||
validation: {
|
||||
title: "Turn on device validation",
|
||||
required: true,
|
||||
open: "Agent data is rejected until one validation method is on.",
|
||||
done: "Device validation is on.",
|
||||
cta: "Enable",
|
||||
},
|
||||
deploy: {
|
||||
title: "Deploy the agent with Intune",
|
||||
required: true,
|
||||
open: "One platform script for your Autopilot devices. The agent installs on first boot.",
|
||||
done: "First enrollment received.",
|
||||
cta: "Deploy",
|
||||
},
|
||||
};
|
||||
|
||||
const pillRecommended = "px-2 py-0.5 rounded-full text-[11px] font-medium bg-gray-100 text-gray-600 border border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600";
|
||||
const pillRequired = "px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-50 text-amber-800 border border-amber-200 dark:bg-amber-900/30 dark:text-amber-200 dark:border-amber-800";
|
||||
const ctaClass = "inline-flex items-center gap-1 px-2 py-1.5 rounded-md text-sm font-medium text-green-700 hover:bg-green-50 dark:text-green-400 dark:hover:bg-green-900/20";
|
||||
|
||||
/**
|
||||
* The dashboard's "Get started" checklist for a tenant that never enrolled a device. It replaces
|
||||
* the welcome card and, while it shows, the red "no device validation" banner: the required step
|
||||
* says the same thing without alarming a tenant on its first day. Done states come from the flags.
|
||||
*/
|
||||
export function SetupChecklistCard({ setup }: { setup: SetupChecklistState }) {
|
||||
const { getAccessToken } = useAuth();
|
||||
const continueSetup = useContinueSetup();
|
||||
const [hiding, setHiding] = useState(false);
|
||||
const { checklist, canEdit, readOnlyReason, tenantId } = setup;
|
||||
if (!checklist) return null;
|
||||
|
||||
const waiting = checklist.next === "deploy";
|
||||
// Agents can connect as soon as a validation is on, whatever the recommended steps say: from
|
||||
// then on the card shows which devices the backend turned away.
|
||||
const devicesCanConnect = checklist.done.validation && !checklist.done.deploy;
|
||||
|
||||
const hide = async () => {
|
||||
setHiding(true);
|
||||
try {
|
||||
await recordSetupGuideStep(tenantId, "dismiss", getAccessToken);
|
||||
markSetup(tenantId, { dismissed: true });
|
||||
trackEvent("setup_guide_dismissed", { done: checklist.doneCount });
|
||||
} catch {
|
||||
setHiding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-labelledby="setup-checklist-title" className="mb-6 bg-white shadow rounded-lg overflow-hidden dark:bg-gray-800">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4 px-6 pt-5 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="min-w-0 flex-1 basis-72">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-green-700 dark:text-green-400">Get started</p>
|
||||
<h2 id="setup-checklist-title" className="mt-1 text-xl font-semibold text-gray-900 dark:text-gray-100">Set up Autopilot Monitor</h2>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Four steps until your first enrollment shows up here. The guide walks you through each one; the last one happens in Intune.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 sm:flex-col sm:items-end sm:gap-1.5">
|
||||
<span className="text-xs text-gray-500 tabular-nums dark:text-gray-400">{checklist.doneCount} of 4 done</span>
|
||||
<span className="block w-36 h-1.5 rounded-full bg-gray-100 overflow-hidden dark:bg-gray-700" aria-hidden="true">
|
||||
<span className="block h-full rounded-full bg-green-600 transition-[width] duration-500" style={{ width: `${checklist.doneCount * 25}%` }} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol className="px-3 py-2">
|
||||
{SETUP_STEPS.map((step) => {
|
||||
const row = ROWS[step];
|
||||
const done = checklist.done[step];
|
||||
const isNext = checklist.next === step;
|
||||
return (
|
||||
<li key={step} className={`grid grid-cols-[1.5rem_minmax(0,1fr)] sm:grid-cols-[1.5rem_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 px-3 py-2.5 rounded-md ${isNext ? "bg-green-50/70 dark:bg-green-900/10" : ""}`}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`w-5 h-5 rounded-full flex items-center justify-center ${done ? "bg-green-600 text-white" : isNext ? "border-2 border-green-600" : "border-2 border-gray-300 dark:border-gray-600"}`}
|
||||
>
|
||||
{done && <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className={`flex flex-wrap items-center gap-2 text-sm font-medium ${done ? "text-gray-500 dark:text-gray-400" : "text-gray-900 dark:text-gray-100"}`}>
|
||||
{row.title}
|
||||
<span className={row.required ? pillRequired : pillRecommended}>{row.required ? "Required" : "Recommended"}</span>
|
||||
<span className="sr-only">{done ? "(done)" : "(open)"}</span>
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-gray-500 dark:text-gray-400">{done ? row.done : row.open}</p>
|
||||
</div>
|
||||
<div className="col-start-2 sm:col-start-auto">
|
||||
{done ? (
|
||||
<span className="inline-flex items-center gap-1 pr-2 text-xs font-medium text-green-700 dark:text-green-400">Done</span>
|
||||
) : canEdit || readOnlyReason === "view-as" ? (
|
||||
<button type="button" className={ctaClass} onClick={() => continueSetup(step, "card")}>
|
||||
{row.cta}
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{devicesCanConnect && <WaitingDetails canEdit={canEdit} tenantId={tenantId} />}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 px-6 py-3.5 border-t border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
{waiting ? (
|
||||
<>
|
||||
<span className="relative flex h-2.5 w-2.5" aria-hidden="true">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-60 motion-reduce:animate-none" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-green-600" />
|
||||
</span>
|
||||
<p className="flex-1 basis-64 text-sm text-gray-700 dark:text-gray-300">
|
||||
<span className="font-semibold">Waiting for your first enrollment.</span>{" "}
|
||||
This page updates as soon as a device starts enrolling.
|
||||
</p>
|
||||
<button type="button" className={ctaClass} onClick={() => continueSetup("deploy", "card")}>Deployment steps</button>
|
||||
</>
|
||||
) : readOnlyReason === "role" ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">A tenant admin completes these steps.</p>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => continueSetup(checklist.next, "card")}
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-green-600 hover:bg-green-700 ${checklist.doneCount === 0 ? "motion-safe:animate-pulse" : ""}`}
|
||||
>
|
||||
{checklist.doneCount === 0 ? "Start guided setup" : "Continue setup"}
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={hiding}
|
||||
onClick={() => void hide()}
|
||||
title="You can open the setup guide again from the help menu"
|
||||
className="text-sm font-medium text-gray-500 hover:text-gray-700 hover:underline underline-offset-2 disabled:opacity-50 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
)}
|
||||
{readOnlyReason === "view-as" && <span className="text-xs text-gray-500 dark:text-gray-400">View as is read-only.</span>}
|
||||
<span className="ml-auto text-xs text-gray-400">A few minutes, Intune included</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const REFRESH_MS = 60_000;
|
||||
|
||||
/** What the portal can see while it waits: why a device may not show up, and devices the backend turned away. */
|
||||
function WaitingDetails({ canEdit, tenantId }: { canEdit: boolean; tenantId: string }) {
|
||||
const { getAccessToken } = useAuth();
|
||||
const [hardware, setHardware] = useState<HardwareRejectedItem[]>([]);
|
||||
const [unregistered, setUnregistered] = useState<DeviceNotRegisteredItem[]>([]);
|
||||
const [accepted, setAccepted] = useState<ReadonlySet<string>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const [hw, nr] = await Promise.all([
|
||||
fetchJson<HardwareRejectedResponse>(api.distress.hardwareRejected(), getAccessToken),
|
||||
fetchJson<DeviceNotRegisteredResponse>(api.distress.deviceNotRegistered(), getAccessToken),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setHardware(hw.aggregated ?? []);
|
||||
setUnregistered(nr.aggregated ?? []);
|
||||
} catch {
|
||||
// The list is a hint; without it the checklist above still says what to check.
|
||||
}
|
||||
};
|
||||
void load();
|
||||
const timer = window.setInterval(() => void load(), REFRESH_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [getAccessToken]);
|
||||
|
||||
// `entry` comes from reportedValueEntry: the reported manufacturer as one literal pattern.
|
||||
const accept = async (entry: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
const config = await fetchJson<TenantConfiguration>(api.config.tenant(tenantId), getAccessToken);
|
||||
const next = addWhitelistEntry(config.manufacturerWhitelist ?? "", entry);
|
||||
await writeGuideConfigFields(tenantId, { manufacturerWhitelist: next }, "setup-guide:accept-hardware", getAccessToken);
|
||||
markSetup(tenantId, { hardware: true });
|
||||
setAccepted((prev) => new Set(prev).add(entry));
|
||||
trackEvent("setup_hardware_accepted");
|
||||
} catch (err) {
|
||||
setError(apiErrorText(err, "The manufacturer could not be added."));
|
||||
}
|
||||
};
|
||||
|
||||
const autopilotPage: Route = route("/settings/tenant/autopilot");
|
||||
const shownHardware = hardware.slice(0, 3);
|
||||
const shownUnregistered = unregistered.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="px-6 pb-3 space-y-3">
|
||||
{(shownHardware.length > 0 || shownUnregistered.length > 0) && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 space-y-2 dark:border-amber-800 dark:bg-amber-900/20">
|
||||
<p className="text-sm font-semibold text-amber-900 dark:text-amber-100">Devices we turned away</p>
|
||||
<ul className="space-y-1.5 text-sm text-gray-700 dark:text-gray-200">
|
||||
{shownHardware.map((item) => {
|
||||
// Reported values arrive unauthenticated: a wildcard in them never becomes an entry.
|
||||
const entry = reportedValueEntry(item.manufacturer);
|
||||
return (
|
||||
<li key={`hw-${item.manufacturer}-${item.model}`} className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="min-w-0 flex-1 basis-60">
|
||||
<span className="font-mono text-xs">{item.manufacturer} · {item.model}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400"> — manufacturer not on your hardware whitelist</span>
|
||||
</span>
|
||||
{entry && accepted.has(entry) ? (
|
||||
<span className="text-xs font-medium text-green-700 dark:text-green-400">
|
||||
Added <span className="font-mono">{entry}</span>: its next enrollments are accepted
|
||||
</span>
|
||||
) : entry && canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void accept(entry)}
|
||||
title={`Adds "${entry}" to the manufacturer whitelist`}
|
||||
className="px-2.5 py-1 rounded-md border border-amber-300 text-xs font-medium text-amber-900 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/40"
|
||||
>
|
||||
Accept this manufacturer
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{shownUnregistered.map((item) => (
|
||||
<li key={`nr-${item.serialNumber}`} className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="min-w-0 flex-1 basis-60">
|
||||
<span className="font-mono text-xs">{item.serialNumber}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400"> — not registered as an Autopilot device in this tenant</span>
|
||||
</span>
|
||||
<Link href={autopilotPage} className="text-xs font-medium text-amber-900 hover:underline dark:text-amber-100">Autopilot Validation</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
<details className="group rounded-lg border border-gray-200 px-4 py-2.5 text-sm dark:border-gray-700">
|
||||
<summary className="cursor-pointer font-medium text-gray-700 dark:text-gray-200">No device after an hour? Check these</summary>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5 text-gray-600 dark:text-gray-300">
|
||||
<li>The platform script is assigned to a group your test device is in.</li>
|
||||
<li>The test device is fresh: reset or new, and switched on for less than 12 hours.</li>
|
||||
<li>Virtual machines report their own manufacturer. It has to be on the hardware whitelist.</li>
|
||||
<li>
|
||||
The dry run on the device shows why the agent skipped it:{" "}
|
||||
<a href={`${DOCS_URL}${DOCS_PATHS.deployTheAgent}`} target="_blank" rel="noopener noreferrer" className="font-medium text-green-700 hover:underline dark:text-green-400">
|
||||
Deploy the agent
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The empty dashboard of a tenant without the checklist: the guide is hidden, or the caller has no tenant role. */
|
||||
export function NoSessionsNote({ canOpenGuide }: { canOpenGuide: boolean }) {
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg px-6 py-5 dark:bg-gray-800">
|
||||
<p className="text-base font-semibold text-gray-900 dark:text-gray-100">No enrollments yet</p>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Sessions appear here in real time once the agent runs on a device that enrolls.{" "}
|
||||
<a href={`${DOCS_URL}${DOCS_PATHS.deployTheAgent}`} target="_blank" rel="noopener noreferrer" className="font-medium text-green-700 hover:underline dark:text-green-400">
|
||||
Deploy the agent
|
||||
</a>
|
||||
{canOpenGuide && (
|
||||
<>
|
||||
{" "}or{" "}
|
||||
<button type="button" onClick={() => openSetupGuide("empty_note")} className="font-medium text-green-700 hover:underline dark:text-green-400">
|
||||
open the setup guide
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shown once, when the first session of a tenant arrives while the checklist is on screen. */
|
||||
export function FirstEnrollmentCard({ deviceLabel, href, onDismiss }: { deviceLabel: string; href: Route; onDismiss: () => void }) {
|
||||
return (
|
||||
<div className="mb-6 flex flex-wrap items-center gap-4 rounded-lg border border-green-200 bg-green-50 px-5 py-4 dark:border-green-800 dark:bg-green-900/20">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-green-600 text-white" aria-hidden="true">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 basis-64">
|
||||
<p className="text-base font-semibold text-gray-900 dark:text-gray-100">Your first enrollment is here</p>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">{deviceLabel} started enrolling. Setup is complete; the setup guide stays in the help menu.</p>
|
||||
</div>
|
||||
<Link href={href} onClick={() => trackEvent("setup_first_session_opened")} className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-green-600 hover:bg-green-700">
|
||||
Open session
|
||||
</Link>
|
||||
<button type="button" onClick={onDismiss} aria-label="Dismiss" className="p-1 rounded-md text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import { DOCS_URL } from "@/lib/config";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
import { GITHUB_REPO_URL } from "@/lib/githubStars";
|
||||
|
||||
// The card shows only while the tenant has no sessions, so these clicks are the onboarding funnel
|
||||
// between signup and the first enrollment. All links open a new tab; the page stays loaded.
|
||||
const trackLink = (link: "setup_guide" | "deploy_agent" | "docs" | "github") =>
|
||||
trackEvent("welcome_link_clicked", { link });
|
||||
|
||||
export function WelcomeMessage() {
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
{/* Top accent bar */}
|
||||
<div className="h-1 bg-gradient-to-r from-green-500 via-emerald-500 to-teal-500" />
|
||||
<div className="p-6 sm:p-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="shrink-0 w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 leading-tight">Welcome to Autopilot Monitor</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">Your tenant is connected — deploy the agent to start monitoring enrollments.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<ol className="space-y-3 mb-7">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-green-600 text-white text-[10px] font-bold flex items-center justify-center">1</span>
|
||||
<p className="text-sm text-gray-700">
|
||||
Follow the{" "}
|
||||
<a href={`${DOCS_URL}${DOCS_PATHS.portalSetup}`} target="_blank" rel="noopener noreferrer" onClick={() => trackLink("setup_guide")} className="text-green-700 hover:underline font-medium">setup guide</a>
|
||||
{" "}to configure your tenant and grant the required permissions.
|
||||
</p>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-green-600 text-white text-[10px] font-bold flex items-center justify-center">2</span>
|
||||
<p className="text-sm text-gray-700">
|
||||
Deploy the{" "}
|
||||
<a href={`${DOCS_URL}${DOCS_PATHS.deployTheAgent}`} target="_blank" rel="noopener noreferrer" onClick={() => trackLink("deploy_agent")} className="text-green-700 hover:underline font-medium">Intune bootstrapper</a>
|
||||
{" "}to your Autopilot device groups — the agent installs automatically on first boot.
|
||||
</p>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-green-600 text-white text-[10px] font-bold flex items-center justify-center">3</span>
|
||||
<p className="text-sm text-gray-700">Enrollment sessions will appear here in real time as devices go through the enrollment process.</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<a
|
||||
href={DOCS_URL} target="_blank" rel="noopener noreferrer"
|
||||
onClick={() => trackLink("docs")}
|
||||
className="group flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 hover:border-green-300 hover:bg-green-50 transition-colors"
|
||||
>
|
||||
<span className="shrink-0 w-8 h-8 rounded-lg bg-white border border-gray-200 group-hover:border-green-200 flex items-center justify-center shadow-sm">
|
||||
<svg className="w-4 h-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">Documentation</p>
|
||||
<p className="text-xs text-gray-500">Setup and configuration guides</p>
|
||||
</div>
|
||||
<svg className="w-4 h-4 text-gray-300 group-hover:text-green-400 ml-auto shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href={GITHUB_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackLink("github")}
|
||||
className="group flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 hover:border-gray-400 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<span className="shrink-0 w-8 h-8 rounded-lg bg-white border border-gray-200 group-hover:border-gray-300 flex items-center justify-center shadow-sm">
|
||||
<svg className="w-4 h-4 text-gray-700" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">GitHub Repository</p>
|
||||
<p className="text-xs text-gray-500">Source code and issue tracking</p>
|
||||
</div>
|
||||
<svg className="w-4 h-4 text-gray-300 group-hover:text-gray-500 ml-auto shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { WelcomeMessage } from "../WelcomeMessage";
|
||||
import { DOCS_URL } from "@/lib/config";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
import { GITHUB_REPO_URL } from "@/lib/githubStars";
|
||||
|
||||
describe("WelcomeMessage", () => {
|
||||
it("links the setup guide, the deploy guide, the docs and the repository in a new tab", () => {
|
||||
const html = renderToStaticMarkup(createElement(WelcomeMessage));
|
||||
for (const href of [
|
||||
`${DOCS_URL}${DOCS_PATHS.portalSetup}`,
|
||||
`${DOCS_URL}${DOCS_PATHS.deployTheAgent}`,
|
||||
DOCS_URL,
|
||||
GITHUB_REPO_URL,
|
||||
]) {
|
||||
expect(html).toContain(`href="${href}"`);
|
||||
}
|
||||
expect(html.match(/target="_blank"/g)).toHaveLength(4);
|
||||
});
|
||||
|
||||
// The growth report reads welcome_link_clicked as the step between signup and the first
|
||||
// enrollment; a link without its own id would drop out of that funnel unnoticed.
|
||||
it("tracks every link with its own id", () => {
|
||||
const source = readFileSync(join(__dirname, "..", "WelcomeMessage.tsx"), "utf-8");
|
||||
const ids = [...source.matchAll(/trackLink\("([a-z_]+)"\)/g)].map(m => m[1]);
|
||||
expect(source.match(/<a\b/g)).toHaveLength(ids.length);
|
||||
expect(ids).toEqual(["setup_guide", "deploy_agent", "docs", "github"]);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { useTenant } from "../../contexts/TenantContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { useNotifications } from "../../contexts/NotificationContext";
|
||||
import { StatsCard } from "./components/StatsCards";
|
||||
import { WelcomeMessage } from "./components/WelcomeMessage";
|
||||
import { FirstEnrollmentCard, NoSessionsNote, SetupChecklistCard } from "./components/SetupChecklistCard";
|
||||
import { ActivelyDevelopedBanner } from "./components/ActivelyDevelopedBanner";
|
||||
import { AppHomingBanner } from "./components/AppHomingBanner";
|
||||
import { GraphAccessBanner } from "./components/GraphAccessBanner";
|
||||
@@ -21,6 +21,8 @@ import { TenantFilterBar } from "./components/TenantFilterBar";
|
||||
import { DeleteConfirmModal, BlockConfirmModal } from "./components/ConfirmationModals";
|
||||
import TipOfTheDay from "./components/TipOfTheDay";
|
||||
import { useAdminMode } from "@/hooks/useAdminMode";
|
||||
import { useSetupChecklist } from "@/hooks/useSetupChecklist";
|
||||
import { markSetup } from "@/lib/setupGuideStore";
|
||||
import { useDeleteSession } from "./hooks/useDeleteSession";
|
||||
import { useBlockDevice } from "./hooks/useBlockDevice";
|
||||
import { useTenantSecurityConfig } from "./hooks/useTenantSecurityConfig";
|
||||
@@ -264,6 +266,34 @@ function HomeContent() {
|
||||
graphAccessRejected, graphAccessAppMissing, addOnPermissionMissing, addOnPermissionMissingBlocksAll,
|
||||
} = useTenantSecurityConfig(tenantId, user, getAccessToken, notifyError);
|
||||
|
||||
// A tenant that never enrolled a device gets the setup checklist instead of an empty page. While
|
||||
// the checklist is on, its required validation step stands in for the red validation banner; the
|
||||
// banner waits for the checklist's flags, so it does not flash before the card replaces it.
|
||||
const setup = useSetupChecklist();
|
||||
const setupOwnsEmptyState = !crossTenant && !!setup.checklist?.visible;
|
||||
const showSetupCard = setupOwnsEmptyState && !loading && sessions.length === 0;
|
||||
|
||||
// The first session arriving while the checklist was on screen gets a one-time success card.
|
||||
// Adjust-during-render (compare-prev), keyed on the tenant so a view-as switch never reads as an arrival.
|
||||
// A cross-tenant view forgets the card: on the way back the cross-tenant list is still loaded for one render.
|
||||
const [setupCardSeenFor, setSetupCardSeenFor] = useState<string | null>(null);
|
||||
const [firstArrival, setFirstArrival] = useState<{ tenantId: string; sessionId: string; label: string; dismissed: boolean } | null>(null);
|
||||
if (showSetupCard && setupCardSeenFor !== tenantId) {
|
||||
setSetupCardSeenFor(tenantId);
|
||||
}
|
||||
if (crossTenant && setupCardSeenFor !== null) {
|
||||
setSetupCardSeenFor(null);
|
||||
}
|
||||
if (!crossTenant && setupCardSeenFor === tenantId && firstArrival?.tenantId !== tenantId && sessions.length > 0) {
|
||||
const first = sessions[0];
|
||||
setFirstArrival({ tenantId, sessionId: first.sessionId, label: first.deviceName || first.serialNumber || "A device", dismissed: false });
|
||||
}
|
||||
const arrivedTenant = firstArrival?.tenantId ?? null;
|
||||
useEffect(() => {
|
||||
if (arrivedTenant) markSetup(arrivedTenant, { enrolled: true });
|
||||
}, [arrivedTenant]);
|
||||
const showFirstArrival = !crossTenant && firstArrival !== null && firstArrival.tenantId === tenantId && !firstArrival.dismissed;
|
||||
|
||||
// Disable global-scope mode for users without platform scope. A read-only Global Reader keeps it
|
||||
// (their cross-tenant view is read-only-safe; writes are gated separately + backend-enforced).
|
||||
useEffect(() => {
|
||||
@@ -373,7 +403,17 @@ function HomeContent() {
|
||||
<AppHomingBanner />
|
||||
)}
|
||||
|
||||
{deviceValidationEnabled === false && (
|
||||
{firstArrival && showFirstArrival && (
|
||||
<FirstEnrollmentCard
|
||||
deviceLabel={firstArrival.label}
|
||||
href={sessionUrl(firstArrival.sessionId)}
|
||||
onDismiss={() => setFirstArrival({ ...firstArrival, dismissed: true })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSetupCard && <SetupChecklistCard setup={setup} />}
|
||||
|
||||
{deviceValidationEnabled === false && !setup.pending && !setupOwnsEmptyState && (
|
||||
<div className="mb-6 bg-red-600 border-2 border-red-700 rounded-xl p-5 shadow-lg">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -516,8 +556,10 @@ function HomeContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Welcome message - only show when no sessions */}
|
||||
{!loading && sessions.length === 0 && <WelcomeMessage />}
|
||||
{/* Own tenant, no sessions, no checklist: the guide is hidden or the caller has no tenant role. */}
|
||||
{!loading && sessions.length === 0 && !crossTenant && !setupOwnsEmptyState && !setup.pending && (
|
||||
<NoSessionsNote canOpenGuide={!!(user?.isTenantAdmin || user?.isGlobalAdmin)} />
|
||||
)}
|
||||
|
||||
{/* Fleet-context rule filter chip (from a session-detail "View sessions" link). */}
|
||||
{ruleFilter && (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user