Compare commits

...

24 Commits

Author SHA1 Message Date
Oliver Kieselbach e6191831bd Rules: remove the AutoLogon allow-list template ANALYZE-SEC-004
The ID stays retired; a test pins that it does not come back and that no rule
matches on the AutoLogon user name.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 18:56:15 +01:00
Oliver Kieselbach a558ca5510 bump pointer: internal, .claude/commands (D-434 released: backend 1.5.1330, agent 2.0.1503, web and MCP 1.6.596 on e01e7ab)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:39:32 +01:00
Oliver Kieselbach e01e7abc2b bump pointer: internal, .claude/commands (D-434 registration 410: decision, concept doc, plan, backlog, health markers)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:06:11 +01:00
Oliver Kieselbach 512b145484 Backend: the comment on register-session's 410 sits on its method
The summary of the 410 response stood above ClampStartedAt and promised
that the agent treats registration and telemetry alike. It now sits on
WriteSessionLockedAsync and says what the agent does.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:05:40 +01:00
Oliver Kieselbach 8ece30631a Agent: a registration answered with 410 ends monitoring; a WhiteGlove Part 2 start registers a new session
register-session answers 410 while a deleted session's tombstone is fresh.
The agent treated that like a network failure: six attempts, exit 7,
everything kept for the next boot. A WhiteGlove resume never left that
loop, because the 48-hour break is skipped for it.

A 410 that echoes the request's correlation id is now its own signal. The
start sends one report (RegistrationRejectedSessionGone) and ends
monitoring with a forced cleanup, as the telemetry path already does on
410. A WhiteGlove Part 2 start rotates its session id and registers again
instead, so the user phase is observed; agent_started names the reason in
sessionRotationReason. A 410 without the echo stays a retried failure.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:05:39 +01:00
Oliver Kieselbach c9078d00b6 bump pointer: internal, .claude/commands (D-433 released: backend 1.5.1329, web and MCP 1.6.595 on 1ff68dc)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 15:30:25 +01:00
Oliver Kieselbach 1ff68dc0af bump pointer: internal, .claude/commands (D-433 script-name lookups: decision, plan, backlog, health marker)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 15:08:01 +01:00
Oliver Kieselbach 7c8204357b Web: the session page starts fresh for every session
A link from one session to another only changes ?id=, so the route stays
mounted, and the append-only event list kept the previous session's
events: both sessions ended up in one timeline. The page content is now
keyed by the session id.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 15:07:48 +01:00
Oliver Kieselbach dfcf7ad8ba Web: one script-name lookup per session view, one follow-up after a time limit
The session page loads events page by page, and the hook fired a lookup
for every page that added a script. It now waits for the complete event
walk and asks once, with every script of the session.

When the backend reports that a time limit cut the lookup short, the hook
asks once more after 5 s. The follow-up never takes away a name the first
answer had, and a session switch drops a pending one.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 15:07:35 +01:00
Oliver Kieselbach 54d4c0bd4c Script names: an incomplete lookup leaves a trace and reports its time limit
A lookup that ends without an answer for every ref, because one of the
resolver's time limits fired or Graph or the token context failed, writes
one Warning with the reasons. These paths logged at Information or not at
all, and nothing collects worker logs below Warning.

The response carries timedOut when a time limit cut the lookup short, the
request may carry retry, and the request row gets ScriptNameLookup
(timedOut / retry / retryTimedOut), so a follow-up lookup can be told from
a first one.

Also:
- The token cap is 3 s instead of 1.5 s: the first mint on a cold instance
  often needs longer, and a lookup without a token returns no name at all.
- A not-found id is kept as long as a name (7 days) instead of being asked
  again after every cleanup run.
- A list pull whose budget ends between two pages is no longer stored as
  the tenant's complete list.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 15:07:10 +01:00
Oliver Kieselbach 11bc0ec2ab bump pointer: internal, .claude/commands (D-432 released: backend 1.5.1328, web and MCP on 7e9b0ce)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 13:06:27 +01:00
Oliver Kieselbach 7e9b0cea54 bump pointer: internal, .claude/commands (D-432 configuration read after a write: decision, concept doc, plan, backlog, health markers)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 12:29:14 +01:00
Oliver Kieselbach c115cb354e MCP: get_tenant_config reads the stored configuration
The tool sends X-Tenant-Config: stored, so the read that checks an
update_tenant_config does not come from a backend instance's cached copy.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 12:28:58 +01:00
Oliver Kieselbach 9cfe3fb74d Backend: a saved tenant configuration reaches the next request that needs it
The tenant configuration stays cached per instance for five minutes, and a
write clears only the cache of the instance that served it. Two readers no
longer act on a copy another instance's write has outdated:

- A signed-in request carrying X-Tenant-Config: stored (the portal sends it for
  a few minutes after a configuration write, the MCP on get_tenant_config)
  reads the stored row of the acting tenant, and of the target tenant once the
  cross-tenant check has passed, into the instance cache before authorization
  and the function read it. A storage failure keeps the cached copy. The
  header grants nothing: it costs the caller's own request one read.
- The agent gate confirms a refusal the configuration decides (tenant
  suspended, no validation switched on, manufacturer or model not allowed,
  bootstrap not enabled) against the stored row first, at most once per 15 s
  per tenant and instance, and runs the device chain once more when the stored
  row switches on a validation it had not asked. A device enrolled right after
  an admin allowed it is no longer turned away for up to five minutes.
  Requests that pass read nothing extra. The bootstrap routes check their
  feature switch through one helper that confirms the same way.

GET config/{tenantId} and the feature flags answer a storage failure with an
error instead of a synthesized default configuration.

Request-row dimensions: ConfigRefresh (stored row read on the caller's hint),
ConfigConfirm (averted / confirmed / rerun on agent requests) and
ConfigPatchUnchanged on the field patch (sent fields the row already held).

A guard test keeps every function that writes the tenant row on a route the
portal recognizes as a configuration write.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 12:28:55 +01:00
Oliver Kieselbach 892ff4c1e1 Portal: saved settings show at once, a card saves only its edits, setup guide replay
After a successful write of the tenant configuration the browser asks for the
stored configuration on its API calls for six minutes (X-Tenant-Config: stored,
lib/storedConfigHint.ts). The backend caches a configuration per instance, so a
page loaded right after a save, for example on the way back from the admin
consent, could show the values from before it. The mark lives in localStorage:
it survives the redirect and holds in other tabs. The header leaves the fetch
cache mode alone, and a backend that does not know it ignores it.

A settings card sends exactly the fields that were edited. The first save of a
card used to send every field the configuration had never stored, with the
form's default; a page showing an older configuration could write such a
default over a value saved elsewhere. What a card sends and what counts as
unsaved is one computation now (sectionSavePayload). A switch that saves on
change always sends the value that was clicked. The form's defaults are pinned
against the model's and the agent's defaults in the generated manifest.

Setup guide: "Go through the steps again" on the completion screen starts it at
step 1, and ?setup-guide=settings|hardware|validation|deploy opens it at that
step for tenant admins. It works on the tenant's current configuration and
resets nothing. The dialog shows its own writes ("Back" after "Apply" shows
"Already on"), recording a step drops the cached feature flags, the hardware
step asks for at least one manufacturer instead of claiming an empty list
rejects every device, and "The approval was not completed" needs a loaded
configuration.

The duplicate baseline loses one entry.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 12:28:44 +01:00
Oliver Kieselbach 2cb181734a bump pointer: internal (D-431 released with agent 2.0.1502)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 20:33:53 +01:00
112 changed files with 5105 additions and 746 deletions
-94
View File
@@ -1,94 +0,0 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-SEC-004",
"title": "AutoLogon enabled for an unexpected user",
"description": "Optional kiosk allow-list for AutoLogon. Fires when AutoLogon is enabled for a default user that is NOT on your configured list of approved kiosk / shared-device accounts. Disabled until configured because a normal Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (AutoLogon enabled, a default user, no plaintext password) — so AutoLogon-enabled alone is not graded by default; only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) is. Enable this rule with your expected kiosk account name(s) if you want to surface AutoLogon configured for any other user.",
"severity": "warning",
"category": "security",
"version": "1.2.0",
"enabled": false,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 80,
"conditions": [
{
"signal": "autologon_enabled",
"source": "event_type",
"eventType": "autologon_analysis",
"dataField": "checks.autologon_enabled",
"operator": "equals",
"value": "true",
"required": true
},
{
"signal": "unexpected_autologon_user",
"source": "event_data",
"eventType": "autologon_analysis",
"dataField": "checks.default_user_name",
"operator": "not_in",
"value": "EXPECTED-KIOSK-USER-1,EXPECTED-KIOSK-USER-2",
"required": true
}
],
"confidenceThreshold": 40,
"explanation": "AutoLogon is enabled on this device for a user that is **not on your approved kiosk allow-list**. AutoLogon is legitimate for kiosk and shared-device scenarios, so this rule only warns when the configured default user differs from the account(s) you expect.\n\nThis rule is **off by default**. A normal user-driven Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (`AutoAdminLogon=1`, a `DefaultUserName`, an `AutoLogonSID`, and no plaintext password) — registry-indistinguishable from a kiosk AutoLogon — so AutoLogon-enabled alone is not graded. The only AutoLogon state graded by default is a plaintext `DefaultPassword` on disk (**ANALYZE-SEC-003**).\n\nTo use this rule: configure the allow-list with your approved kiosk account name(s) (comma-separated, case-insensitive exact match against the Winlogon `DefaultUserName`) and enable it. AutoLogon for any other user will then warn.\n\nNote: an AutoLogon with no explicit default user (e.g. ForceAutoLogon only) is treated as unexpected and will warn.",
"remediation": [
{
"title": "Confirm the AutoLogon user",
"steps": [
"Open the autologon_analysis event in the session timeline and check the default user/domain",
"If the user is an approved kiosk account, add it to this rule's allow-list",
"If the user is unexpected, treat it as a possible enrollment/OOBE manipulation and investigate what wrote the Winlogon keys"
]
},
{
"title": "Disable AutoLogon if not required",
"steps": [
"Set HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon\\AutoAdminLogon to 0",
"Remove DefaultPassword (and any Sysinternals LSA secret) if present",
"For kiosk scenarios, prefer Assigned Access / kiosk profiles over raw AutoLogon where possible"
]
}
],
"relatedDocs": [
{
"title": "Microsoft: Configure Windows to automatically sign in (AutoLogon)",
"url": "https://learn.microsoft.com/en-us/troubleshoot/windows-server/user-profiles-and-logon/turn-on-automatic-logon"
}
],
"presentation": {
"role": "advisory",
"summary": "AutoLogon is enabled for an unexpected user: {{unexpected_autologon_user.value}}",
"subject": {
"kind": "account",
"key": "{{unexpected_autologon_user.value}}"
},
"facts": [
{
"label": "AutoLogon user",
"value": "{{unexpected_autologon_user.value}}",
"format": "code"
}
]
},
"tags": [
"security",
"autologon",
"winlogon",
"kiosk",
"allow-list"
],
"templateVariables": [
{
"name": "allowed_autologon_users",
"label": "Allowed AutoLogon users (kiosk accounts)",
"description": "Comma-separated list of approved kiosk / shared-device account names that are expected to use AutoLogon (case-insensitive exact match against the Winlogon DefaultUserName, e.g. 'KioskUser' or 'Reception,LobbyPC'). AutoLogon for any other user raises a warning.",
"conditionIndex": 1,
"field": "value",
"placeholder": "EXPECTED-KIOSK-USER-1,EXPECTED-KIOSK-USER-2"
}
]
}
-93
View File
@@ -4926,99 +4926,6 @@
"plaintext-password"
]
},
{
"ruleId": "ANALYZE-SEC-004",
"title": "AutoLogon enabled for an unexpected user",
"description": "Optional kiosk allow-list for AutoLogon. Fires when AutoLogon is enabled for a default user that is NOT on your configured list of approved kiosk / shared-device accounts. Disabled until configured because a normal Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (AutoLogon enabled, a default user, no plaintext password) — so AutoLogon-enabled alone is not graded by default; only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) is. Enable this rule with your expected kiosk account name(s) if you want to surface AutoLogon configured for any other user.",
"severity": "warning",
"category": "security",
"version": "1.2.0",
"enabled": false,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 80,
"conditions": [
{
"signal": "autologon_enabled",
"source": "event_type",
"eventType": "autologon_analysis",
"dataField": "checks.autologon_enabled",
"operator": "equals",
"value": "true",
"required": true
},
{
"signal": "unexpected_autologon_user",
"source": "event_data",
"eventType": "autologon_analysis",
"dataField": "checks.default_user_name",
"operator": "not_in",
"value": "EXPECTED-KIOSK-USER-1,EXPECTED-KIOSK-USER-2",
"required": true
}
],
"confidenceThreshold": 40,
"explanation": "AutoLogon is enabled on this device for a user that is **not on your approved kiosk allow-list**. AutoLogon is legitimate for kiosk and shared-device scenarios, so this rule only warns when the configured default user differs from the account(s) you expect.\n\nThis rule is **off by default**. A normal user-driven Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (`AutoAdminLogon=1`, a `DefaultUserName`, an `AutoLogonSID`, and no plaintext password) — registry-indistinguishable from a kiosk AutoLogon — so AutoLogon-enabled alone is not graded. The only AutoLogon state graded by default is a plaintext `DefaultPassword` on disk (**ANALYZE-SEC-003**).\n\nTo use this rule: configure the allow-list with your approved kiosk account name(s) (comma-separated, case-insensitive exact match against the Winlogon `DefaultUserName`) and enable it. AutoLogon for any other user will then warn.\n\nNote: an AutoLogon with no explicit default user (e.g. ForceAutoLogon only) is treated as unexpected and will warn.",
"remediation": [
{
"title": "Confirm the AutoLogon user",
"steps": [
"Open the autologon_analysis event in the session timeline and check the default user/domain",
"If the user is an approved kiosk account, add it to this rule's allow-list",
"If the user is unexpected, treat it as a possible enrollment/OOBE manipulation and investigate what wrote the Winlogon keys"
]
},
{
"title": "Disable AutoLogon if not required",
"steps": [
"Set HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon\\AutoAdminLogon to 0",
"Remove DefaultPassword (and any Sysinternals LSA secret) if present",
"For kiosk scenarios, prefer Assigned Access / kiosk profiles over raw AutoLogon where possible"
]
}
],
"relatedDocs": [
{
"title": "Microsoft: Configure Windows to automatically sign in (AutoLogon)",
"url": "https://learn.microsoft.com/en-us/troubleshoot/windows-server/user-profiles-and-logon/turn-on-automatic-logon"
}
],
"presentation": {
"role": "advisory",
"summary": "AutoLogon is enabled for an unexpected user: {{unexpected_autologon_user.value}}",
"subject": {
"kind": "account",
"key": "{{unexpected_autologon_user.value}}"
},
"facts": [
{
"label": "AutoLogon user",
"value": "{{unexpected_autologon_user.value}}",
"format": "code"
}
]
},
"tags": [
"security",
"autologon",
"winlogon",
"kiosk",
"allow-list"
],
"templateVariables": [
{
"name": "allowed_autologon_users",
"label": "Allowed AutoLogon users (kiosk accounts)",
"description": "Comma-separated list of approved kiosk / shared-device account names that are expected to use AutoLogon (case-insensitive exact match against the Winlogon DefaultUserName, e.g. 'KioskUser' or 'Reception,LobbyPC'). AutoLogon for any other user raises a warning.",
"conditionIndex": 1,
"field": "value",
"placeholder": "EXPECTED-KIOSK-USER-1,EXPECTED-KIOSK-USER-2"
}
]
},
{
"ruleId": "ANALYZE-SEC-005",
"title": "Unexpected Provisioning Package",
@@ -135,6 +135,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
Assert.Equal("CONTOSO", checks["default_domain_name"]);
}
[Fact]
public void BuildPayload_reports_a_sign_in_address_as_its_domain_only()
{
var snap = new AutoLogonSnapshot
{
WinlogonKeyPresent = true,
AutoAdminLogon = "1",
DefaultUserName = "luke.skywalker@example.net",
AltDefaultUserName = @"AzureAD\luke.skywalker@example.net",
};
var data = AutoLogonAnalyzer.BuildPayload(snap, "shutdown");
var checks = Checks(data);
Assert.Equal("[redacted]@example.net", checks["default_user_name"]);
Assert.Equal("[redacted]@example.net", checks["alt_default_user_name"]);
Assert.DoesNotContain(checks.Values.OfType<string>(), v => v.Contains("skywalker"));
// The reduction is presentation only — the identity is still reported as present.
Assert.True((bool)checks["default_identity_present"]);
}
[Theory]
[InlineData("kioskuser", "kioskuser")]
[InlineData("defaultuser0", "defaultuser0")]
[InlineData("foouser@example.net", "foouser@example.net")]
[InlineData("autopilot@example.net", "autopilot@example.net")]
[InlineData("luke.skywalker@example.net", "[redacted]@example.net")]
[InlineData("luke@skywalker@example.net", "[redacted]@example.net")]
[InlineData("luke.skywalker@", "[redacted]@unknown")]
[InlineData("", "")]
[InlineData(null, "")]
public void ReduceUserName_keeps_local_accounts_and_placeholders_readable(string? raw, string expected)
{
Assert.Equal(expected, AutoLogonAnalyzer.ReduceUserName(raw));
}
[Theory]
[InlineData("1", true)]
[InlineData("true", true)]
@@ -45,7 +45,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.DeviceInfo
{
using var rig = new Rig();
var notJoined = new Dictionary<string, object> { { "joinType", "Not Joined" } };
var joined = new Dictionary<string, object> { { "joinType", "Azure AD Joined" }, { "userEmail", "alice@contoso.com" } };
var joined = DeviceInfoCollector.BuildAadJoinedData("T1", "alice@contoso.com", "AABB", isPlaceholderUser: false);
Assert.True(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, notJoined));
Assert.False(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, notJoined));
@@ -56,6 +56,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.DeviceInfo
Assert.False(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, joined));
}
[Fact]
public void Placeholder_replaced_by_a_user_of_the_same_domain_reemits()
{
// Pre-provisioning hands the device from the placeholder account to the real user
// in the same domain — the two payloads must still differ for the gate.
using var rig = new Rig();
var placeholder = DeviceInfoCollector.BuildAadJoinedData("T1", "foouser@contoso.com", "AABB", isPlaceholderUser: true);
var realUser = DeviceInfoCollector.BuildAadJoinedData("T1", "alice@contoso.com", "AABB", isPlaceholderUser: false);
Assert.True(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, placeholder));
Assert.True(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, realUser));
}
[Fact]
public void Boot_time_and_wifi_signal_are_exempt_from_the_gate()
{
@@ -114,6 +127,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.DeviceInfo
}
}
/// <summary>Payload of <c>aad_join_status</c> for a joined device.</summary>
public sealed class AadJoinStatusPayloadTests
{
[Fact]
public void Joined_payload_carries_the_domain_of_the_signed_in_address_only()
{
var data = DeviceInfoCollector.BuildAadJoinedData("T1", "alice@contoso.com", "AABB", isPlaceholderUser: false);
Assert.Equal("Azure AD Joined", data["joinType"]);
Assert.Equal("T1", data["tenantId"]);
Assert.Equal("AABB", data["thumbprint"]);
Assert.Equal("contoso.com", data["userDomain"]);
Assert.False(data.ContainsKey("isFooUser"));
Assert.DoesNotContain(data.Values, v => v is string s && s.Contains("alice"));
}
[Fact]
public void Joined_payload_flags_the_placeholder_and_omits_the_domain_without_an_address()
{
var placeholder = DeviceInfoCollector.BuildAadJoinedData("T1", "foouser@contoso.com", "AABB", isPlaceholderUser: true);
Assert.Equal(true, placeholder["isFooUser"]);
Assert.Equal("contoso.com", placeholder["userDomain"]);
var deviceOnly = DeviceInfoCollector.BuildAadJoinedData("T1", null!, "AABB", isPlaceholderUser: false);
Assert.False(deviceOnly.ContainsKey("userDomain"));
Assert.False(deviceOnly.ContainsKey("isFooUser"));
}
}
/// <summary>Inventory fingerprint used by the SoftwareInventoryAnalyzer's restart dedup.</summary>
public sealed class SoftwareInventoryFingerprintTests
{
@@ -148,5 +148,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
"the window never closed: the worker exited before the watcher subscribed to Exited");
Assert.False(watcher.IsActive);
}
[Fact]
public void A_traced_id_that_belongs_to_another_program_is_not_tracked()
{
// The start trace arrives late: by then the id can belong to an unrelated process.
using var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
watcher.WorkerSource = () => Array.Empty<Process>();
watcher.Start(armWmi: false);
var child = Spawn();
watcher.TrackTracedProcess(child.Id, "OfficeC2RClient");
Assert.False(watcher.IsActive);
}
[Fact]
public void A_traced_id_that_still_carries_the_expected_name_is_tracked()
{
using var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
watcher.WorkerSource = () => Array.Empty<Process>();
watcher.Start(armWmi: false);
var child = Spawn();
watcher.TrackTracedProcess(child.Id, "cmd");
Assert.True(watcher.IsActive);
}
}
}
@@ -1,6 +1,7 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
@@ -10,14 +11,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
{
/// <summary>
/// ConsoleBypassWatcher classification — driven via the internal <c>HandleStart</c>/<c>Classify</c>
/// with an injected process probe (no real WMI / processes). Asserts the parent-free discriminator:
/// an interactive-session cmd with a bare command line is flagged (high confidence); a scripted
/// <c>cmd /c</c> and a session-0 (service) cmd are ignored; an interactive-session cmd whose command
/// line cannot be read (instant-close race) is still surfaced at low confidence.
/// with injected probes (no real WMI / processes). A console that is still running when probed is
/// reported: bare command line (high confidence), <c>/k</c> or unreadable (low). A scripted
/// <c>cmd /c</c> and a session-0 cmd are ignored. A start whose process is gone, or whose id now
/// belongs to another process, is counted as script activity and never reported as a console.
/// </summary>
public sealed class ConsoleBypassWatcherTests
{
private const string Cmd = "cmd.exe";
private const string BareCmd = @"C:\WINDOWS\system32\cmd.exe";
private const string OobeAccount = "S-1-5-21-1-2-3-1000";
private const string Created = "20261111143453.119277+060";
private const int Pid = 9636;
private const int Parent = 9016;
private static readonly DateTime At = new DateTime(2026, 11, 11, 14, 34, 53, DateTimeKind.Utc);
private static AgentLogger NewLogger(string dir) => new AgentLogger(dir, AgentLogLevel.Info);
@@ -25,59 +32,67 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
{
private readonly TempDirectory _tmp = new TempDirectory();
public List<ConsoleSpawnInfo> Detected { get; } = new List<ConsoleSpawnInfo>();
public List<int> ParentLookups { get; } = new List<int>();
public int WindowsOpened { get; private set; }
public ConsoleBypassWatcher Sut { get; }
public Rig(Func<int, ProcessProbe?> probe)
public Rig(Func<int, string, ProcessProbe?> probe, bool alive = true, Func<int, string?>? parentName = null)
{
Sut = new ConsoleBypassWatcher(NewLogger(_tmp.Path), probe);
Sut = new ConsoleBypassWatcher(
NewLogger(_tmp.Path),
probe,
isAlive: _ => alive,
parentName: pid => { ParentLookups.Add(pid); return parentName?.Invoke(pid); },
utcNow: () => At);
Sut.BypassConsoleDetected += (_, info) => Detected.Add(info);
Sut.ScriptActivityStarted += (_, __) => WindowsOpened++;
}
public void Trace(int pid = Pid, int parentPid = Parent, int sessionId = 1, string? ownerSid = OobeAccount) =>
Sut.HandleStart(pid, parentPid, sessionId, ownerSid, "process_start_trace", Cmd);
public void Dispose() { Sut.Dispose(); _tmp.Dispose(); }
}
private static Func<int, ProcessProbe?> Probe(string? commandLine, string? owner = @"PC\defaultuser0")
=> _ => new ProcessProbe(commandLine, owner);
/// <summary>A running process that is the traced one, with the given command line.</summary>
private static Func<int, string, ProcessProbe?> Running(
string? commandLine, string name = Cmd, int sessionId = 1, int parentPid = Parent, string creationDate = Created)
=> (_, __) => new ProcessProbe(name, sessionId, parentPid, commandLine, creationDate);
private static readonly Func<int, ProcessProbe?> GoneProbe = _ => null;
/// <summary>The process has exited before it could be read.</summary>
private static readonly Func<int, string, ProcessProbe?> Gone = (_, __) => null;
// ---------------------------------------------------------------- HandleStart end-to-end
// ---------------------------------------------------------------- consoles that are reported
[Fact]
public void Interactive_session_bare_cmd_raises_high_confidence_with_signature()
{
using var rig = new Rig(Probe(BareCmd, owner: @"PC\defaultuser0"));
using var rig = new Rig(Running(BareCmd), parentName: _ => "explorer.exe");
rig.Sut.HandleStart(pid: 9636, parentPid: 9016, sessionId: 1, ownerSidHint: null, detectedVia: "process_start_trace");
rig.Trace();
var info = Assert.Single(rig.Detected);
Assert.Equal("cmd.exe", info.ProcessName);
Assert.Equal(9636, info.ProcessId);
Assert.Equal(9016, info.ParentProcessId);
Assert.Equal(Cmd, info.ProcessName);
Assert.Equal(Pid, info.ProcessId);
Assert.Equal(Parent, info.ParentProcessId);
Assert.Equal("explorer.exe", info.ParentProcessName);
Assert.Equal(1, info.SessionId);
Assert.Equal("high", info.Confidence);
Assert.Equal("interactive_console", info.Classification);
Assert.Equal(@"PC\defaultuser0", info.Owner);
Assert.Equal(OobeAccount, info.Owner);
Assert.Equal(BareCmd, info.CommandLine);
Assert.Null(rig.Sut.TakeScriptActivity());
}
[Fact]
public void Session_zero_cmd_is_ignored_even_when_bare()
public void A_console_whose_launcher_is_gone_has_no_parent_name()
{
// IME / Intune install cmd run as SYSTEM in session 0 — never an interactive console.
using var rig = new Rig(Probe(BareCmd));
rig.Sut.HandleStart(1234, 1, sessionId: 0, ownerSidHint: null, detectedVia: "process_start_trace");
Assert.Empty(rig.Detected);
}
// The launcher behind Shift+F10 exits within milliseconds.
using var rig = new Rig(Running(BareCmd));
[Theory]
[InlineData(@"C:\WINDOWS\system32\cmd.exe /c ""install.bat""")]
[InlineData(@"cmd.exe /C ping")]
public void Scripted_cmd_is_ignored(string commandLine)
{
using var rig = new Rig(Probe(commandLine));
rig.Sut.HandleStart(2222, 1, sessionId: 1, ownerSidHint: null, detectedVia: "process_start_trace");
Assert.Empty(rig.Detected);
rig.Trace();
Assert.Null(Assert.Single(rig.Detected).ParentProcessName);
}
[Fact]
@@ -85,9 +100,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
{
// L12: /k runs its command and then leaves a fully interactive shell — a deliberate
// false-negative seam if ignored (e.g. a technician-planted `cmd /k whoami`).
using var rig = new Rig(Probe(@"cmd.exe /k whoami"));
using var rig = new Rig(Running(@"cmd.exe /k whoami"));
rig.Sut.HandleStart(2222, 1, sessionId: 1, ownerSidHint: null, detectedVia: "process_start_trace");
rig.Trace();
var info = Assert.Single(rig.Detected);
Assert.Equal("low", info.Confidence);
@@ -101,9 +116,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
{
// cmd.exe honors only the FIRST /c-or-/k switch; a '/c' inside the /k payload is inert, so
// the console is a persistent human-usable shell and must still be surfaced (low confidence).
using var rig = new Rig(Probe(commandLine));
using var rig = new Rig(Running(commandLine));
rig.Sut.HandleStart(2223, 1, sessionId: 1, ownerSidHint: null, detectedVia: "process_start_trace");
rig.Trace();
var info = Assert.Single(rig.Detected);
Assert.Equal("low", info.Confidence);
@@ -111,88 +126,317 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
}
[Fact]
public void Instant_close_unreadable_commandline_raises_low_confidence()
public void A_running_process_without_a_readable_command_line_raises_low_confidence()
{
// Win32_Process query loses the race (process already exited). SessionID from the trace is
// still known, so the console is surfaced — low confidence, owner from the trace SID hint.
using var rig = new Rig(GoneProbe);
using var rig = new Rig(Running(commandLine: null), alive: true);
rig.Sut.HandleStart(9636, 9016, sessionId: 1, ownerSidHint: "S-1-5-18", detectedVia: "process_start_trace");
rig.Trace();
var info = Assert.Single(rig.Detected);
Assert.Equal("low", info.Confidence);
Assert.Equal("interactive_session_unclassified", info.Classification);
Assert.Equal("S-1-5-18", info.Owner);
Assert.Null(info.CommandLine);
Assert.Null(rig.Sut.TakeScriptActivity());
}
[Fact]
public void Owner_prefers_probe_over_trace_hint()
public void A_running_process_that_could_not_be_read_at_all_raises_low_confidence()
{
using var rig = new Rig(Probe(BareCmd, owner: @"PC\defaultuser0"));
rig.Sut.HandleStart(9636, 9016, sessionId: 1, ownerSidHint: "S-1-5-18", detectedVia: "process_start_trace");
Assert.Equal(@"PC\defaultuser0", Assert.Single(rig.Detected).Owner);
// The query failed. That says nothing about the process: it is reported, never counted away.
using var rig = new Rig((_, __) => ProcessProbe.Unreadable, alive: true);
rig.Trace();
var info = Assert.Single(rig.Detected);
Assert.Equal("low", info.Confidence);
Assert.Equal("interactive_session_unclassified", info.Classification);
Assert.Equal(OobeAccount, info.Owner);
Assert.Null(rig.Sut.TakeScriptActivity());
}
// ---------------------------------------------------------------- starts that are ignored
[Fact]
public void Same_pid_seen_twice_raises_only_once()
public void Session_zero_cmd_is_ignored_even_when_bare()
{
// The startup probe and the live trace can both observe the same still-open console.
using var rig = new Rig(Probe(BareCmd));
rig.Sut.HandleStart(9636, 9016, 1, null, "startup_probe");
rig.Sut.HandleStart(9636, 9016, 1, null, "process_start_trace");
Assert.Single(rig.Detected);
// IME / Intune install cmd run as SYSTEM in session 0 — never an interactive console.
using var rig = new Rig(Running(BareCmd));
rig.Trace(sessionId: 0);
Assert.Empty(rig.Detected);
Assert.Null(rig.Sut.TakeScriptActivity());
}
[Theory]
[InlineData(@"C:\WINDOWS\system32\cmd.exe /c ""install.bat""")]
[InlineData(@"cmd.exe /C ping")]
public void A_running_scripted_cmd_is_neither_reported_nor_counted(string commandLine)
{
using var rig = new Rig(Running(commandLine));
rig.Trace();
Assert.Empty(rig.Detected);
Assert.Null(rig.Sut.TakeScriptActivity());
}
[Fact]
public void Invalid_pid_is_ignored()
{
using var rig = new Rig(Probe(BareCmd));
rig.Sut.HandleStart(0, 9016, 1, null, "process_start_trace");
using var rig = new Rig(Running(BareCmd));
rig.Trace(pid: 0);
Assert.Empty(rig.Detected);
Assert.Null(rig.Sut.TakeScriptActivity());
}
// ---------------------------------------------------------------- starts that are counted
[Fact]
public void A_process_that_has_exited_is_counted_and_not_reported()
{
// WMI delivers the start about a second late; a scripted cmd /c is gone by then. That is
// script activity, not a console someone types into.
using var rig = new Rig(Gone, parentName: _ => "powershell.exe");
rig.Trace();
Assert.Empty(rig.Detected);
var activity = rig.Sut.TakeScriptActivity()!;
Assert.Equal(1, activity.Count);
Assert.Equal(Cmd, activity.ProcessName);
Assert.Equal(At, activity.FirstSeenUtc);
Assert.Equal(new[] { 1 }, activity.WindowsSessions);
Assert.Equal(new[] { OobeAccount }, activity.Owners);
var parent = Assert.Single(activity.Parents);
Assert.Equal(Parent, parent.ProcessId);
Assert.Equal("powershell.exe", parent.Name);
}
[Theory]
[InlineData("SearchFilterHost.exe", 1, Parent)] // another program was given the id
[InlineData(Cmd, 0, Parent)] // a service-session cmd was given the id
[InlineData(Cmd, 1, 4242)] // a cmd of another parent was given the id
public void An_id_that_now_belongs_to_another_process_is_counted_and_not_reported(string name, int sessionId, int parentPid)
{
// Its command line has no /c, which used to read as a bare interactive console.
using var rig = new Rig(Running(@"C:\Windows\System32\other.exe -Embedding", name, sessionId, parentPid));
rig.Trace();
Assert.Empty(rig.Detected);
Assert.Equal(1, rig.Sut.TakeScriptActivity()!.Count);
}
[Fact]
public void A_row_without_a_command_line_whose_process_has_ended_is_counted()
{
using var rig = new Rig(Running(commandLine: null), alive: false);
rig.Trace();
Assert.Empty(rig.Detected);
Assert.Equal(1, rig.Sut.TakeScriptActivity()!.Count);
}
[Fact]
public void A_process_that_could_not_be_read_and_is_gone_at_the_second_look_is_counted()
{
using var rig = new Rig((_, __) => ProcessProbe.Unreadable, alive: false);
rig.Trace();
Assert.Empty(rig.Detected);
Assert.Equal(1, rig.Sut.TakeScriptActivity()!.Count);
}
[Fact]
public void A_burst_opens_one_window_and_asks_for_each_parent_once()
{
using var rig = new Rig(Gone, parentName: _ => "python.exe");
for (var i = 0; i < 25; i++) rig.Trace(pid: 5000 + i, parentPid: 2688);
for (var i = 0; i < 21; i++) rig.Trace(pid: 6000 + i, parentPid: 9296);
Assert.Empty(rig.Detected);
Assert.Equal(1, rig.WindowsOpened);
Assert.Equal(new[] { 2688, 9296 }, rig.ParentLookups);
var activity = rig.Sut.TakeScriptActivity()!;
Assert.Equal(46, activity.Count);
Assert.Equal(new[] { 25, 21 }, activity.Parents.Select(p => p.Children));
}
[Fact]
public void The_next_start_after_the_content_was_taken_opens_a_new_window()
{
using var rig = new Rig(Gone);
rig.Trace(pid: 1);
rig.Trace(pid: 2);
rig.Sut.TakeScriptActivity();
rig.Trace(pid: 3);
Assert.Equal(2, rig.WindowsOpened);
Assert.Equal(1, rig.Sut.TakeScriptActivity()!.Count);
}
[Fact]
public void Nothing_is_reported_or_counted_after_dispose()
{
var rig = new Rig(Gone);
rig.Sut.Dispose();
rig.Trace();
Assert.Empty(rig.Detected);
Assert.Null(rig.Sut.TakeScriptActivity());
rig.Dispose();
}
// ---------------------------------------------------------------- de-duplication
[Fact]
public void The_same_process_seen_by_startup_probe_and_trace_raises_only_once()
{
// The startup probe and the live trace can both observe the same still-open console.
using var rig = new Rig(Running(BareCmd));
rig.Sut.HandleProbed(Pid, Parent, 1, OobeAccount, "startup_probe", Cmd, new ProcessProbe(Cmd, 1, Parent, BareCmd, Created));
rig.Trace();
Assert.Equal("startup_probe", Assert.Single(rig.Detected).DetectedVia);
}
[Fact]
public void A_later_console_that_was_given_an_id_seen_before_is_reported_again()
{
var created = Created;
using var rig = new Rig((_, __) => new ProcessProbe(Cmd, 1, Parent, BareCmd, created));
rig.Trace();
created = "20261111150324.504661+060";
rig.Trace();
Assert.Equal(2, rig.Detected.Count);
}
// ---------------------------------------------------------------- the probe's decision
private static readonly ProcessProbe Row = new ProcessProbe(Cmd, 1, Parent, BareCmd, Created);
[Theory]
[InlineData("NotRunning")]
[InlineData("OtherImage")]
public void The_probe_says_gone_only_when_the_process_list_is_certain(string listed)
{
var asked = false;
var state = (ConsoleBypassWatcher.ListedAs)Enum.Parse(typeof(ConsoleBypassWatcher.ListedAs), listed);
var probe = ConsoleBypassWatcher.Probe(Pid, state, _ => { asked = true; return Row; });
Assert.Null(probe);
Assert.False(asked, "a process that is certainly gone costs no query");
}
[Theory]
[InlineData("Expected")]
[InlineData("Unknown")]
public void The_probe_returns_the_row_of_a_process_that_may_still_run(string listed)
{
var state = (ConsoleBypassWatcher.ListedAs)Enum.Parse(typeof(ConsoleBypassWatcher.ListedAs), listed);
Assert.Same(Row, ConsoleBypassWatcher.Probe(Pid, state, _ => Row));
}
[Fact]
public void A_failed_query_is_unreadable_and_never_gone()
{
var probe = ConsoleBypassWatcher.Probe(
Pid, ConsoleBypassWatcher.ListedAs.Expected, _ => throw new InvalidOperationException("WinMgmt did not answer"));
Assert.Same(ProcessProbe.Unreadable, probe);
}
[Fact]
public void A_query_without_a_row_is_unreadable_and_never_gone()
{
var probe = ConsoleBypassWatcher.Probe(Pid, ConsoleBypassWatcher.ListedAs.Expected, _ => null);
Assert.Same(ProcessProbe.Unreadable, probe);
}
[Fact]
public void The_process_list_tells_running_other_image_and_not_running_apart()
{
using var me = System.Diagnostics.Process.GetCurrentProcess();
const int neverAProcessId = int.MaxValue;
Assert.Equal(ConsoleBypassWatcher.ListedAs.Expected, ConsoleBypassWatcher.ProcessListState(me.Id, me.ProcessName + ".exe"));
Assert.Equal(ConsoleBypassWatcher.ListedAs.OtherImage, ConsoleBypassWatcher.ProcessListState(me.Id, "not-" + me.ProcessName + ".exe"));
Assert.Equal(ConsoleBypassWatcher.ListedAs.NotRunning, ConsoleBypassWatcher.ProcessListState(neverAProcessId, Cmd));
Assert.True(ConsoleBypassWatcher.IsRunning(me.Id));
Assert.False(ConsoleBypassWatcher.IsRunning(neverAProcessId));
}
// ---------------------------------------------------------------- IsTracedProcess (pure)
[Theory]
[InlineData("cmd.exe", 1, Parent, true)]
[InlineData("CMD.EXE", 2, Parent, true)] // image name compares without case
[InlineData("cmd.exe", 1, 0, true)] // the row does not know its parent
// A fact that could not be read contradicts nothing — the process stays the traced one.
[InlineData(null, null, null, true)]
[InlineData(null, 1, Parent, true)]
[InlineData("cmd.exe", null, Parent, true)]
[InlineData("cmd.exe", 1, null, true)]
// A fact that was read and differs makes it another process.
[InlineData("conhost.exe", 1, Parent, false)]
[InlineData("cmd.exe", 0, Parent, false)]
[InlineData("cmd.exe", 1, 77, false)]
public void IsTracedProcess_compares_name_session_and_parent(string? name, int? sessionId, int? rowParent, bool expected)
{
var probe = new ProcessProbe(name, sessionId, rowParent, BareCmd, Created);
Assert.Equal(expected, ConsoleBypassWatcher.IsTracedProcess(probe, Cmd, Parent));
}
[Fact]
public void IsTracedProcess_accepts_any_parent_when_the_trace_has_none()
{
var probe = new ProcessProbe(Cmd, 1, 77, BareCmd, Created);
Assert.True(ConsoleBypassWatcher.IsTracedProcess(probe, Cmd, parentPid: 0));
}
// ---------------------------------------------------------------- Classify (pure)
[Theory]
[InlineData(0, BareCmd, "Ignore")] // service session
[InlineData(1, null, "UnclassifiedInteractive")] // instant-close
[InlineData(1, null, "UnclassifiedInteractive")] // running, command line not readable
[InlineData(1, BareCmd, "InteractiveConsole")] // bare shell
[InlineData(1, @"""C:\WINDOWS\system32\cmd.exe""", "InteractiveConsole")]
[InlineData(1, "", "InteractiveConsole")]
[InlineData(1, @"cmd.exe /d", "InteractiveConsole")] // /d skips AutoRun — still interactive
[InlineData(1, @"C:\cmd\cmd.exe", "InteractiveConsole")] // folder named 'cmd', not a switch
[InlineData(1, @"""C:/k/cmd.exe""", "InteractiveConsole")] // forward-slash path stripped before scan
[InlineData(1, @"cmd.exe /c foo", "Ignore")] // scripted, run-and-exit
[InlineData(1, @"cmd.exe /C whoami", "Ignore")]
[InlineData(1, @"""C:\WINDOWS\system32\cmd.exe"" /c ""x.bat""", "Ignore")]
[InlineData(2, @"C:\x\cmd.exe /K bar", "InteractiveWithCommand")] // L12: /k stays interactive
[InlineData(1, @"cmd.exe /k whoami", "InteractiveWithCommand")] // technician-planted shell
[InlineData(1, @"cmd.exe /k", "InteractiveWithCommand")]
[InlineData(1, @"cmd.exe /D/Q/K stay", "InteractiveWithCommand")] // combined run-and-stay
[InlineData(1, @"cmd.exe /d/q/c exit 9", "Ignore")] // combined run-and-exit wins
[InlineData(1, @"cmd /k rem /c", "InteractiveWithCommand")] // cmd.exe honors the FIRST switch: /k stays interactive
[InlineData(1, @"cmd.exe /k ""title /c""", "InteractiveWithCommand")] // /c inside the /k payload is not a switch
[InlineData(1, @"cmd /k /c", "InteractiveWithCommand")] // /c after /k is just the /k payload
[InlineData(1, @"cmd.exe /c cmd /k", "Ignore")] // first switch /c wins the other way round
[InlineData(1, @"cmd.exe /c rem /k", "Ignore")]
public void Classify_maps_session_and_commandline(int sessionId, string? commandLine, string expected)
{
Assert.Equal(expected, ConsoleBypassWatcher.Classify(sessionId, commandLine).ToString());
}
// ---------------------------------------------------------------- HasScriptArgument (pure)
[Theory]
[InlineData(@"C:\WINDOWS\system32\cmd.exe", false)]
[InlineData(@"""C:\WINDOWS\system32\cmd.exe""", false)]
[InlineData(@"cmd.exe /d", false)] // /d skips AutoRun — still interactive
[InlineData(@"C:\cmd\cmd.exe", false)] // folder named 'cmd', not a switch
[InlineData(@"""C:/k/cmd.exe""", false)] // forward-slash path stripped before scan
[InlineData("", false)]
[InlineData(@"cmd.exe /c whoami", true)]
[InlineData(@"cmd.exe /C whoami", true)]
[InlineData(@"cmd.exe /k", false)] // L12: /k leaves an interactive shell
[InlineData(@"cmd.exe /d/q/c exit 9", true)] // combined switches — the field case
[InlineData(@"cmd.exe /D/Q/K stay", false)] // L12: run-and-stay is not scripted
[InlineData(@"cmd.exe /k rem /c", false)] // first switch /k wins — payload /c is inert
[InlineData(@"cmd.exe /c rem /k", true)] // first switch /c wins
[InlineData(@"""C:\WINDOWS\system32\cmd.exe"" /c ""x.bat""", true)]
public void HasScriptArgument_detects_run_command_switch(string commandLine, bool expected)
{
Assert.Equal(expected, ConsoleBypassWatcher.HasScriptArgument(commandLine));
}
[Theory]
[InlineData(@"C:\WINDOWS\system32\cmd.exe", "")]
[InlineData(@"cmd.exe /d/q/c exit 9", "/d/q/c exit 9")]
@@ -0,0 +1,152 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
{
/// <summary>
/// The tally behind <c>console_script_activity</c>: what it counts, what it lists and caps, and
/// that taking the content empties it.
/// </summary>
public sealed class ConsoleScriptActivityTallyTests
{
private static readonly DateTime At = new DateTime(2026, 11, 11, 14, 41, 35, DateTimeKind.Utc);
private const string Cmd = "cmd.exe";
private const string OobeAccount = "S-1-5-21-1-2-3-1000";
private static string? NoName(int pid) => null;
[Fact]
public void An_empty_tally_has_nothing_to_take()
{
Assert.Null(new ConsoleScriptActivityTally().Take());
}
[Fact]
public void Starts_are_counted_with_time_span_sessions_owners_and_parents()
{
var sut = new ConsoleScriptActivityTally();
sut.Add(Cmd, 101, 50, sessionId: 2, OobeAccount, At, _ => "powershell.exe");
sut.Add(Cmd, 102, 50, sessionId: 1, OobeAccount, At.AddSeconds(1), _ => "powershell.exe");
sut.Add(Cmd, 103, 60, sessionId: 1, "S-1-5-18", At.AddSeconds(9), _ => "setup.exe");
var a = sut.Take()!;
Assert.Equal(Cmd, a.ProcessName);
Assert.Equal(3, a.Count);
Assert.Equal(At, a.FirstSeenUtc);
Assert.Equal(At.AddSeconds(9), a.LastSeenUtc);
Assert.Equal(new[] { 1, 2 }, a.WindowsSessions);
Assert.Equal(new[] { OobeAccount, "S-1-5-18" }, a.Owners);
Assert.Equal(2, a.OwnersTotal);
Assert.Equal(2, a.ParentsTotal);
Assert.Equal(new[] { 50, 60 }, a.Parents.Select(p => p.ProcessId));
Assert.Equal(new[] { 2, 1 }, a.Parents.Select(p => p.Children));
Assert.Equal(new[] { "powershell.exe", "setup.exe" }, a.Parents.Select(p => p.Name));
}
[Fact]
public void Taking_empties_the_tally_and_the_next_start_opens_it_again()
{
var sut = new ConsoleScriptActivityTally();
Assert.True(sut.Add(Cmd, 101, 50, 1, OobeAccount, At, NoName));
Assert.False(sut.Add(Cmd, 102, 50, 1, OobeAccount, At, NoName));
Assert.Equal(2, sut.Take()!.Count);
Assert.Null(sut.Take());
Assert.True(sut.Add(Cmd, 103, 50, 1, OobeAccount, At, NoName));
Assert.Equal(1, sut.Take()!.Count);
}
[Fact]
public void A_parent_is_looked_up_once_even_when_the_lookup_finds_nothing()
{
var sut = new ConsoleScriptActivityTally();
var asked = new List<int>();
string? Lookup(int pid) { asked.Add(pid); return null; }
for (var i = 0; i < 5; i++) sut.Add(Cmd, 100 + i, 50, 1, OobeAccount, At, Lookup);
Assert.Equal(new[] { 50 }, asked);
var parent = Assert.Single(sut.Take()!.Parents);
Assert.Null(parent.Name);
Assert.Equal(5, parent.Children);
}
[Fact]
public void A_parent_that_was_counted_itself_is_named_without_a_lookup()
{
// A script shell that is already gone, and the children it started.
var sut = new ConsoleScriptActivityTally();
var asked = new List<int>();
string? Lookup(int pid) { asked.Add(pid); return "AgentExecutor.exe"; }
sut.Add(Cmd, 100, 50, 2, OobeAccount, At, Lookup);
for (var i = 1; i <= 6; i++) sut.Add(Cmd, 100 + i, 100, 2, OobeAccount, At, Lookup);
Assert.Equal(new[] { 50 }, asked);
var a = sut.Take()!;
Assert.Equal(7, a.Count);
Assert.Equal(new[] { 100, 50 }, a.Parents.Select(p => p.ProcessId));
Assert.Equal(new[] { Cmd, "AgentExecutor.exe" }, a.Parents.Select(p => p.Name));
}
[Fact]
public void Lookups_are_limited_per_window_and_allowed_again_after_taking()
{
var sut = new ConsoleScriptActivityTally();
var lookups = 0;
string? Lookup(int pid) { lookups++; return "p" + pid + ".exe"; }
for (var i = 0; i < ConsoleScriptActivityTally.MaxParentLookups + 3; i++)
sut.Add(Cmd, 1000 + i, 10 + i, 1, OobeAccount, At, Lookup);
Assert.Equal(ConsoleScriptActivityTally.MaxParentLookups, lookups);
sut.Take();
sut.Add(Cmd, 2000, 99, 1, OobeAccount, At, Lookup);
Assert.Equal(ConsoleScriptActivityTally.MaxParentLookups + 1, lookups);
}
[Fact]
public void Lists_are_capped_and_the_totals_tell_how_much_there_was()
{
var sut = new ConsoleScriptActivityTally();
const int parents = ConsoleScriptActivityTally.MaxParentsListed + 3;
const int owners = ConsoleScriptActivityTally.MaxOwnersListed + 2;
for (var p = 0; p < parents; p++)
{
// Parent p gets p+1 children, so the busiest parents are the last ones added.
for (var c = 0; c <= p; c++)
sut.Add(Cmd, 10_000 + p * 100 + c, 500 + p, 1, "S-1-5-21-9-9-9-" + (p % owners), At, NoName);
}
var a = sut.Take()!;
Assert.Equal(parents, a.ParentsTotal);
Assert.Equal(ConsoleScriptActivityTally.MaxParentsListed, a.Parents.Count);
Assert.Equal(500 + parents - 1, a.Parents[0].ProcessId);
Assert.Equal(parents, a.Parents[0].Children);
Assert.Equal(owners, a.OwnersTotal);
Assert.Equal(ConsoleScriptActivityTally.MaxOwnersListed, a.Owners.Count);
}
[Fact]
public void A_start_without_a_parent_id_or_owner_still_counts()
{
var sut = new ConsoleScriptActivityTally();
sut.Add(Cmd, 101, 0, 1, null, At, _ => throw new InvalidOperationException("no parent to look up"));
var a = sut.Take()!;
Assert.Equal(1, a.Count);
Assert.Empty(a.Parents);
Assert.Equal(0, a.ParentsTotal);
Assert.Empty(a.Owners);
}
}
}
@@ -80,6 +80,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
Assert.Equal(2, client.Calls);
}
[Fact]
public async Task Registration_session_gone_report_is_exempt_from_the_cooldown_like_the_telemetry_one()
{
// The integrity report goes out post-config, seconds before the registration is refused.
var clock = Clock();
var client = new FakeApiClient();
client.Results.Enqueue(true);
client.Results.Enqueue(true);
var reporter = Reporter(client, clock);
await reporter.TrySendAsync(AgentErrorType.IntegrityCheckFailed, "m");
clock.Advance(TimeSpan.FromSeconds(5));
await reporter.TrySendAsync(AgentErrorType.RegistrationRejectedSessionGone, "m", httpStatusCode: 410);
Assert.Equal(2, client.Calls);
Assert.Equal(AgentErrorType.RegistrationRejectedSessionGone, client.Reports[1].ErrorType);
Assert.Equal(410, client.Reports[1].HttpStatusCode);
// Key dedup still applies: a second one is held back.
await reporter.TrySendAsync(AgentErrorType.RegistrationRejectedSessionGone, "m", httpStatusCode: 410);
Assert.Equal(2, client.Calls);
}
[Fact]
public async Task Second_session_unknown_report_within_the_window_is_still_suppressed()
{
@@ -0,0 +1,325 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Persistence;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
{
/// <summary>
/// ConsoleBypassHost — what goes onto the single rail: a console the watcher reports becomes a
/// Warning <c>oobe_console_spawned</c>; starts the watcher only counts become one Info
/// <c>console_script_activity</c> per window, capped per run, and whatever is still counted goes
/// out on every way the host stops. The watcher is driven through its internal entry points with
/// injected probes; no WMI subscription and no window timer runs here.
/// </summary>
[Collection("SerialThreading")] // one test runs a real SignalIngress worker
public sealed class ConsoleBypassHostTests : IDisposable
{
private static readonly DateTime At = new DateTime(2026, 11, 11, 14, 41, 35, DateTimeKind.Utc);
private const string Cmd = "cmd.exe";
private const string BareCmd = @"C:\WINDOWS\system32\cmd.exe";
private const string OobeAccount = "S-1-5-21-1-2-3-1000";
private const int ScriptHost = 2688;
private const int GoneLauncher = 9016;
private readonly TempDirectory _tmp = new TempDirectory();
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
private readonly AgentLogger _logger;
private string? _runningCommandLine; // null = every probed process has exited
private DateTime _now = At;
public ConsoleBypassHostTests()
{
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
}
public void Dispose() => _tmp.Dispose();
private ConsoleBypassWatcher NewWatcher() => new ConsoleBypassWatcher(
_logger,
processProbe: (pid, name) => _runningCommandLine == null
? null
: new ProcessProbe(name, 1, 0, _runningCommandLine, "20261111144135.000000+060"),
isAlive: _ => true,
parentName: pid => pid == ScriptHost ? "powershell.exe" : null,
utcNow: () => _now);
private ConsoleBypassHost Build(ConsoleBypassWatcher watcher, ISignalIngressSink? ingress = null) =>
new ConsoleBypassHost("sess-console", "tenant-console", ingress ?? _sink, new VirtualClock(At), _logger, watcher);
private static void Trace(ConsoleBypassWatcher watcher, int pid, int parentPid = ScriptHost) =>
watcher.HandleStart(pid, parentPid, 1, OobeAccount, "process_start_trace", Cmd);
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> Events(string eventType) =>
_sink.Posted.Where(p =>
p.Kind == DecisionSignalKind.InformationalEvent
&& p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == eventType).ToList();
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal s) =>
(IReadOnlyDictionary<string, object>)s.TypedPayload!;
// ---------------------------------------------------------------- consoles
[Fact]
public void A_console_is_a_warning_with_immediate_upload_and_the_name_of_its_parent()
{
_runningCommandLine = BareCmd;
var watcher = NewWatcher();
using var host = Build(watcher);
Trace(watcher, pid: 9636);
var s = Assert.Single(Events(Constants.EventTypes.OobeConsoleSpawned));
Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]);
Assert.Equal("true", s.Payload![SignalPayloadKeys.ImmediateUpload]);
var data = Data(s);
Assert.Equal("high", data["confidence"]);
Assert.Equal("powershell.exe", data["parentProcessName"]);
Assert.Equal(ScriptHost, data["parentProcessId"]);
Assert.Equal(OobeAccount, data["owner"]);
Assert.Equal(BareCmd, data["commandLine"]);
Assert.Empty(Events(Constants.EventTypes.ConsoleScriptActivity));
}
[Fact]
public void A_low_confidence_console_is_uploaded_at_once_too_and_names_a_gone_parent_unknown()
{
// The confidence follows the command line, which whoever opens the console chooses:
// it labels the warning, it never delays it.
_runningCommandLine = "cmd.exe /k whoami";
var watcher = NewWatcher();
using var host = Build(watcher);
Trace(watcher, pid: 9636, parentPid: GoneLauncher);
var s = Assert.Single(Events(Constants.EventTypes.OobeConsoleSpawned));
Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]);
Assert.Equal("true", s.Payload![SignalPayloadKeys.ImmediateUpload]);
Assert.Equal("low", Data(s)["confidence"]);
Assert.Equal("unknown", Data(s)["parentProcessName"]);
}
// ---------------------------------------------------------------- script activity
[Fact]
public void Counted_starts_become_one_info_line_when_the_window_ends()
{
var watcher = NewWatcher();
using var host = Build(watcher);
Trace(watcher, pid: 5001);
_now = At.AddSeconds(1);
Trace(watcher, pid: 5002);
_now = At.AddSeconds(2);
Trace(watcher, pid: 5003);
Assert.Empty(_sink.Posted);
host.WindowElapsedForTest();
Assert.Empty(Events(Constants.EventTypes.OobeConsoleSpawned));
var s = Assert.Single(Events(Constants.EventTypes.ConsoleScriptActivity));
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
Assert.Equal("false", s.Payload![SignalPayloadKeys.ImmediateUpload]);
Assert.Equal(At.AddSeconds(2), s.OccurredAtUtc);
Assert.Equal(
"Script activity: 3 short-lived cmd.exe processes in session 1, started by powershell.exe — counted, not an interactive console",
s.Payload![SignalPayloadKeys.Message]);
var data = Data(s);
Assert.Equal(3, data["count"]);
Assert.Equal(Cmd, data["processName"]);
Assert.Equal(At.ToString("o"), data["firstSeenUtc"]);
Assert.Equal(At.AddSeconds(2).ToString("o"), data["lastSeenUtc"]);
Assert.Equal(new[] { 1 }, (List<int>)data["windowsSessions"]);
Assert.Equal(new[] { OobeAccount }, (List<string>)data["owners"]);
Assert.Equal(1, data["ownersTotal"]);
Assert.Equal(1, data["parentsTotal"]);
var parent = Assert.Single((List<Dictionary<string, object>>)data["parents"]);
Assert.Equal(ScriptHost, parent["pid"]);
Assert.Equal("powershell.exe", parent["name"]);
Assert.Equal(3, parent["children"]);
Assert.False(data.ContainsKey("capped"));
}
[Fact]
public void A_window_without_counted_starts_reports_nothing()
{
using var host = Build(NewWatcher());
host.WindowElapsedForTest();
Assert.Empty(_sink.Posted);
}
[Fact]
public void Each_window_reports_only_its_own_starts()
{
var watcher = NewWatcher();
using var host = Build(watcher);
Trace(watcher, pid: 5001);
host.WindowElapsedForTest();
Trace(watcher, pid: 5002);
Trace(watcher, pid: 5003);
host.WindowElapsedForTest();
Assert.Equal(new object[] { 1, 2 }, Events(Constants.EventTypes.ConsoleScriptActivity).Select(s => Data(s)["count"]));
}
[Fact]
public void After_the_cap_no_window_reports_and_the_line_at_stop_says_so()
{
var watcher = NewWatcher();
var host = Build(watcher);
for (var i = 0; i < ConsoleBypassHost.MaxScriptActivitySummaries; i++)
{
Trace(watcher, pid: 5000 + i);
host.WindowElapsedForTest();
}
Assert.Equal(ConsoleBypassHost.MaxScriptActivitySummaries, Events(Constants.EventTypes.ConsoleScriptActivity).Count);
Trace(watcher, pid: 7001);
Trace(watcher, pid: 7002);
host.WindowElapsedForTest();
Assert.Equal(ConsoleBypassHost.MaxScriptActivitySummaries, Events(Constants.EventTypes.ConsoleScriptActivity).Count);
host.Stop();
var all = Events(Constants.EventTypes.ConsoleScriptActivity);
Assert.Equal(ConsoleBypassHost.MaxScriptActivitySummaries + 1, all.Count);
Assert.All(all.Take(ConsoleBypassHost.MaxScriptActivitySummaries), s => Assert.False(Data(s).ContainsKey("capped")));
var last = Data(all[all.Count - 1]);
Assert.Equal(2, last["count"]);
Assert.Equal(true, last["capped"]);
}
// ---------------------------------------------------------------- stopping
[Fact]
public void Stopping_reports_what_was_counted_exactly_once()
{
var watcher = NewWatcher();
var host = Build(watcher);
Trace(watcher, pid: 5001);
Trace(watcher, pid: 5002);
host.Stop();
host.Stop();
host.Dispose();
Assert.Equal(2, Data(Assert.Single(Events(Constants.EventTypes.ConsoleScriptActivity)))["count"]);
}
[Fact]
public void Desktop_arrival_reports_what_was_counted()
{
var watcher = NewWatcher();
var host = Build(watcher);
Trace(watcher, pid: 5001);
host.StopForDesktopArrival();
Assert.Single(Events(Constants.EventTypes.ConsoleScriptActivity));
}
[Fact]
public void The_stop_signal_on_the_decision_rail_reports_what_was_counted()
{
var clock = new VirtualClock(At);
using var ingress = new SignalIngress(
engine: new DecisionEngine(),
signalLog: new SignalLogWriter(_tmp.File("signal-log.jsonl")),
traceCounter: new SessionTraceOrdinalProvider(),
processor: new FakeDecisionStepProcessor(),
clock: clock);
var postedEventTypes = new List<string>();
ingress.SignalPosted += (kind, payload) =>
{
if (kind == DecisionSignalKind.InformationalEvent && payload != null
&& payload.TryGetValue(SignalPayloadKeys.EventType, out var et))
{
lock (postedEventTypes) postedEventTypes.Add(et);
}
};
var watcher = NewWatcher();
var host = Build(watcher, ingress);
ingress.Start();
host.StartCore(armWatcher: false);
Trace(watcher, pid: 5001);
ingress.Post(
DecisionSignalKind.DesktopArrived, At, "DesktopArrivalDetector",
new Evidence(EvidenceKind.Raw, "desktop", "evidence-desktop"));
Assert.True(SpinWait.SpinUntil(() => { lock (postedEventTypes) return postedEventTypes.Contains(Constants.EventTypes.ConsoleScriptActivity); }, 5000),
"the host stopped on the rail signal without reporting what was counted");
Trace(watcher, pid: 5002); // the watcher is disposed: nothing is counted any more
Assert.Null(watcher.TakeScriptActivity());
}
[Fact]
public void A_stopped_ingress_does_not_break_the_stop()
{
var watcher = NewWatcher();
var host = Build(watcher);
Trace(watcher, pid: 5001);
_sink.ThrowOnPost = new InvalidOperationException("SignalIngress has been stopped.");
Assert.Null(Record.Exception(() => host.Dispose()));
}
// ---------------------------------------------------------------- timeline text
private static ConsoleScriptActivity Activity(int count, int[] sessions, int parentsTotal, params (int pid, string? name, int children)[] parents) =>
new ConsoleScriptActivity(
Cmd, count, At, At, sessions,
new[] { OobeAccount }, 1,
parents.Select(p => new ConsoleScriptParent(p.pid, p.name, p.children)).ToList(), parentsTotal);
[Fact]
public void One_process_from_a_parent_that_is_gone()
{
Assert.Equal(
"Script activity: 1 short-lived cmd.exe process in session 1, started by an unknown program — counted, not an interactive console",
ConsoleBypassHost.DescribeScriptActivity(Activity(1, new[] { 1 }, 1, (6688, null, 1))));
}
[Fact]
public void Several_named_parents_in_two_sessions()
{
Assert.Equal(
"Script activity: 46 short-lived cmd.exe processes in sessions 1, 2, started by 2 parent processes (powershell.exe, cmd.exe) — counted, not an interactive console",
ConsoleBypassHost.DescribeScriptActivity(Activity(46, new[] { 1, 2 }, 2, (2688, "powershell.exe", 25), (9296, "cmd.exe", 21))));
}
[Fact]
public void More_parents_than_names_end_in_an_ellipsis()
{
Assert.Equal(
"Script activity: 9 short-lived cmd.exe processes in session 2, started by 4 parent processes (python.exe, …) — counted, not an interactive console",
ConsoleBypassHost.DescribeScriptActivity(Activity(9, new[] { 2 }, 4, (10, "python.exe", 5), (11, null, 4))));
}
[Fact]
public void Parents_without_any_name_are_only_counted()
{
Assert.Equal(
"Script activity: 3 short-lived cmd.exe processes in session 1, started by 3 parent processes — counted, not an interactive console",
ConsoleBypassHost.DescribeScriptActivity(Activity(3, new[] { 1 }, 3, (10, null, 1), (11, null, 1), (12, null, 1))));
}
}
}
@@ -43,6 +43,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
[InlineData("network_connectivity_check")]
// Bookkeeping of health-script runs that were deliberately not reported.
[InlineData("script_recurrence_summary")]
// Counted short-lived console processes — a spawning script is not enrollment progress.
[InlineData("console_script_activity")]
// Agent health / control / transport — not device/enrollment progress (P2).
[InlineData("collector_degraded")]
[InlineData("telemetry_upload_poisoned")]
@@ -0,0 +1,167 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.Agent.V2.Runtime;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared.Models;
using Xunit;
using ProgramAlias = AutopilotMonitor.Agent.V2.Program;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
/// <summary>
/// A registration the backend answered with HTTP 410 at process start: the session was deleted.
/// Exactly one <see cref="AgentErrorType.RegistrationRejectedSessionGone"/> report, then the
/// start-time end of monitoring with a forced cleanup — the same end state as the emergency
/// break and the kill signal. The WhiteGlove Part 2 start that registers a new session instead
/// is covered by <c>SessionRegistrationHelperTests</c>; here only its trace on <c>agent_started</c>.
/// </summary>
public sealed class SessionGoneAtRegistrationTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
private readonly AgentLogger _logger;
private readonly SessionIdPersistence _persistence;
private readonly string _stateDir;
private readonly List<string> _sequence = new List<string>();
public SessionGoneAtRegistrationTests()
{
_logger = new AgentLogger(Path.Combine(_tmp.Path, "logs"), AgentLogLevel.Info);
_persistence = new SessionIdPersistence(_tmp.Path);
_persistence.GetOrCreate(_logger);
_stateDir = Path.Combine(_tmp.Path, "State");
}
public void Dispose() => _tmp.Dispose();
private sealed class RecordingEmergencyReporter : EmergencyReporter
{
private readonly List<string> _sequence;
public List<(AgentErrorType ErrorType, string Message, int? HttpStatusCode, int Attempts)> Reports { get; } =
new List<(AgentErrorType, string, int?, int)>();
public RecordingEmergencyReporter(List<string> sequence)
: base(apiClient: null!, sessionId: "S1", tenantId: "T1", agentVersion: "0.0.0", logger: null!)
{
_sequence = sequence;
}
public override Task TrySendAsync(
AgentErrorType errorType,
string message,
int? httpStatusCode = null,
long? sequenceNumber = null,
int attempts = 1,
TimeSpan? perAttemptTimeout = null,
TimeSpan? retryDelay = null,
double? sessionAgeHours = null,
RegistrationFailureSummary? priorRegistrationFailure = null)
{
Reports.Add((errorType, message, httpStatusCode, attempts));
_sequence.Add("report");
return Task.CompletedTask;
}
}
private CleanupService CleanupFactory()
{
_sequence.Add("cleanup");
// The real factory spawns a PowerShell cleanup script; throwing makes the cleanup retry
// swallow and log it, so the call is counted without a side effect (as in ProgramGuardsTests).
throw new InvalidOperationException("test-harness: cleanup factory must not spawn real PowerShell in unit tests");
}
private bool MarkerExists => File.Exists(Path.Combine(_stateDir, "enrollment-complete.marker"));
[Theory]
[InlineData(true)]
[InlineData(false)] // forced like the kill signal: SelfDestructOnComplete does not gate it
public void Reports_once_then_ends_monitoring_with_a_forced_cleanup(bool selfDestructOnComplete)
{
_persistence.SaveWhiteGloveComplete(_logger);
var config = new AgentConfiguration { SessionId = "S1", TenantId = "T1", SelfDestructOnComplete = selfDestructOnComplete };
var reporter = new RecordingEmergencyReporter(_sequence);
var exitCode = BackendSessionRegistration.ConcludeSessionGone(
config, reporter, _persistence, _stateDir, CleanupFactory, consoleMode: false, _logger);
Assert.Equal(0, exitCode);
// The report leaves before the cleanup deletes the agent.
Assert.Equal(new[] { "report", "cleanup" }, _sequence);
var report = Assert.Single(reporter.Reports);
Assert.Equal(AgentErrorType.RegistrationRejectedSessionGone, report.ErrorType);
Assert.Equal(410, report.HttpStatusCode);
Assert.Contains("410", report.Message);
Assert.True(report.Attempts > 1, "the process ends right after: the one report gets a retry");
Assert.True(MarkerExists);
Assert.False(_persistence.SessionExists());
Assert.False(_persistence.IsWhiteGloveResume());
Assert.Null(_persistence.LoadSessionCreatedAt());
// The next start ends at the bootstrap guard, exactly as after the emergency break.
Assert.True(ProgramAlias.CheckEnrollmentCompleteMarker(
_stateDir, selfDestructOnComplete: false, cleanupServiceFactory: null, _logger, consoleMode: false));
}
[Fact]
public void Ends_monitoring_without_a_reporter()
{
var config = new AgentConfiguration { SessionId = "S1", TenantId = "T1" };
var exitCode = BackendSessionRegistration.ConcludeSessionGone(
config, emergencyReporter: null!, _persistence, _stateDir, CleanupFactory, consoleMode: false, _logger);
Assert.Equal(0, exitCode);
Assert.Equal(new[] { "cleanup" }, _sequence);
Assert.True(MarkerExists);
Assert.False(_persistence.SessionExists());
}
[Theory]
[InlineData(SessionRotationReasons.SessionGone)]
[InlineData(SessionRotationReasons.OwnerMismatch)]
public void Agent_started_names_why_the_session_was_rotated(string reason)
{
var data = EmitAgentStarted(rotatedFromSessionId: "11111111-1111-1111-1111-111111111111", reason);
Assert.Equal(true, data["sessionRotated"]);
Assert.Equal("11111111-1111-1111-1111-111111111111", data["previousSessionId"]);
Assert.Equal(reason, data["sessionRotationReason"]);
}
[Fact]
public void Agent_started_carries_no_rotation_reason_on_the_normal_path()
{
var data = EmitAgentStarted(rotatedFromSessionId: null, sessionRotationReason: null);
Assert.Equal(false, data["sessionRotated"]);
Assert.False(data.ContainsKey("sessionRotationReason"));
}
private Dictionary<string, object> EmitAgentStarted(string? rotatedFromSessionId, string? sessionRotationReason)
{
var sink = new FakeSignalIngressSink();
LifecycleEmitters.EmitAgentStarted(
new InformationalEventPost(sink, SystemClock.Instance, _logger),
new AgentConfiguration { SessionId = "S1", TenantId = "00000000-0000-0000-0000-000000000001", CommandLineArgs = string.Empty },
new ProgramAlias.PreviousExitSummary { ExitType = "reboot_kill" },
"2.0.test",
remoteConfigService: null,
_logger,
rotatedFromSessionId: rotatedFromSessionId,
sessionRotationReason: sessionRotationReason);
return (Dictionary<string, object>)Assert.Single(sink.Posted).TypedPayload!;
}
}
}
@@ -166,6 +166,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.Equal(0, tracker.ConsecutiveFailures);
Assert.Equal(new[] { originalSessionId, rotatedTo }, seenSessionIds);
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
Assert.Equal(SessionRotationReasons.OwnerMismatch, result.RotationReason);
}
private static BackendRefusalException TenantConflict()
@@ -313,6 +314,136 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.Equal(1, tracker.ConsecutiveFailures);
}
// ── 410 at registration: the backend deleted the session ────────────
private static BackendSessionGoneException SessionGone()
=> new BackendSessionGoneException("Backend returned 410 Gone: the session was deleted or is being deleted.", 410);
[Fact]
public async Task Session_gone_ends_the_run_at_once_without_retry_rotation_or_auth_failure()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var rotations = 0;
var apiClient = new FakeApiClient(_ => throw SessionGone());
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
clock: SystemClock.Instance, logger: logger);
// The rotation hook is wired on every start; without the Part 2 flag it must stay unused.
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
authFailureTracker: tracker,
backoffDelay: _ => throw new InvalidOperationException("a 410 must not be retried"),
networkLinkWait: _ => Task.CompletedTask,
rotateSession: () => { rotations++; return Guid.NewGuid().ToString(); });
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
Assert.Equal(410, result.HttpStatusCode);
Assert.Equal(1, apiClient.CallCount);
Assert.Equal(0, rotations);
Assert.Equal(0, tracker.ConsecutiveFailures);
Assert.Null(result.RotatedFromSessionId);
Assert.Null(result.RotationReason);
}
[Fact]
public async Task Session_gone_at_a_whiteglove_part2_start_rotates_once_and_registers_the_new_session()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var originalSessionId = config.SessionId;
var rotatedTo = Guid.NewGuid().ToString();
var seenSessionIds = new System.Collections.Generic.List<string>();
var rotations = 0;
var apiClient = new FakeApiClient(
attempt => attempt == 1
? throw SessionGone()
: new RegisterSessionResponse { Success = true, SessionId = rotatedTo, ValidatedBy = ValidatorType.AutopilotV1 },
onRegister: r => seenSessionIds.Add(r.SessionId));
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
clock: SystemClock.Instance, logger: logger);
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
authFailureTracker: tracker,
backoffDelay: _ => throw new InvalidOperationException("rotation must retry without backoff"),
networkLinkWait: _ => Task.CompletedTask,
rotateSession: () => { rotations++; config.SessionId = rotatedTo; return rotatedTo; },
rotateOnSessionGone: true);
Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
Assert.Equal(2, apiClient.CallCount);
Assert.Equal(1, rotations);
Assert.Equal(0, tracker.ConsecutiveFailures);
Assert.Equal(new[] { originalSessionId, rotatedTo }, seenSessionIds);
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
Assert.Equal(SessionRotationReasons.SessionGone, result.RotationReason);
}
[Fact]
public async Task Second_session_gone_after_rotation_ends_the_run()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var rotations = 0;
var apiClient = new FakeApiClient(_ => throw SessionGone());
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
backoffDelay: _ => throw new InvalidOperationException("a 410 must not be retried"),
networkLinkWait: _ => Task.CompletedTask,
rotateSession: () => { rotations++; config.SessionId = Guid.NewGuid().ToString(); return config.SessionId; },
rotateOnSessionGone: true);
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
Assert.Equal(2, apiClient.CallCount);
Assert.Equal(1, rotations);
}
[Fact]
public async Task Session_gone_with_a_failing_rotation_ends_the_run()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var apiClient = new FakeApiClient(_ => throw SessionGone());
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
networkLinkWait: _ => Task.CompletedTask,
rotateSession: () => throw new IOException("disk full"),
rotateOnSessionGone: true);
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
Assert.Equal(1, apiClient.CallCount);
Assert.Null(result.RotatedFromSessionId);
}
[Fact]
public async Task Session_gone_at_a_whiteglove_part2_start_without_rotation_hook_ends_the_run()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var apiClient = new FakeApiClient(_ => throw SessionGone());
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
networkLinkWait: _ => Task.CompletedTask,
rotateOnSessionGone: true);
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
Assert.Equal(1, apiClient.CallCount);
}
[Fact]
public async Task Fails_after_six_exceptions_without_auth_error()
{
@@ -138,6 +138,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
() => client.GetDiagnosticsUploadUrlAsync("tenant-1", "session-1", "package.zip"));
}
// ── 410: session gone, only when the backend said so ────────────────
[Fact]
public async Task Backend_410_on_registration_is_session_gone()
{
using var client = BuildClient(
HttpStatusCode.Gone,
"{\"success\":false,\"message\":\"Session is being deleted by an administrator (state=Tombstoned); registration is rejected.\"}",
"application/json");
var ex = await Assert.ThrowsAsync<BackendSessionGoneException>(
() => client.RegisterSessionAsync(new AutopilotMonitor.Shared.Models.SessionRegistration()));
Assert.Equal(410, ex.StatusCode);
}
[Fact]
public async Task A_410_without_the_backends_echo_stays_a_plain_http_error()
{
// Answered on the network path: the caller would end monitoring on a session-gone
// signal, so it must stay an ordinary, retried failure.
using var client = BuildClient(
HttpStatusCode.Gone,
"<html><head><title>Gone</title></head><body>Gone</body></html>",
"text/html",
fromBackend: false);
await Assert.ThrowsAsync<HttpRequestException>(
() => client.RegisterSessionAsync(new AutopilotMonitor.Shared.Models.SessionRegistration()));
}
[Theory]
[InlineData(null, null)]
[InlineData("", null)]
@@ -3,8 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
@@ -130,7 +129,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
try { pid = process.Id; } catch { }
try { session = process.SessionId; } catch { }
try { start = process.StartTime.ToUniversalTime(); } catch { }
try { path = QueryImagePath(pid); } catch { }
try { path = ProcessImagePath.Query(pid); } catch { }
if (path == null)
{
try { path = process.MainModule?.FileName; } catch { }
@@ -138,35 +137,5 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
return new ImeProcessCandidate(pid, session, path, start);
}
private const int PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr OpenProcess(int desiredAccess, bool inheritHandle, int processId);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr handle);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool QueryFullProcessImageName(IntPtr process, int flags, StringBuilder exeName, ref int size);
private static string QueryImagePath(int pid)
{
if (pid <= 0) return null;
var handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
if (handle == IntPtr.Zero) return null;
try
{
var sb = new StringBuilder(1024);
int size = sb.Capacity;
return QueryFullProcessImageName(handle, 0, sb, ref size) && size > 0 ? sb.ToString(0, size) : null;
}
finally
{
CloseHandle(handle);
}
}
}
}
@@ -80,5 +80,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
return email.StartsWith("foouser@", StringComparison.OrdinalIgnoreCase)
|| email.StartsWith("autopilot@", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// The domain part of a sign-in address, or "unknown" when there is none. Telemetry
/// carries this instead of the address: the part before the last '@' stays on the device.
/// </summary>
internal static string ExtractUserDomain(string email)
{
if (string.IsNullOrEmpty(email)) return "unknown";
var at = email.LastIndexOf('@');
return at >= 0 && at + 1 < email.Length ? email.Substring(at + 1) : "unknown";
}
}
}
@@ -0,0 +1,45 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// Full image path of another process through <c>QueryFullProcessImageName</c>. It needs only
/// <c>PROCESS_QUERY_LIMITED_INFORMATION</c> and resolves across bitness, where
/// <c>Process.MainModule</c> throws.
/// </summary>
internal static class ProcessImagePath
{
private const int PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr OpenProcess(int desiredAccess, bool inheritHandle, int processId);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr handle);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool QueryFullProcessImageName(IntPtr process, int flags, StringBuilder exeName, ref int size);
/// <summary>The path, or <c>null</c> when the process cannot be opened or queried.</summary>
public static string Query(int pid)
{
if (pid <= 0) return null;
var handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
if (handle == IntPtr.Zero) return null;
try
{
var sb = new StringBuilder(1024);
int size = sb.Capacity;
return QueryFullProcessImageName(handle, 0, sb, ref size) && size > 0 ? sb.ToString(0, size) : null;
}
finally
{
CloseHandle(handle);
}
}
}
}
@@ -1,3 +1,4 @@
using System;
using System.Management;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
@@ -121,5 +122,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
return null;
}
/// <summary>
/// Owner SID of a <c>Win32_Process</c> row via <c>GetOwnerSid</c>, or <c>null</c> on failure.
/// The row must have been selected with <c>Handle</c>: without its key property WMI cannot
/// invoke a method on the object and throws.
/// </summary>
public static string OwnerSid(ManagementObject process)
{
try
{
using (var result = process.InvokeMethod("GetOwnerSid", null, null))
{
if (result != null && Convert.ToUInt32(result["ReturnValue"]) == 0)
return result["Sid"]?.ToString();
}
}
catch
{
// Process gone or owner not readable.
}
return null;
}
}
}
@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
@@ -31,7 +32,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
/// This analyzer reports facts ONLY, always at <see cref="EventSeverity.Info"/>. It never reads
/// or emits the <c>DefaultPassword</c> value — only its presence. Whether an AutoLogon is a
/// problem is judged by backend analyze-rules (ANALYZE-SEC-003 = plaintext DefaultPassword on
/// disk → escalated; ANALYZE-SEC-004 = optional kiosk allow-list template).
/// disk → escalated).
/// </para>
/// <para>
/// <b>Trigger</b>: NOT at agent start — the Winlogon keys are written later by a provisioning
@@ -218,9 +219,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
{ "force_auto_logon", snap.ForceAutoLogon ?? string.Empty },
{ "force_auto_logon_enabled", forceAutoLogonEnabled },
{ "autologon_enabled", autologonEnabled },
{ "default_user_name", snap.DefaultUserName ?? string.Empty },
{ "default_user_name", ReduceUserName(snap.DefaultUserName) },
{ "default_domain_name", snap.DefaultDomainName ?? string.Empty },
{ "alt_default_user_name", snap.AltDefaultUserName ?? string.Empty },
{ "alt_default_user_name", ReduceUserName(snap.AltDefaultUserName) },
{ "alt_default_domain_name", snap.AltDefaultDomainName ?? string.Empty },
{ "auto_logon_sid", snap.AutoLogonSid ?? string.Empty },
{ "auto_logon_count", snap.AutoLogonCount.HasValue ? (object)snap.AutoLogonCount.Value : string.Empty },
@@ -244,6 +245,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
// Small fail-soft helpers.
// -----------------------------------------------------------------------
/// <summary>
/// A sign-in address is reported as its domain only. Local account names (kiosk,
/// defaultuser0) and the provisioning placeholders stay readable.
/// </summary>
internal static string ReduceUserName(string? userName)
{
if (userName is not string name || name.Length == 0) return string.Empty;
if (name.IndexOf('@') < 0 || AadJoinInfo.IsPlaceholderUserEmail(name)) return name;
return "[redacted]@" + AadJoinInfo.ExtractUserDomain(name);
}
/// <summary>AutoLogon flags are REG_SZ "0"/"1"; treat "1"/"true" (any case) as enabled.</summary>
private static bool IsTruthy(string? value)
{
@@ -640,53 +640,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
{
try
{
var data = new Dictionary<string, object>();
Dictionary<string, object> data;
// Read AAD join info via shared helper. Placeholder users (foouser@, autopilot@)
// are transient provisioning accounts and must NOT be treated as a real AAD join;
// HasAadJoinedUser is only set for real user emails.
if (AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser))
if (AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser)
&& !string.IsNullOrEmpty(thumbprint))
{
// Still need TenantId — open the subkey directly when we have a thumbprint.
if (!string.IsNullOrEmpty(thumbprint))
// Still need TenantId — open the subkey directly.
string tenantId;
using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath))
using (var subKey = joinInfoKey?.OpenSubKey(thumbprint))
{
using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath))
using (var subKey = joinInfoKey?.OpenSubKey(thumbprint))
{
data["tenantId"] = subKey?.GetValue("TenantId")?.ToString();
}
data["userEmail"] = userEmail;
data["joinType"] = "Azure AD Joined";
data["thumbprint"] = thumbprint;
if (!string.IsNullOrWhiteSpace(userEmail))
{
if (isPlaceholderUser)
{
IsFooUserDetected = true;
data["isFooUser"] = true;
_logger.Info($"AAD join: placeholder user detected ({userEmail}) — pre-provisioning indicator, NOT a real AAD join");
}
else
{
HasAadJoinedUser = true;
}
}
tenantId = subKey?.GetValue("TenantId")?.ToString();
}
else
data = BuildAadJoinedData(tenantId, userEmail, thumbprint, isPlaceholderUser);
if (!string.IsNullOrWhiteSpace(userEmail))
{
data["joinType"] = "Not Joined";
if (isPlaceholderUser)
{
IsFooUserDetected = true;
_logger.Info($"AAD join: placeholder user detected ({userEmail}) — pre-provisioning indicator, NOT a real AAD join");
}
else
{
HasAadJoinedUser = true;
}
}
}
else
{
data["joinType"] = "Not Joined";
data = new Dictionary<string, object> { ["joinType"] = "Not Joined" };
}
object joinTypeValue;
var joinType = data.TryGetValue("joinType", out joinTypeValue) ? joinTypeValue?.ToString() ?? "Unknown" : "Unknown";
EmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, $"AAD join: {joinType}", data);
EmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, $"AAD join: {data["joinType"]}", data);
}
catch (Exception ex)
{
@@ -694,6 +684,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
}
}
/// <summary>
/// The <c>aad_join_status</c> payload of a joined device. The signed-in address is
/// reported as its domain only.
/// </summary>
internal static Dictionary<string, object> BuildAadJoinedData(
string tenantId, string userEmail, string thumbprint, bool isPlaceholderUser)
{
var data = new Dictionary<string, object>
{
["tenantId"] = tenantId,
["joinType"] = "Azure AD Joined",
["thumbprint"] = thumbprint,
};
if (!string.IsNullOrWhiteSpace(userEmail))
{
data["userDomain"] = AadJoinInfo.ExtractUserDomain(userEmail);
if (isPlaceholderUser)
data["isFooUser"] = true;
}
return data;
}
/// <summary>
/// Reads ESP skip configuration from the enrollment FirstSync registry key via the
/// shared <see cref="EspSkipConfigurationProbe"/> (also used by
@@ -166,12 +166,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
{
var pidObj = e.NewEvent?["ProcessID"];
if (pidObj == null) return;
var pid = Convert.ToInt32(pidObj);
Process? proc = null;
try { proc = Process.GetProcessById(pid); }
catch { /* already gone — ignore */ }
if (proc != null) TrackWorker(proc);
TrackTracedProcess(Convert.ToInt32(pidObj), WorkerProcessNameNoExt);
}
catch (Exception ex)
{
@@ -179,6 +174,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
}
}
/// <summary>
/// Tracks the process a start trace announced (internal for tests). WMI delivers the trace
/// with a delay, so the id may already belong to another process: only a process that still
/// carries the worker's name is tracked. A foreign process would hold the install window
/// open until it exits.
/// </summary>
internal void TrackTracedProcess(int pid, string processNameNoExt)
{
Process? proc = null;
try { proc = Process.GetProcessById(pid); }
catch { /* already gone — ignore */ }
if (proc == null) return;
bool isWorker;
try { isWorker = string.Equals(proc.ProcessName, processNameNoExt, StringComparison.OrdinalIgnoreCase); }
catch { isWorker = false; /* exited between the lookup and the name read */ }
if (isWorker)
{
TrackWorker(proc);
return;
}
try { proc.Dispose(); } catch { }
}
private void TrackWorker(Process proc)
{
bool tracked = false;
@@ -1,10 +1,14 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Management;
using System.Security.Principal;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
{
@@ -26,10 +30,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
/// </list>
/// </para>
/// <para>
/// <b>Instant-close fallback.</b> Reading the command line still races if the process exits before
/// the probe. In that case (<c>SessionID ≠ 0</c> but command line unreadable) the console is still
/// surfaced, tagged <c>confidence:"low"</c> — better than missing it. A truly race-free capture of
/// the command line would require the ETW Kernel-Process provider (deferred).
/// <b>A process id is not an identity.</b> WMI delivers the start trace in batches about once a
/// second, so the probe runs up to a second after the start. By then a scripted <c>cmd /c</c> has
/// usually exited, and Windows may have given its id to another process. A start whose process is
/// gone, or whose id now belongs to a different process (other image name, session 0, other
/// parent), is not a console someone types into: it is counted in
/// <see cref="ConsoleScriptActivityTally"/> and reported as one summary, never as a console.
/// The comparison uses no clock on purpose — trace time and process start time need not follow
/// the same clock corrections during OOBE, and a wrong tolerance would count real consoles away.
/// Counting needs certainty: the process list no longer has the id, or a fact that was read
/// contradicts the trace. A query that fails, or a fact that cannot be read, proves nothing —
/// such a process is reported at low confidence, exactly as a running console nobody could
/// classify.
/// </para>
/// <para>
/// <b>Lifecycle.</b> The host stops this watcher once the real-user desktop has arrived (wired in
@@ -69,9 +81,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
}
private readonly AgentLogger _logger;
private readonly Func<int, ProcessProbe?> _processProbe;
private readonly Func<int, string, ProcessProbe?> _processProbe;
private readonly Func<int, bool> _isAlive;
private readonly Func<int, string?> _parentName;
private readonly Func<DateTime> _utcNow;
private readonly ConsoleScriptActivityTally _tally = new ConsoleScriptActivityTally();
private readonly object _lock = new object();
private readonly HashSet<int> _seen = new HashSet<int>();
private readonly HashSet<string> _seen = new HashSet<string>(StringComparer.Ordinal);
private ManagementEventWatcher? _startWatcher;
private bool _started;
@@ -79,11 +95,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
/// <summary>
/// Raised once per process when a watched console is classified as an interactive console
/// (high confidence) or an unclassifiable interactive-session console (low confidence). The
/// (high confidence) or as one that stays open or cannot be classified (low confidence). The
/// host translates this into a Warning <c>oobe_console_spawned</c> event.
/// </summary>
public event EventHandler<ConsoleSpawnInfo>? BypassConsoleDetected;
/// <summary>
/// Raised when a short-lived start is counted into an empty tally. The host opens its
/// reporting window on it and collects the content with <see cref="TakeScriptActivity"/>.
/// </summary>
internal event EventHandler? ScriptActivityStarted;
/// <summary>
/// Raised when the WMI <c>Win32_ProcessStartTrace</c> watcher fails to arm — the LIVE detector
/// is dead and only the one-shot startup probe ran. The host surfaces this as a
@@ -92,12 +114,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
/// </summary>
public event EventHandler<Exception>? WatcherArmFailed;
/// <param name="processProbe">Resolves the command line + owner for a live PID; returns null
/// when the process has already exited (the instant-close race). Defaults to a WMI query.</param>
public ConsoleBypassWatcher(AgentLogger logger, Func<int, ProcessProbe?>? processProbe = null)
/// <param name="processProbe">Reads a live process by id; the second argument is the image
/// name the trace announced. Returns null only when the process has certainly exited or
/// the id certainly runs another image, and <see cref="ProcessProbe.Unreadable"/> when it
/// could not be read. Defaults to a process-list check followed by one WMI row.</param>
/// <param name="isAlive">False only when a process id certainly no longer runs. Defaults to
/// the process list.</param>
/// <param name="parentName">Image file name of a process, or null when it cannot be read.</param>
/// <param name="utcNow">Clock for the timestamps of counted starts.</param>
public ConsoleBypassWatcher(
AgentLogger logger,
Func<int, string, ProcessProbe?>? processProbe = null,
Func<int, bool>? isAlive = null,
Func<int, string?>? parentName = null,
Func<DateTime>? utcNow = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_processProbe = processProbe ?? DefaultProbe;
_isAlive = isAlive ?? IsRunning;
_parentName = parentName ?? ImageFileName;
_utcNow = utcNow ?? (() => DateTime.UtcNow);
}
public void Start()
@@ -114,7 +150,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
// delayed every host after this one — so they run on a background thread. Exceptions
// are contained in StartCore; a disposal racing the background start is handled by
// the _disposed re-checks inside.
System.Threading.Tasks.Task.Run(() => StartCore());
Task.Run(() => StartCore());
}
private void StartCore()
@@ -147,7 +183,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
watcher.Start();
// Re-check after arming: Dispose() may have run between the lock release above and
// Start() — it then stopped a not-yet-armed watcher and this thread armed it right
// Start() — it then tore down a not-yet-armed watcher and this thread armed it right
// back, leaving a WMI subscription no code path could ever reach again. If disposal
// won that race, tear the watcher down here.
bool disposedDuringArm;
@@ -158,9 +194,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
}
if (disposedDuringArm)
{
try { watcher.EventArrived -= OnProcessStartTrace; } catch { }
try { watcher.Stop(); } catch { }
try { watcher.Dispose(); } catch { }
TearDownStartTrace(watcher);
return;
}
@@ -182,27 +216,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
}
}
// 2) Startup probe — catch a console already open before the watcher armed. Win32_Process
// exposes SessionId directly, so the same classification path is reused. Runs AFTER the
// arm so there is no gap; overlap with the live trace is deduped by _seen.
// 2) Startup probe — catch a console already open before the watcher armed. One query
// carries everything the classification needs; Handle is selected because the owner
// is a WMI method call on the row. Runs AFTER the arm so there is no gap; overlap
// with the live trace is deduped by _seen.
try
{
var nameFilter = string.Join(" OR ",
Array.ConvertAll(WatchedProcessNames, n => $"Name = '{n}'"));
using var searcher = new ManagementObjectSearcher(
$"SELECT Name, ProcessId, ParentProcessId, SessionId FROM Win32_Process WHERE {nameFilter}");
"SELECT Handle, Name, ProcessId, ParentProcessId, SessionId, CommandLine, CreationDate " +
$"FROM Win32_Process WHERE {nameFilter}");
using var results = searcher.Get();
foreach (var mo in results)
foreach (ManagementObject mo in results)
{
using (mo)
{
HandleStart(
ToInt(mo["ProcessId"]),
ToInt(mo["ParentProcessId"]),
ToInt(mo["SessionId"]),
ownerSidHint: null,
detectedVia: "startup_probe",
processName: mo["Name"]?.ToString() ?? WatchedProcessNames[0]);
var sessionId = ToInt(mo["SessionId"]);
var commandLine = mo["CommandLine"]?.ToString();
// The owner costs a method call: only for a row that will be reported.
if (Classify(sessionId, commandLine) == ConsoleVerdict.Ignore) continue;
var name = mo["Name"]?.ToString() ?? WatchedProcessNames[0];
var parentPid = ToInt(mo["ParentProcessId"]);
HandleProbed(
ToInt(mo["ProcessId"]), parentPid, sessionId, ProcessOwnerLookup.OwnerSid(mo), "startup_probe", name,
new ProcessProbe(name, sessionId, parentPid, commandLine, mo["CreationDate"]?.ToString()));
}
}
}
@@ -230,13 +269,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
}
/// <summary>
/// Classification core (internal for tests). Gates race-free on <paramref name="sessionId"/>,
/// then probes the live process for its command line; raises <see cref="BypassConsoleDetected"/>
/// once per PID for an interactive console (bare command line → high confidence) or an
/// unreadable interactive-session console (instant-close → low confidence). Scripted
/// invocations (<c>cmd /c</c>) and session-0 (service) cmd are ignored.
/// One traced start (internal for tests). Gates race-free on <paramref name="sessionId"/>,
/// then probes the process: a start that is gone or whose id belongs to another process is
/// counted, everything else goes through <see cref="HandleProbed"/>.
/// </summary>
internal void HandleStart(int pid, int parentPid, int sessionId, string? ownerSidHint, string detectedVia, string? processName = null)
internal void HandleStart(int pid, int parentPid, int sessionId, string? ownerSid, string detectedVia, string? processName = null)
{
if (pid <= 0) return;
lock (_lock) { if (_disposed) return; }
@@ -245,16 +282,46 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
// bulk of legitimate IME/Intune install cmd (SYSTEM, session 0) without even probing.
if (sessionId == 0) return;
var probe = _processProbe(pid); // null when the process already exited (instant-close)
var verdict = Classify(sessionId, probe?.CommandLine);
var name = processName ?? WatchedProcessNames[0];
var probe = _processProbe(pid, name);
if (probe == null || !IsTracedProcess(probe, name, parentPid))
{
CountShortLived(name, pid, parentPid, sessionId, ownerSid);
return;
}
HandleProbed(pid, parentPid, sessionId, ownerSid, detectedVia, name, probe);
}
/// <summary>
/// Classification of a process that was read while it ran (internal for tests). Raises
/// <see cref="BypassConsoleDetected"/> once per process for an interactive console (bare
/// command line → high confidence), one that stays open after its command (<c>/k</c> → low)
/// or a running one whose command line WMI does not return (low). A scripted <c>cmd /c</c>
/// is ignored.
/// </summary>
internal void HandleProbed(int pid, int parentPid, int sessionId, string? ownerSid, string detectedVia, string processName, ProcessProbe probe)
{
lock (_lock) { if (_disposed) return; }
// A row without a command line is only a console nobody could classify while the
// process still runs; one that ended in the meantime is a short-lived start.
if (probe.CommandLine == null && !_isAlive(pid))
{
CountShortLived(processName, pid, parentPid, sessionId, ownerSid);
return;
}
var verdict = Classify(sessionId, probe.CommandLine);
if (verdict == ConsoleVerdict.Ignore) return; // scripted invocation (cmd /c ...)
// Dedup only on a flagged hit, so the startup probe + live trace observing the same console
// collapse to one detection, and an ignored start never pre-empts a later real one.
// collapse to one detection, and an ignored start never pre-empts a later real one. The
// creation time is part of the key: a later console may be given an id seen before.
lock (_lock)
{
if (_disposed) return;
if (!_seen.Add(pid)) return;
if (!_seen.Add(pid + "|" + probe.CreationDate)) return;
}
bool high = verdict == ConsoleVerdict.InteractiveConsole;
@@ -266,15 +333,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
default: classification = "interactive_session_unclassified"; break;
}
var info = new ConsoleSpawnInfo(
processName: processName ?? WatchedProcessNames[0],
processName: processName,
processId: pid,
parentProcessId: parentPid,
sessionId: sessionId,
owner: probe?.Owner ?? ownerSidHint,
commandLine: probe?.CommandLine,
owner: ownerSid,
commandLine: probe.CommandLine,
confidence: high ? "high" : "low",
classification: classification,
detectedVia: detectedVia);
detectedVia: detectedVia,
parentProcessName: SafeParentName(parentPid));
_logger.Warning($"[ConsoleBypassWatcher] interactive console detected: {info.ProcessName} (PID={pid}) " +
$"session={sessionId}, confidence={info.Confidence}, via={detectedVia} — possible Shift+F10");
@@ -283,14 +351,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
catch (Exception ex) { _logger.Warning($"[ConsoleBypassWatcher] handler threw: {ex.Message}"); }
}
/// <summary>
/// The counted short-lived starts since the last call, or null when there were none. The
/// host calls it when its window ends and when it stops.
/// </summary>
internal ConsoleScriptActivity? TakeScriptActivity() => _tally.Take();
private void CountShortLived(string processName, int pid, int parentPid, int sessionId, string? ownerSid)
{
var opened = _tally.Add(processName, pid, parentPid, sessionId, ownerSid, _utcNow(), SafeParentName);
_logger.Trace($"[ConsoleBypassWatcher] short-lived {processName} counted (PID={pid}, parent={parentPid}, session={sessionId})");
if (!opened) return;
try { ScriptActivityStarted?.Invoke(this, EventArgs.Empty); }
catch (Exception ex) { _logger.Debug($"[ConsoleBypassWatcher] script-activity handler threw: {ex.Message}"); }
}
private string? SafeParentName(int parentPid)
{
if (parentPid <= 0) return null;
try { return _parentName(parentPid); }
catch (Exception) { return null; }
}
internal enum ConsoleVerdict { Ignore, InteractiveConsole, UnclassifiedInteractive, InteractiveWithCommand }
/// <summary>
/// Pure classification (internal for tests): session-0 → Ignore; a scripted <c>cmd /c</c> →
/// Ignore; <c>cmd /k</c> (runs its command, then STAYS interactive — L12) →
/// InteractiveWithCommand (low); a bare command line → InteractiveConsole (high); an
/// unreadable command line in an interactive session → UnclassifiedInteractive (low, the
/// instant-close fallback).
/// InteractiveWithCommand (low); a bare command line → InteractiveConsole (high); a running
/// process without a readable command line in an interactive session →
/// UnclassifiedInteractive (low).
/// </summary>
internal static ConsoleVerdict Classify(int sessionId, string? commandLine)
{
@@ -304,15 +395,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
}
}
/// <summary>True when the FIRST <c>/c</c>-or-<c>/k</c> switch on the command line is <c>/c</c>
/// (a scripted run-and-exit invocation, mirroring cmd.exe precedence), false for a bare
/// interactive shell or a <c>/k</c> run-and-stay shell — even one whose payload contains a
/// later <c>/c</c>. The executable token is stripped first so a path can never spoof a
/// switch, and switches may be combined (e.g. <c>cmd /d/q/c exit 9</c>).</summary>
internal static bool HasScriptArgument(string commandLine)
/// <summary>
/// Whether a probed process is still the one the trace announced (internal for tests). Only
/// a fact that contradicts the trace makes it another process — another image name, the
/// service session, another parent. A fact that could not be read contradicts nothing: the
/// process is then reported, never counted away.
/// </summary>
internal static bool IsTracedProcess(ProcessProbe probe, string processName, int parentPid)
{
if (string.IsNullOrEmpty(commandLine)) return false;
return FirstRunSwitch(StripExecutable(commandLine)) == RunSwitch.RunAndExit;
if (probe.Name != null && !string.Equals(probe.Name, processName, StringComparison.OrdinalIgnoreCase)) return false;
if (probe.SessionId == 0) return false;
return parentPid <= 0 || probe.ParentProcessId == null || probe.ParentProcessId <= 0 || probe.ParentProcessId == parentPid;
}
/// <summary>Returns the command line with the leading executable token (quoted or
@@ -330,44 +423,100 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
return space >= 0 ? s.Substring(space + 1) : string.Empty;
}
private static ProcessProbe? DefaultProbe(int pid)
// The process list first: a WMI query takes a few hundred milliseconds, and in a burst
// almost every traced process has already exited. Queried one after the other on the
// delivery thread they would pile up, and a real console closed during the backlog would
// be counted as a script.
private static ProcessProbe? DefaultProbe(int pid, string processName) =>
Probe(pid, ProcessListState(pid, processName), ReadRow);
/// <summary>
/// The probe's decision (internal for tests). Null only when the process list says so for
/// certain: the id no longer runs, or it runs another image. Everything else comes back as
/// a probe — with the row's facts, or <see cref="ProcessProbe.Unreadable"/> when the query
/// failed or returned nothing. A failed query says nothing about the process, so it must
/// never read as "exited".
/// </summary>
internal static ProcessProbe? Probe(int pid, ListedAs listed, Func<int, ProcessProbe?> readRow)
{
if (listed == ListedAs.NotRunning || listed == ListedAs.OtherImage) return null;
try
{
using var searcher = new ManagementObjectSearcher(
$"SELECT CommandLine FROM Win32_Process WHERE ProcessId = {pid}");
using var results = searcher.Get();
foreach (ManagementObject mo in results)
{
using (mo)
{
var commandLine = mo["CommandLine"]?.ToString();
return new ProcessProbe(commandLine, TryGetOwner(mo));
}
}
return readRow(pid) ?? ProcessProbe.Unreadable;
}
catch (Exception)
{
// Process gone or query failed — treated as the instant-close fallback (low confidence).
return ProcessProbe.Unreadable; // WinMgmt can fail or hang during OOBE
}
}
private static ProcessProbe? ReadRow(int pid)
{
using var searcher = new ManagementObjectSearcher(
$"SELECT Name, SessionId, ParentProcessId, CommandLine, CreationDate FROM Win32_Process WHERE ProcessId = {pid}");
using var results = searcher.Get();
foreach (ManagementObject mo in results)
{
using (mo)
{
return new ProcessProbe(
mo["Name"]?.ToString(),
ToNullableInt(mo["SessionId"]),
ToNullableInt(mo["ParentProcessId"]),
mo["CommandLine"]?.ToString(),
mo["CreationDate"]?.ToString());
}
}
return null;
}
private static string? TryGetOwner(ManagementObject process)
/// <summary>What the process list says about an id and the image name expected under it.</summary>
internal enum ListedAs { NotRunning, OtherImage, Expected, Unknown }
/// <summary>Internal for tests.</summary>
internal static ListedAs ProcessListState(int pid, string processName)
{
try
{
var outParams = process.InvokeMethod("GetOwner", null, null);
if (outParams != null && Convert.ToInt32(outParams["ReturnValue"]) == 0)
{
var domain = outParams["Domain"]?.ToString();
var user = outParams["User"]?.ToString();
if (!string.IsNullOrEmpty(user))
return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}";
}
using var process = Process.GetProcessById(pid);
return string.Equals(process.ProcessName, Path.GetFileNameWithoutExtension(processName), StringComparison.OrdinalIgnoreCase)
? ListedAs.Expected
: ListedAs.OtherImage;
}
catch { /* best-effort enrichment */ }
return null;
catch (ArgumentException)
{
return ListedAs.NotRunning; // what GetProcessById throws for an id that does not run
}
catch (Exception)
{
return ListedAs.Unknown;
}
}
/// <summary>False only when the id certainly does not run; when that cannot be told, true.
/// Internal for tests.</summary>
internal static bool IsRunning(int pid)
{
try
{
using var process = Process.GetProcessById(pid);
return !process.HasExited;
}
catch (ArgumentException)
{
return false;
}
catch (Exception)
{
return true;
}
}
private static string? ImageFileName(int pid)
{
var path = ProcessImagePath.Query(pid);
return string.IsNullOrEmpty(path) ? null : Path.GetFileName(path);
}
private static string? TryReadSid(object? sidValue)
@@ -388,42 +537,77 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
catch { return 0; }
}
private static int? ToNullableInt(object? value)
{
if (value == null) return null;
try { return Convert.ToInt32(value); }
catch { return null; }
}
private void TearDownStartTrace(ManagementEventWatcher watcher)
{
try { watcher.EventArrived -= OnProcessStartTrace; } catch { }
try { watcher.Stop(); } catch { }
try { watcher.Dispose(); } catch { }
}
public void Dispose()
{
ManagementEventWatcher? startWatcher;
lock (_lock)
{
if (_disposed) return;
_disposed = true;
startWatcher = _startWatcher;
_startWatcher = null;
}
if (_startWatcher != null)
if (startWatcher != null)
{
try { _startWatcher.EventArrived -= OnProcessStartTrace; } catch { }
try { _startWatcher.Stop(); } catch { }
try { _startWatcher.Dispose(); } catch { }
_startWatcher = null;
// Cancelling a WMI subscription can take tens of seconds on a busy WinMgmt. The caller
// is the agent's stop path, so it never waits for it.
var armed = startWatcher;
Task.Run(() => TearDownStartTrace(armed));
}
}
}
/// <summary>Command line + owner for a probed PID; either may be null (best-effort).</summary>
/// <summary>
/// What was read about a running process: the facts that tell whether it is still the process
/// a start trace announced, and its command line. Every field is null when it could not be
/// read; null never means "no" or "zero".
/// </summary>
public sealed class ProcessProbe
{
public ProcessProbe(string? commandLine, string? owner)
/// <summary>
/// A process that runs but about which nothing could be read (the query failed or returned
/// no row). It goes on as the traced process without a command line, so it is reported.
/// </summary>
internal static readonly ProcessProbe Unreadable = new ProcessProbe(null, null, null, null, null);
public ProcessProbe(string? name, int? sessionId, int? parentProcessId, string? commandLine, string? creationDate)
{
Name = name;
SessionId = sessionId;
ParentProcessId = parentProcessId;
CommandLine = commandLine;
Owner = owner;
CreationDate = creationDate;
}
public string? Name { get; }
public int? SessionId { get; }
public int? ParentProcessId { get; }
public string? CommandLine { get; }
public string? Owner { get; }
/// <summary>The raw WMI creation time; only compared for equality.</summary>
public string? CreationDate { get; }
}
/// <summary>Immutable detail record for a detected interactive console spawn.</summary>
public sealed class ConsoleSpawnInfo
{
public ConsoleSpawnInfo(string processName, int processId, int parentProcessId, int sessionId,
string? owner, string? commandLine, string confidence, string classification, string detectedVia)
string? owner, string? commandLine, string confidence, string classification, string detectedVia,
string? parentProcessName)
{
ProcessName = processName;
ProcessId = processId;
@@ -434,17 +618,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
Confidence = confidence;
Classification = classification;
DetectedVia = detectedVia;
ParentProcessName = parentProcessName;
}
public string ProcessName { get; }
public int ProcessId { get; }
public int ParentProcessId { get; }
public int SessionId { get; }
/// <summary>Owner SID, or null when it could not be read.</summary>
public string? Owner { get; }
public string? CommandLine { get; }
/// <summary>"high" (bare interactive shell) or "low" (interactive session, command line unreadable).</summary>
/// <summary>"high" (bare interactive shell) or "low" (stays open after a command, or not classifiable).</summary>
public string Confidence { get; }
public string Classification { get; }
public string DetectedVia { get; }
/// <summary>
/// Image name of the parent while it still ran, else null. The launcher behind Shift+F10
/// exits within milliseconds, so null is the expected value for that case.
/// </summary>
public string? ParentProcessName { get; }
}
}
@@ -0,0 +1,182 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
{
/// <summary>
/// Counts console processes that were gone before they could be inspected — script children,
/// reported as one summary per window instead of one event each. Thread-safe. The content
/// leaves through <see cref="Take"/>, which hands out copies and leaves an empty tally behind,
/// so a start is never counted in two summaries.
/// </summary>
internal sealed class ConsoleScriptActivityTally
{
internal const int MaxOwnersListed = 5;
internal const int MaxParentsListed = 10;
internal const int MaxParentLookups = 10;
// A spawner can keep running for hours after the host has stopped opening windows. The
// count grows with it; what is remembered per start does not.
private const int MaxTrackedParents = 256;
private const int MaxTrackedProcessIds = 2048;
private const int MaxTrackedOwners = 64;
private sealed class Parent
{
public string? Name;
public int Children;
}
private readonly object _lock = new object();
private readonly SortedSet<int> _sessions = new SortedSet<int>();
private readonly List<string> _owners = new List<string>();
private readonly Dictionary<int, Parent> _parents = new Dictionary<int, Parent>();
private readonly HashSet<int> _processIds = new HashSet<int>();
private string _processName = string.Empty;
private int _count;
private int _parentLookups;
private DateTime _firstSeenUtc;
private DateTime _lastSeenUtc;
/// <summary>
/// Counts one start. <paramref name="parentName"/> is asked at most once per parent and at
/// most <see cref="MaxParentLookups"/> times per window, outside the lock; a parent that is
/// itself one of the counted processes is named without asking.
/// </summary>
/// <returns><c>true</c> when this start opened an empty tally.</returns>
public bool Add(
string processName, int processId, int parentProcessId, int sessionId, string? ownerSid,
DateTime seenUtc, Func<int, string?> parentName)
{
bool opened;
Parent? toResolve = null;
lock (_lock)
{
opened = _count == 0;
if (opened)
{
_processName = processName;
_firstSeenUtc = seenUtc;
}
_count++;
_lastSeenUtc = seenUtc;
_sessions.Add(sessionId);
if (!string.IsNullOrEmpty(ownerSid) && _owners.Count < MaxTrackedOwners && !_owners.Contains(ownerSid!))
_owners.Add(ownerSid!);
if (parentProcessId > 0)
{
if (_parents.TryGetValue(parentProcessId, out var parent))
{
parent.Children++;
}
else if (_parents.Count < MaxTrackedParents)
{
parent = new Parent { Children = 1 };
_parents[parentProcessId] = parent;
if (_processIds.Contains(parentProcessId))
{
parent.Name = processName;
}
else if (_parentLookups < MaxParentLookups)
{
_parentLookups++;
toResolve = parent;
}
}
}
if (_processIds.Count < MaxTrackedProcessIds) _processIds.Add(processId);
}
if (toResolve != null)
{
var name = parentName(parentProcessId);
lock (_lock) { toResolve.Name = name; }
}
return opened;
}
/// <summary>The counted content, or <c>null</c> when nothing was counted. Empties the tally.</summary>
public ConsoleScriptActivity? Take()
{
lock (_lock)
{
if (_count == 0) return null;
var parents = _parents
.Select(kv => new ConsoleScriptParent(kv.Key, kv.Value.Name, kv.Value.Children))
.OrderByDescending(p => p.Children)
.ThenBy(p => p.ProcessId)
.ToList();
var activity = new ConsoleScriptActivity(
_processName, _count, _firstSeenUtc, _lastSeenUtc,
_sessions.ToList(),
_owners.Take(MaxOwnersListed).ToList(), _owners.Count,
parents.Take(MaxParentsListed).ToList(), parents.Count);
_count = 0;
_parentLookups = 0;
_processName = string.Empty;
_sessions.Clear();
_owners.Clear();
_parents.Clear();
_processIds.Clear();
return activity;
}
}
}
/// <summary>One window of counted console processes. Lists are capped; the totals are not.</summary>
internal sealed class ConsoleScriptActivity
{
public ConsoleScriptActivity(
string processName, int count, DateTime firstSeenUtc, DateTime lastSeenUtc,
IReadOnlyList<int> windowsSessions,
IReadOnlyList<string> owners, int ownersTotal,
IReadOnlyList<ConsoleScriptParent> parents, int parentsTotal)
{
ProcessName = processName;
Count = count;
FirstSeenUtc = firstSeenUtc;
LastSeenUtc = lastSeenUtc;
WindowsSessions = windowsSessions;
Owners = owners;
OwnersTotal = ownersTotal;
Parents = parents;
ParentsTotal = parentsTotal;
}
public string ProcessName { get; }
public int Count { get; }
public DateTime FirstSeenUtc { get; }
public DateTime LastSeenUtc { get; }
public IReadOnlyList<int> WindowsSessions { get; }
public IReadOnlyList<string> Owners { get; }
public int OwnersTotal { get; }
/// <summary>Parents by number of children, most first.</summary>
public IReadOnlyList<ConsoleScriptParent> Parents { get; }
public int ParentsTotal { get; }
}
/// <summary>A parent process of counted console processes. Only its image name is kept, never its command line.</summary>
internal sealed class ConsoleScriptParent
{
public ConsoleScriptParent(int processId, string? name, int children)
{
ProcessId = processId;
Name = name;
Children = children;
}
public int ProcessId { get; }
/// <summary><c>null</c> when the parent had exited or was not looked up.</summary>
public string? Name { get; }
public int Children { get; }
}
}
@@ -260,6 +260,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
await ThrowOnAuthFailureAsync(response, correlationId).ConfigureAwait(false);
await ThrowOnCodedConflictAsync(response).ConfigureAwait(false);
ThrowOnSessionGone(response, correlationId);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
@@ -412,6 +413,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
errorCode);
}
/// <summary>
/// Throws <see cref="BackendSessionGoneException"/> for a 410 the backend answered: the
/// session was deleted or is being deleted. Only a response that echoes this request's
/// <c>X-Correlation-ID</c> counts — the caller ends monitoring on it, so a 410 from the
/// network path stays an ordinary failure (<c>EnsureSuccessStatusCode</c>) and is retried.
/// </summary>
private static void ThrowOnSessionGone(HttpResponseMessage response, string correlationId)
{
if (response.StatusCode != HttpStatusCode.Gone)
return;
if (!ResponseOriginClassifier.EchoesCorrelationId(response, correlationId))
return;
throw new BackendSessionGoneException(
"Backend returned 410 Gone: the session was deleted or is being deleted.",
(int)response.StatusCode);
}
/// <summary>
/// Pulls <c>errorCode</c> out of a backend JSON error body. Tolerant: any parse failure
/// or a non-string value yields null, never an exception.
@@ -537,4 +556,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
ErrorCode = errorCode;
}
}
/// <summary>
/// Thrown for a 410 Gone the backend answered: the session named in the request was deleted or
/// is being deleted. No retry can clear it and it says nothing about the device's authorization.
/// </summary>
public class BackendSessionGoneException : Exception
{
/// <summary>HTTP status code returned by the backend (410).</summary>
public int StatusCode { get; }
public BackendSessionGoneException(string message, int statusCode) : base(message)
{
StatusCode = statusCode;
}
}
}
@@ -15,7 +15,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
/// - Same error key (ErrorType + HTTP status code) is only ever sent once per session
/// - At most <see cref="MaxReportsPerSession"/> emergency reports total per session
/// - At most one report every <see cref="MinIntervalMinutes"/> minutes regardless of error type
/// (the terminal <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/> report is exempt)
/// (the terminal session-gone reports <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/>
/// and <see cref="AgentErrorType.RegistrationRejectedSessionGone"/> are exempt)
///
/// A failure in this reporter is silently swallowed — it must never cascade into the main loop.
/// </summary>
@@ -123,11 +124,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
return;
}
// The session-unknown report is terminal and sent at most once per process (the
// A session-gone report is terminal and sent at most once per process (the
// termination follows immediately): a routine report minutes earlier must not cost
// the last word about the session. Only the cooldown is waived; key dedup and the
// per-session maximum still apply.
var exemptFromCooldown = errorType == AgentErrorType.TelemetryRejectedSessionUnknown;
var exemptFromCooldown = errorType == AgentErrorType.TelemetryRejectedSessionUnknown
|| errorType == AgentErrorType.RegistrationRejectedSessionGone;
if (!exemptFromCooldown && _lastReportTime != null &&
(now - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes)
{
@@ -1,6 +1,7 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
@@ -21,6 +22,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// only sees consoles spawned after the agent started; the pre-agent OOBE window is covered
/// (coarsely) by the ConsolePrefetchScanner. The composition root calls <see cref="Stop"/> once the
/// real-user desktop arrives (Shift+F10 no longer possible) to avoid post-enrollment false positives.
/// <para>
/// Short-lived console processes (script children) are not events of their own: the watcher
/// counts them and this host reports one Info <c>console_script_activity</c> per window. The
/// first counted start opens a window of <see cref="ScriptActivityWindow"/>; at most
/// <see cref="MaxScriptActivitySummaries"/> windows are reported per agent run, whatever comes
/// after goes into the one line written when the host stops.
/// </para>
/// </summary>
internal sealed class ConsoleBypassHost : ICollectorHost
{
@@ -28,6 +36,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
"best-effort: only consoles spawned after the agent started are observable; a Shift+F10 " +
"pressed earlier in OOBE (before agent install) is covered coarsely by console_prefetch_detected";
internal const int MaxScriptActivitySummaries = 20;
private static readonly TimeSpan ScriptActivityWindow = TimeSpan.FromSeconds(60);
public string Name => "ConsoleBypassWatcher";
private readonly string _sessionId;
@@ -36,7 +47,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly AgentLogger _logger;
private readonly ConsoleBypassWatcher _watcher;
private readonly SignalIngress? _observableIngress;
private readonly object _summaryLock = new object();
private Action<AutopilotMonitor.DecisionCore.Signals.DecisionSignalKind, IReadOnlyDictionary<string, string>?>? _signalPostedHandler;
private Timer? _windowTimer;
private int _summariesEmitted;
private int _disposed;
public ConsoleBypassHost(
@@ -55,11 +69,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_post = new InformationalEventPost(ingress, clock, logger);
_watcher = watcher ?? new ConsoleBypassWatcher(logger);
_watcher.BypassConsoleDetected += OnBypassConsoleDetected;
_watcher.ScriptActivityStarted += OnScriptActivityStarted;
_watcher.WatcherArmFailed += OnWatcherArmFailed;
_observableIngress = ingress as SignalIngress;
}
public void Start()
public void Start() => StartCore(armWatcher: true);
/// <summary>Internal for tests: <paramref name="armWatcher"/> = false leaves the WMI
/// subscription and the startup probe out; everything else is the real start.</summary>
internal void StartCore(bool armWatcher)
{
// L14 fallback: the primary stop path is the DesktopArrivalDetector's real-user-owner
// callback (StopForDesktopArrival). If the DAD misses the owner (no-candidate timeout,
@@ -73,7 +92,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_observableIngress.SignalPosted += _signalPostedHandler;
}
_watcher.Start();
if (armWatcher) _watcher.Start();
}
private void OnSignalPosted(AutopilotMonitor.DecisionCore.Signals.DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
@@ -117,6 +136,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
Phase = EnrollmentPhase.Unknown,
Message = $"Interactive console spawned during enrollment: {info.ProcessName} " +
$"(session {info.SessionId}, {info.Confidence} confidence) — possible Shift+F10",
// At once, whatever the confidence: the confidence follows the command line,
// and that is chosen by whoever opens the console.
ImmediateUpload = true,
Data = new Dictionary<string, object>
{
@@ -124,6 +145,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{ "processName", info.ProcessName },
{ "processId", info.ProcessId },
{ "parentProcessId", info.ParentProcessId },
{ "parentProcessName", info.ParentProcessName ?? "unknown" },
{ "sessionId", info.SessionId },
{ "owner", info.Owner ?? "unknown" },
{ "commandLine", info.CommandLine ?? "unavailable" },
@@ -141,6 +163,132 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
}
private void OnScriptActivityStarted(object? sender, EventArgs e)
{
lock (_summaryLock)
{
// Checked under the lock Dispose takes for the timer, so no timer is created behind it.
if (Interlocked.CompareExchange(ref _disposed, 0, 0) == 1) return;
// Past the cap no window opens any more; the watcher keeps counting for the stop line.
if (_summariesEmitted >= MaxScriptActivitySummaries) return;
try
{
if (_windowTimer == null)
_windowTimer = new Timer(_ => WindowElapsed(), null, ScriptActivityWindow, Timeout.InfiniteTimeSpan);
else
_windowTimer.Change(ScriptActivityWindow, Timeout.InfiniteTimeSpan);
}
catch (ObjectDisposedException)
{
// stopping
}
}
}
/// <summary>Internal for tests: what the window timer runs when it elapses.</summary>
internal void WindowElapsedForTest() => WindowElapsed();
private void WindowElapsed()
{
if (Interlocked.CompareExchange(ref _disposed, 0, 0) == 1) return;
ReportScriptActivity(stopping: false);
}
private void ReportScriptActivity(bool stopping)
{
ConsoleScriptActivity? activity;
bool capped;
lock (_summaryLock)
{
capped = _summariesEmitted >= MaxScriptActivitySummaries;
if (capped && !stopping) return;
activity = _watcher.TakeScriptActivity();
if (activity == null) return;
_summariesEmitted++;
}
// Outside the lock: a post blocks while the ingress is full.
try
{
var data = new Dictionary<string, object>
{
{ "count", activity.Count },
{ "processName", activity.ProcessName },
{ "firstSeenUtc", activity.FirstSeenUtc.ToString("o") },
{ "lastSeenUtc", activity.LastSeenUtc.ToString("o") },
{ "windowsSessions", activity.WindowsSessions.ToList() },
{ "owners", activity.Owners.ToList() },
{ "ownersTotal", activity.OwnersTotal },
{
"parents", activity.Parents.Select(p => new Dictionary<string, object>
{
{ "pid", p.ProcessId },
{ "name", p.Name ?? "unknown" },
{ "children", p.Children },
}).ToList()
},
{ "parentsTotal", activity.ParentsTotal },
};
if (capped) data["capped"] = true;
_post.Emit(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = Constants.EventTypes.ConsoleScriptActivity,
Severity = EventSeverity.Info,
Source = Name,
Phase = EnrollmentPhase.Unknown,
Timestamp = activity.LastSeenUtc,
Message = DescribeScriptActivity(activity),
ImmediateUpload = false,
Data = data,
});
_logger.Debug($"[ConsoleBypassHost] script activity reported: {activity.Count} short-lived {activity.ProcessName} " +
$"from {activity.ParentsTotal} parent(s){(capped ? " (summary cap reached)" : string.Empty)}");
}
catch (Exception ex)
{
_logger.Warning($"[ConsoleBypassHost] failed to emit console_script_activity: {ex.Message}");
}
}
/// <summary>The timeline text of one summary (internal for tests).</summary>
internal static string DescribeScriptActivity(ConsoleScriptActivity activity)
{
var processes = activity.Count == 1
? $"1 short-lived {activity.ProcessName} process"
: $"{activity.Count} short-lived {activity.ProcessName} processes";
var sessions = activity.WindowsSessions.Count == 1
? $"session {activity.WindowsSessions[0]}"
: $"sessions {string.Join(", ", activity.WindowsSessions)}";
string startedBy;
if (activity.ParentsTotal == 1 && activity.Parents.Count == 1)
{
startedBy = activity.Parents[0].Name ?? "an unknown program";
}
else
{
var names = activity.Parents
.Select(p => p.Name)
.Where(n => !string.IsNullOrEmpty(n))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var shown = names.Take(3).ToList();
var more = names.Count > shown.Count || activity.Parents.Any(p => p.Name == null) || activity.ParentsTotal > activity.Parents.Count;
startedBy = $"{activity.ParentsTotal} parent processes";
if (shown.Count > 0)
startedBy += $" ({string.Join(", ", shown)}{(more ? ", …" : string.Empty)})";
}
return $"Script activity: {processes} in {sessions}, started by {startedBy} — counted, not an interactive console";
}
// The WMI live watcher could not arm — surface it as collector_degraded so the backend can
// tell "no console detected" from "live detector never started" (MON-D1 pattern).
private void OnWatcherArmFailed(object? sender, Exception ex)
@@ -151,12 +299,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
// What was counted goes out first. It has to happen here, in the host's stop: the
// handlers below go away, and once the orchestrator has stopped the ingress a post throws.
ReportScriptActivity(stopping: true);
if (_observableIngress != null && _signalPostedHandler != null)
{
try { _observableIngress.SignalPosted -= _signalPostedHandler; } catch { }
}
try { _watcher.BypassConsoleDetected -= OnBypassConsoleDetected; } catch { }
try { _watcher.ScriptActivityStarted -= OnScriptActivityStarted; } catch { }
try { _watcher.WatcherArmFailed -= OnWatcherArmFailed; } catch { }
lock (_summaryLock)
{
try { _windowTimer?.Dispose(); } catch { }
_windowTimer = null;
}
try { _watcher.Dispose(); } catch { }
}
@@ -101,6 +101,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// The OOBE update registry snapshot (D-310) is observation at agent start/stop and at
// the update page — the page's own records (oobe_update_page) stay activity.
SharedConstants.EventTypes.OobeUpdateState,
// The count of short-lived console processes: a script that keeps spawning them for
// hours would otherwise hold the periodic collectors awake and reset the stall clock.
// The work those scripts do arrives as its own app, script or phase event.
SharedConstants.EventTypes.ConsoleScriptActivity,
};
/// <summary>
@@ -46,6 +46,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// authorized agent.</item>
/// <item>Any other coded 409 (<see cref="BackendRefusalException"/>) → <see cref="SessionRegistrationOutcome.Failed"/>
/// without retrying — a refusal with a code does not clear by waiting.</item>
/// <item>410 from the backend (<see cref="BackendSessionGoneException"/>): the persisted
/// session was deleted. With <c>rotateOnSessionGone</c> (a WhiteGlove Part 2 start, whose
/// enrollment is only now continuing) the session is rotated like above and registered
/// once more. Every other start returns <see cref="SessionRegistrationOutcome.SessionGone"/>
/// without retrying — the caller ends monitoring. So does a second 410 or a failed rotation:
/// the 48-hour break skips a WhiteGlove resume, so no path here may leave it retrying.</item>
/// <item>Any other exception on the last attempt → <see cref="EmergencyReporter.TrySendAsync"/>
/// with <c>AgentErrorType.RegisterSessionFailed</c> so operators see the final cause.</item>
/// </list>
@@ -75,6 +81,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// Optional pre-registration link wait used by tests. Production callers pass
/// <c>null</c> → <see cref="WaitForNetworkLinkAsync"/> (bounded by <see cref="NetworkLinkWaitMax"/>).
/// </param>
/// <param name="rotateOnSessionGone">
/// True at a WhiteGlove Part 2 start: a 410 for the persisted session then becomes a new
/// session through <paramref name="rotateSession"/> instead of ending monitoring.
/// </param>
public static async Task<SessionRegistrationResult> RegisterWithRetryAsync(
BackendApiClient apiClient,
AgentConfiguration agentConfig,
@@ -86,7 +96,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
Func<Exception, Task> onTerminalTransportFailure = null,
(string Manufacturer, string Model, string SerialNumber)? deviceHardware = null,
Func<AgentLogger, Task> networkLinkWait = null,
Func<string> rotateSession = null)
Func<string> rotateSession = null,
bool rotateOnSessionGone = false)
{
if (apiClient == null) throw new ArgumentNullException(nameof(apiClient));
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
@@ -101,6 +112,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
string lastError = null;
Exception lastException = null;
string rotatedFromSessionId = null;
string rotationReason = null;
for (int attempt = 1; attempt <= MaxAttempts; attempt++)
{
@@ -112,7 +124,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
if (response != null && response.Success)
{
logger.Info($"Session registered successfully: {response.SessionId} (validatedBy={response.ValidatedBy}, adminAction={response.AdminAction ?? "(none)"})");
return SessionRegistrationResult.Succeeded(response, rotatedFromSessionId);
return SessionRegistrationResult.Succeeded(response, rotatedFromSessionId, rotationReason);
}
lastError = response?.Message ?? "(null response)";
@@ -149,9 +161,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
$"Backend refused registration with {Constants.AgentErrorCodes.SessionOwnerMismatch}: session {rotatedFromSessionId} " +
$"is not this device's (bound to another device identity after a re-enrollment without wipe, or claimed by another tenant). " +
$"Rotated to {newSessionId}; re-registering.");
rotationReason = SessionRotationReasons.OwnerMismatch;
registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
continue;
}
catch (BackendSessionGoneException ex)
{
if (rotateOnSessionGone && rotateSession != null && rotatedFromSessionId == null)
{
var goneSessionId = registration.SessionId;
string newSessionId = null;
try
{
newSessionId = rotateSession();
}
catch (Exception rotateEx)
{
logger.Error($"Backend answered 410 for session {goneSessionId} but session rotation failed: {rotateEx.Message}", rotateEx);
}
if (newSessionId != null)
{
rotatedFromSessionId = goneSessionId;
rotationReason = SessionRotationReasons.SessionGone;
logger.Warning(
$"Backend answered 410 for session {goneSessionId} at the start of a WhiteGlove Part 2: " +
$"the session was deleted. Rotated to {newSessionId}; re-registering.");
registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
continue;
}
}
logger.Error($"Session registration refused with 410 Gone — the backend deleted session {registration.SessionId}.");
return SessionRegistrationResult.SessionGone(ex.StatusCode, ex.Message);
}
catch (BackendRefusalException ex)
{
logger.Error($"Session registration refused ({ex.StatusCode}, {ex.ErrorCode}): {ex.Message}");
@@ -294,6 +337,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// <summary>All attempts failed with non-auth errors (network, 5xx, malformed response).</summary>
Failed = 2,
/// <summary>
/// 410 from the backend: the persisted session was deleted. No retry was attempted; the
/// caller ends monitoring and cleans up.
/// </summary>
SessionGone = 3,
}
/// <summary>Why a run registered under a new SessionId (<see cref="SessionRegistrationResult.RotationReason"/>).</summary>
public static class SessionRotationReasons
{
/// <summary>The backend refused the persisted id with <c>session_owner_mismatch</c>.</summary>
public const string OwnerMismatch = "owner_mismatch";
/// <summary>The backend answered 410 for the persisted id at a WhiteGlove Part 2 start.</summary>
public const string SessionGone = "session_gone";
}
/// <summary>Result of <see cref="SessionRegistrationHelper.RegisterWithRetryAsync"/>.</summary>
@@ -308,12 +367,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
public ValidatorType ValidatedBy => Response?.ValidatedBy ?? ValidatorType.Unknown;
/// <summary>
/// SESSION-OWNER-BINDING: the SessionId this run started with when the backend refused it
/// with <c>session_owner_mismatch</c> and the session was rotated before the successful
/// The SessionId this run started with when the backend refused it (<c>session_owner_mismatch</c>,
/// or 410 at a WhiteGlove Part 2 start) and the session was rotated before the successful
/// registration. Null on the normal path.
/// </summary>
public string RotatedFromSessionId { get; }
/// <summary>One of <see cref="SessionRotationReasons"/> when <see cref="RotatedFromSessionId"/> is set, else null.</summary>
public string RotationReason { get; }
/// <summary>
/// True when the run ended on a 401/403 that something on the network path answered (TLS
/// inspection, a proxy page — <see cref="BackendAuthException.IsForeignResponse"/>), not
@@ -327,7 +389,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
int httpStatusCode,
string errorMessage,
string rotatedFromSessionId = null,
bool isIntercepted = false)
bool isIntercepted = false,
string rotationReason = null)
{
Outcome = outcome;
Response = response;
@@ -335,10 +398,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
ErrorMessage = errorMessage;
RotatedFromSessionId = rotatedFromSessionId;
IsIntercepted = isIntercepted;
RotationReason = rotatedFromSessionId != null ? rotationReason : null;
}
public static SessionRegistrationResult Succeeded(RegisterSessionResponse response, string rotatedFromSessionId = null)
=> new SessionRegistrationResult(SessionRegistrationOutcome.Succeeded, response, 200, null, rotatedFromSessionId);
public static SessionRegistrationResult Succeeded(
RegisterSessionResponse response, string rotatedFromSessionId = null, string rotationReason = null)
=> new SessionRegistrationResult(
SessionRegistrationOutcome.Succeeded, response, 200, null, rotatedFromSessionId, rotationReason: rotationReason);
public static SessionRegistrationResult SessionGone(int statusCode, string message)
=> new SessionRegistrationResult(SessionRegistrationOutcome.SessionGone, null, statusCode, message);
public static SessionRegistrationResult AuthFailed(int statusCode, string message, bool intercepted = false)
=> new SessionRegistrationResult(SessionRegistrationOutcome.AuthFailed, null, statusCode, message, isIntercepted: intercepted);
@@ -78,7 +78,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
{
// Don't log the full email in decision signals — user PII. Keep domain only
// for audit trail; full email lives only in local agent logs if at all.
["userDomain"] = ExtractDomain(userEmail),
["userDomain"] = AadJoinInfo.ExtractUserDomain(userEmail),
["hasThumbprint"] = string.IsNullOrEmpty(thumbprint) ? "false" : "true",
// The AadJoinWatcher fires AadUserJoined ONLY when isPlaceholderUser==false
// (placeholder accounts go via the separate PlaceholderUserDetected event).
@@ -137,7 +137,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
var placeholderType = ClassifyPlaceholder(userEmail);
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
["userDomain"] = ExtractDomain(userEmail),
["userDomain"] = AadJoinInfo.ExtractUserDomain(userEmail),
["placeholderType"] = placeholderType,
["registryKey"] = @"HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo",
};
@@ -154,13 +154,6 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
occurredAtUtc: _clock.UtcNow);
}
private static string ExtractDomain(string email)
{
if (string.IsNullOrEmpty(email)) return "unknown";
var at = email.IndexOf('@');
return at >= 0 && at + 1 < email.Length ? email.Substring(at + 1) : "unknown";
}
private static string ClassifyPlaceholder(string email)
{
if (string.IsNullOrEmpty(email)) return "unknown";
@@ -257,7 +257,7 @@ namespace AutopilotMonitor.Agent.V2
/// <c>true</c> when the file-based <c>enrollment-complete.marker</c> signals that a
/// previous session already completed enrollment — written by
/// <see cref="EndMonitoringAtStart"/> (emergency break, intercepted-registration give-up,
/// config kill signal) right before it fires the cleanup retry, so that a next-boot observing the marker exits
/// session gone at registration, config kill signal) right before it fires the cleanup retry, so that a next-boot observing the marker exits
/// cleanly even if the cleanup retry failed to remove the Scheduled Task. Triggers another
/// self-destruct attempt when <paramref name="selfDestructOnComplete"/> is set.
/// <para>
@@ -369,10 +369,11 @@ namespace AutopilotMonitor.Agent.V2
/// next start exits cleanly even if the cleanup retry fails (Scheduled Task still alive,
/// files locked, …), runs the cleanup retry when <paramref name="runSelfDestruct"/> is set
/// (the emergency break and the give-up pass <c>SelfDestructOnComplete</c>, the kill signal
/// forces it), and deletes the persisted session files. Shared by the session-age emergency
/// break, the intercepted-registration give-up
/// (<c>BackendSessionRegistration.ConcludeFailedRun</c>) and the config-channel kill signal
/// so all three leave the device in the same state. Never throws.
/// and the session-gone end force it), and deletes the persisted session files. Shared by
/// the session-age emergency break, the intercepted-registration give-up
/// (<c>BackendSessionRegistration.ConcludeFailedRun</c>), the registration answered with 410
/// (<c>BackendSessionRegistration.ConcludeSessionGone</c>) and the config-channel kill signal
/// so all four leave the device in the same state. Never throws.
/// </summary>
internal static void EndMonitoringAtStart(
string stateDirectory,
@@ -275,7 +275,9 @@ namespace AutopilotMonitor.Agent.V2
// mapping + on-failure client disposal) is encapsulated in BackendSessionRegistration.
// Exit codes: 6 = AuthFailed, 7 = anything else. A registration intercepted on more
// starts than the buffer allows cleans up like the emergency break (state folder +
// cleanup factory) and still exits 6. See Runtime/BackendSessionRegistration.cs.
// cleanup factory) and still exits 6. A registration the backend answers with 410
// (session deleted) cleans up the same way and exits 0; a WhiteGlove Part 2 start
// registers a new session instead. See Runtime/BackendSessionRegistration.cs.
var registration = Runtime.BackendSessionRegistration.Register(
bootstrap.AgentConfig, auth, telemetry.MtlsHttpClient, GetAgentVersion(), consoleMode, logger,
rotateSession: () => Runtime.BackendSessionRegistration.RotateSession(
@@ -283,7 +285,8 @@ namespace AutopilotMonitor.Agent.V2
sessionPersistence: bootstrap.SessionPersistence,
configFetchOutcome: runtimeConfig.RemoteConfigService.LastFetchOutcome.ToString(),
stateDirectory: stateSubdir,
cleanupServiceFactory: bootstrap.CleanupServiceFactory);
cleanupServiceFactory: bootstrap.CleanupServiceFactory,
isWhiteGloveResume: bootstrap.IsWhiteGloveResume);
if (registration.ShouldExit)
{
return registration.ExitCode;
@@ -347,7 +347,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
lifecyclePost = new InformationalEventPost(ingress, SystemClock.Instance, logger);
LifecycleEmitters.EmitAgentStarted(lifecyclePost, agentConfig, previousExit, agentVersion, remoteConfigService, logger,
rotatedFromSessionId: registrationResult?.RotatedFromSessionId,
priorRegistrationFailures: registration.PriorFailures);
priorRegistrationFailures: registration.PriorFailures,
sessionRotationReason: registrationResult?.RotationReason);
// Wire-visible signal that the agent is running on defaults/cache
// rather than the live tenant config — closes the cold-start
// blind spot observed in session 8f2bef72 (2026-05-19).
@@ -26,6 +26,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// the next boot. The failed run is recorded for the emergency break report. A registration
/// that something on the network path answered gets <c>InterceptedRegistrationRetryBoots</c>
/// further boots, then the agent cleans up like the emergency break (<see cref="ConcludeFailedRun"/>).
/// A registration the backend answers with 410 — the session was deleted — ends monitoring at
/// once (<see cref="ConcludeSessionGone"/>); only a WhiteGlove Part 2 start registers a new
/// session instead, because its enrollment is only now continuing.
/// A successful registration after failed starts reports them on <c>agent_started</c>
/// (<see cref="ClearAfterSuccess"/>).
/// </summary>
@@ -37,8 +40,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// </summary>
internal static readonly TimeSpan PendingDistressBound = TimeSpan.FromSeconds(5);
/// <param name="stateDirectory">State folder for <c>enrollment-complete.marker</c> on the intercepted-registration give-up.</param>
/// <param name="cleanupServiceFactory">Self-destruct for the give-up (run when <c>SelfDestructOnComplete</c>).</param>
/// <param name="stateDirectory">State folder for <c>enrollment-complete.marker</c> on the intercepted-registration give-up and the session-gone end.</param>
/// <param name="cleanupServiceFactory">Self-destruct for the give-up (run when <c>SelfDestructOnComplete</c>) and the session-gone end (always run).</param>
/// <param name="isWhiteGloveResume">This start is a WhiteGlove Part 2: a 410 for the persisted session becomes a new session.</param>
public static SessionRegistrationOutcomeResult Register(
AgentConfiguration agentConfig,
BackendAuthBundle auth,
@@ -50,7 +54,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
SessionIdPersistence sessionPersistence = null,
string configFetchOutcome = null,
string stateDirectory = null,
Func<CleanupService> cleanupServiceFactory = null)
Func<CleanupService> cleanupServiceFactory = null,
bool isWhiteGloveResume = false)
{
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
if (auth == null) throw new ArgumentNullException(nameof(auth));
@@ -86,7 +91,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
// Reuse the single hardened hardware read from the auth bundle so the session row
// matches the security headers the backend validated against (no second WMI query).
deviceHardware: (auth.Manufacturer, auth.Model, auth.SerialNumber),
rotateSession: rotateSession)
rotateSession: rotateSession,
rotateOnSessionGone: isWhiteGloveResume)
.GetAwaiter().GetResult();
if (registrationResult.Outcome == SessionRegistrationOutcome.Succeeded)
@@ -97,6 +103,17 @@ namespace AutopilotMonitor.Agent.V2.Runtime
startContext.DeviceBootUtc, auth.ClientCertificateThumbprint));
}
if (registrationResult.Outcome == SessionRegistrationOutcome.SessionGone)
{
// The report goes out through the auth bundle's client: dispose only afterwards.
var exitCode = ConcludeSessionGone(
agentConfig, auth.EmergencyReporter, sessionPersistence, stateDirectory,
cleanupServiceFactory, consoleMode, logger);
try { mtlsHttpClient?.Dispose(); } catch { }
try { auth.BackendApiClient?.Dispose(); } catch { }
return SessionRegistrationOutcomeResult.Exit(exitCode);
}
var record = RecordFailedRun(
sessionPersistence, registrationResult, startedUtc, DateTime.UtcNow,
linkUpAtStart, TryIsNetworkLinkUp(), configFetchOutcome, NetworkFingerprint.TryCapture(), logger,
@@ -264,6 +281,41 @@ namespace AutopilotMonitor.Agent.V2.Runtime
return registrationResult.Outcome == SessionRegistrationOutcome.AuthFailed ? 6 : 7;
}
/// <summary>
/// The end of a start whose registration the backend answered with 410: the session was
/// deleted. One report over the emergency channel (a deleted session has no timeline to
/// carry an event), then monitoring ends like on the telemetry-channel 410 — marker first,
/// self-destruct regardless of <c>SelfDestructOnComplete</c>, session files deleted
/// (<see cref="Program.EndMonitoringAtStart"/>). Returns exit code 0: an intended end.
/// </summary>
internal static int ConcludeSessionGone(
AgentConfiguration agentConfig,
EmergencyReporter emergencyReporter,
SessionIdPersistence sessionPersistence,
string stateDirectory,
Func<CleanupService> cleanupServiceFactory,
bool consoleMode,
AgentLogger logger)
{
logger.Error(
$"=== SESSION GONE (HTTP 410): the backend deleted session {agentConfig.SessionId} — " +
"reporting once over the emergency channel, then cleaning up with self-destruct. ===");
if (consoleMode)
Console.Error.WriteLine("Session was deleted in the backend. Cleaning up and exiting.");
SessionGoneReport.SendBlocking(
emergencyReporter,
AgentErrorType.RegistrationRejectedSessionGone,
"Session registration rejected with HTTP 410 — the backend deleted this session. Agent cleaning up with self-destruct.",
logger);
Program.EndMonitoringAtStart(
stateDirectory, sessionPersistence, runSelfDestruct: true, cleanupServiceFactory, logger,
markerText: $"Session gone at {DateTime.UtcNow:O} (registration refused with HTTP 410)",
cleanupReason: "session_gone");
return 0;
}
/// <summary>
/// Boot times derived from the uptime counter jitter by milliseconds to seconds between two
/// reads of the same boot (NTP corrections shift them further), so only a gap above this
@@ -47,7 +47,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
RemoteConfigService remoteConfigService,
AgentLogger logger,
string rotatedFromSessionId = null,
PriorRegistrationFailures priorRegistrationFailures = null)
PriorRegistrationFailures priorRegistrationFailures = null,
string sessionRotationReason = null)
{
try
{
@@ -72,9 +73,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
{ "diagnosticsUploadMode", agentConfig.DiagnosticsUploadMode ?? "Off" },
{ "previousExitType", previousExit?.ExitType ?? "unknown" },
{ "unrestrictedMode", agentConfig.UnrestrictedMode },
// SESSION-OWNER-BINDING: true when the backend refused the persisted session
// id (session_owner_mismatch) and this run rotated to a new session before
// registering — the re-enroll-without-wipe shape, made visible on the timeline.
// True when the backend refused the persisted session id and this run rotated
// to a new session before registering: session_owner_mismatch (re-enroll
// without wipe) or 410 at a WhiteGlove Part 2 start (session deleted).
// sessionRotationReason below names which.
{ "sessionRotated", rotatedFromSessionId != null },
{ "previousSessionId", rotatedFromSessionId ?? string.Empty },
// remoteConfigFetched answers "is the agent running on a live-fetched
@@ -96,6 +98,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
if (recovered)
data["remoteConfigRecovered"] = true;
if (rotatedFromSessionId != null && !string.IsNullOrEmpty(sessionRotationReason))
data["sessionRotationReason"] = sessionRotationReason;
// Which bootstrap installed this agent (installer and loader versions, MSI or
// Intune loader, OOBE go-script), read from the scripts' own logs: makes the
// fleet's share of old or directly assigned installers countable.
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
@@ -193,14 +192,6 @@ namespace AutopilotMonitor.Agent.V2.Runtime
};
}
// Bounded blocking send for the one session-gone report: the terminate that follows ends
// the process and the cleanup script deletes the agent, so a fire-and-forget send would
// be abandoned mid-flight. Retries stay inside one anti-flood reservation (ONE report).
private const int SessionGoneReportAttempts = 2;
private static readonly TimeSpan SessionGoneReportTimeout = TimeSpan.FromSeconds(15);
private static readonly TimeSpan SessionGoneReportRetryDelay = TimeSpan.FromSeconds(3);
private static readonly TimeSpan SessionGoneReportBudget = TimeSpan.FromSeconds(40);
/// <summary>
/// The backend answered HTTP 410 for the current session's telemetry: the session is over
/// server-side. (a) Send ONE <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/>
@@ -227,25 +218,11 @@ namespace AutopilotMonitor.Agent.V2.Runtime
logger.Error($"Backend no longer knows this session (HTTP 410: {reason}) — reporting once over the emergency channel, then terminating with self-destruct.");
if (emergencyReporter != null)
{
try
{
emergencyReporter
.TrySendAsync(
AgentErrorType.TelemetryRejectedSessionUnknown,
$"Telemetry upload rejected with HTTP 410 — backend no longer knows this session ({reason}). Agent terminating with self-destruct.",
httpStatusCode: (int)HttpStatusCode.Gone,
attempts: SessionGoneReportAttempts,
perAttemptTimeout: SessionGoneReportTimeout,
retryDelay: SessionGoneReportRetryDelay)
.Wait(SessionGoneReportBudget);
}
catch (Exception ex)
{
logger.Debug($"Session-gone report send failed (best-effort): {ex.Message}");
}
}
SessionGoneReport.SendBlocking(
emergencyReporter,
AgentErrorType.TelemetryRejectedSessionUnknown,
$"Telemetry upload rejected with HTTP 410 — backend no longer knows this session ({reason}). Agent terminating with self-destruct.",
logger);
var terminateAction = new ServerAction
{
@@ -0,0 +1,50 @@
using System;
using System.Net;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Runtime
{
/// <summary>
/// The one emergency report an agent sends when the backend answered HTTP 410 for its own
/// session — on a telemetry upload (<see cref="ServerControlPlane.HandleSessionGone"/>) or at
/// registration (<see cref="BackendSessionRegistration"/>). The send blocks within a bound:
/// the caller ends the process right after and the cleanup deletes the agent, so a
/// fire-and-forget send would be abandoned mid-flight. Retries stay inside one anti-flood
/// reservation (ONE report). Never throws.
/// </summary>
internal static class SessionGoneReport
{
private const int Attempts = 2;
private static readonly TimeSpan PerAttemptTimeout = TimeSpan.FromSeconds(15);
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(3);
private static readonly TimeSpan Budget = TimeSpan.FromSeconds(40);
public static void SendBlocking(
EmergencyReporter emergencyReporter,
AgentErrorType errorType,
string message,
AgentLogger logger)
{
if (emergencyReporter == null) return;
try
{
emergencyReporter
.TrySendAsync(
errorType,
message,
httpStatusCode: (int)HttpStatusCode.Gone,
attempts: Attempts,
perAttemptTimeout: PerAttemptTimeout,
retryDelay: RetryDelay)
.Wait(Budget);
}
catch (Exception ex)
{
logger.Debug($"Session-gone report send failed (best-effort): {ex.Message}");
}
}
}
}
@@ -27,7 +27,7 @@ public class AnalyzeRulesFunctionAuthorEndpointTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string RuleId = "ANALYZE-CUSTOM-777";
private const string TemplateRuleId = "ANALYZE-SEC-004";
private const string TemplateRuleId = "ANALYZE-SEC-006";
private sealed class FakeHttpResponseData : HttpResponseData
{
@@ -177,7 +177,7 @@ public class AnalyzeRulesFunctionAuthorEndpointTests
var template = new AnalyzeRule
{
RuleId = TemplateRuleId,
Title = "AutoLogon enabled for an unexpected user",
Title = "Unexpected Provisioning Package (Custom Allow-List)",
Description = "Template",
Severity = "warning",
Category = "security",
@@ -186,7 +186,7 @@ public class AnalyzeRulesFunctionAuthorEndpointTests
Author = "Autopilot Monitor",
Conditions = new List<RuleCondition>
{
new() { Signal = "autologon", Source = "event_type", EventType = "autologon_analysis", Operator = "equals", Value = "PLACEHOLDER", Required = true }
new() { Signal = "unexpected_package", Source = "event_type", EventType = "provisioning_package_scan", Operator = "equals", Value = "PLACEHOLDER", Required = true }
},
TemplateVariables = new List<TemplateVariable>
{
@@ -194,7 +194,7 @@ public class AnalyzeRulesFunctionAuthorEndpointTests
},
};
var (function, stored) = BuildFunction(globalRule: template);
var req = BuildRequest(AlicePrincipal(), new { variables = new Dictionary<string, string> { ["allowed"] = "KIOSK-1" } });
var req = BuildRequest(AlicePrincipal(), new { variables = new Dictionary<string, string> { ["allowed"] = "Contoso.Recovery" } });
var response = await function.CreateFromTemplate(req, TemplateRuleId);
@@ -111,17 +111,12 @@ public class BuiltInRulesTests
Assert.Equal("high", sec003.Severity);
Assert.Contains(sec003.Conditions, c => c.DataField == "checks.default_password_present");
// SEC-004: kiosk allow-list TEMPLATE — disabled until configured.
var sec004 = rules.FirstOrDefault(r => r.RuleId == "ANALYZE-SEC-004");
Assert.NotNull(sec004);
Assert.False(sec004!.Enabled, "ANALYZE-SEC-004 is a template and must ship disabled");
Assert.NotNull(sec004.TemplateVariables);
var tv = Assert.Single(sec004.TemplateVariables);
Assert.Equal("allowed_autologon_users", tv.Name);
Assert.Equal(1, tv.ConditionIndex);
Assert.Equal("value", tv.Field);
Assert.Equal("not_in", sec004.Conditions[1].Operator);
Assert.Equal("checks.default_user_name", sec004.Conditions[1].DataField);
// SEC-004 (kiosk allow-list template) was removed. The ID stays retired, and no surviving
// rule may match on the AutoLogon user name: the event reports a sign-in address as its
// domain only, so an exact-match list no longer fits that field.
Assert.DoesNotContain(rules, r => r.RuleId == "ANALYZE-SEC-004");
Assert.DoesNotContain(rules, r =>
r.Conditions != null && r.Conditions.Any(c => c.DataField == "checks.default_user_name"));
}
[Fact]
@@ -0,0 +1,228 @@
using System.Net;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The agent gate never refuses on a cached configuration another instance's write has outdated:
/// before a refusal the configuration decides, it reads the stored row and decides on that. An admin
/// allows a manufacturer or switches a validation on, and the next device is admitted at once, on
/// every instance. A request the cached copy admits must not cost a read — that is the hot path.
/// The request comes from <see cref="CertTenantBindingTests"/>: a valid certificate of the sample
/// tenant, manufacturer "Contoso", model "TestBook".
/// </summary>
public sealed class ConfigConfirmBeforeRefusalTests
{
private static readonly string TenantId = CertTenantBindingTests.SampleCertTenantId.ToString();
private sealed record Gate(SecurityValidator Validator, Mock<TenantConfigurationService> Config, Dictionary<object, object> Items);
/// <summary>A permissive tenant: no device validator needed, every hardware allowed.</summary>
private static TenantConfiguration Admitting(Action<TenantConfiguration>? change = null)
{
var config = new TenantConfiguration
{
TenantId = TenantId,
AllowInsecureAgentRequests = true,
ManufacturerWhitelist = "*",
ModelWhitelist = "*",
};
change?.Invoke(config);
return config;
}
/// <summary>
/// <paramref name="cached"/> is what this instance holds; <paramref name="stored"/> is what a read
/// before a refusal returns (null: nothing to read, as inside the confirm interval). Like the real
/// service, a confirming read makes the stored row the cached one.
/// </summary>
private static Gate Build(TenantConfiguration cached, TenantConfiguration? stored)
{
var configRepo = Mock.Of<IConfigRepository>();
var cache = new MemoryCache(new MemoryCacheOptions());
var current = cached;
var config = new Mock<TenantConfigurationService>(configRepo, Mock.Of<ILogger<TenantConfigurationService>>(), cache)
{ CallBase = false };
config.Setup(x => x.TryGetConfigurationAsync(It.IsAny<string>())).ReturnsAsync(() => (current, true));
config.Setup(x => x.ConfirmBeforeRefusalAsync(It.IsAny<string>())).ReturnsAsync(() =>
{
if (stored != null) current = stored;
return stored;
});
var adminConfig = new Mock<AdminConfigurationService>(configRepo, Mock.Of<ILogger<AdminConfigurationService>>(), cache)
{ CallBase = false };
adminConfig.Setup(x => x.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration());
var validator = new SecurityValidator(
configService: config.Object,
adminConfigService: adminConfig.Object,
rateLimitService: new RateLimitService(cache, Mock.Of<ILogger<RateLimitService>>()),
autopilotDeviceValidator: null!,
corporateIdentifierValidator: null!,
logger: Mock.Of<ILogger>(),
bootstrapSessionService: null,
deviceAssociationValidator: null);
return new Gate(validator, config, new Dictionary<object, object>());
}
private static Task<SecurityValidationResult> Validate(Gate gate)
=> gate.Validator.ValidateRequestAsync(
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), gate.Items), TenantId);
private static string? Stamp(Gate gate)
=> gate.Items.TryGetValue(RequestRowMarkers.ConfigConfirmKey, out var value) ? value as string : null;
// ---------------------------------------------------------------- a refusal is confirmed first
public static TheoryData<string, TenantConfiguration, string> OutdatedCopies() => new()
{
{ "manufacturer just allowed", Admitting(c => c.ManufacturerWhitelist = "Dell*"), "Hardware not authorized" },
{ "model just allowed", Admitting(c => c.ModelWhitelist = "Latitude*"), "Hardware not authorized" },
{ "validation requirement just met", Admitting(c => c.AllowInsecureAgentRequests = false), "Device validation is required" },
{ "tenant just re-enabled", Admitting(c => { c.Disabled = true; c.DisabledReason = "paused"; }), "Tenant is suspended" },
};
[Theory]
[MemberData(nameof(OutdatedCopies))]
public async Task The_stored_row_admits_what_the_outdated_copy_would_refuse(string _, TenantConfiguration outdated, string refusal)
{
var gate = Build(cached: outdated, stored: Admitting());
var result = await Validate(gate);
Assert.True(result.IsValid, $"refused with '{result.ErrorMessage}' although the stored row admits (outdated copy would say '{refusal}')");
Assert.Equal(RequestRowMarkers.ConfigConfirm.Averted, Stamp(gate));
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(TenantId), Times.Once);
}
[Theory]
[MemberData(nameof(OutdatedCopies))]
public async Task The_refusal_stands_when_the_stored_row_says_the_same(string _, TenantConfiguration refusing, string refusal)
{
var gate = Build(cached: refusing, stored: ConfigRepoMockExtensions.Clone(refusing));
var result = await Validate(gate);
Assert.False(result.IsValid);
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
Assert.Equal(refusal, result.ErrorMessage);
Assert.Equal(RequestRowMarkers.ConfigConfirm.Confirmed, Stamp(gate));
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(TenantId), Times.Once);
}
[Theory]
[MemberData(nameof(OutdatedCopies))]
public async Task Without_a_read_the_cached_copy_decides(string _, TenantConfiguration refusing, string refusal)
{
// Inside the confirm interval, or storage failed: nothing newer to decide on.
var gate = Build(cached: refusing, stored: null);
var result = await Validate(gate);
Assert.False(result.IsValid);
Assert.Equal(refusal, result.ErrorMessage);
Assert.Null(Stamp(gate));
}
// ---------------------------------------------------------------- the hot path stays untouched
[Fact]
public async Task A_request_the_cached_copy_admits_reads_nothing()
{
var gate = Build(cached: Admitting(), stored: Admitting());
var result = await Validate(gate);
Assert.True(result.IsValid);
Assert.Null(Stamp(gate));
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(It.IsAny<string>()), Times.Never);
gate.Config.Verify(x => x.TryGetConfigurationAsync(TenantId), Times.Once);
}
[Fact]
public async Task A_refusal_the_configuration_does_not_decide_reads_nothing()
{
// A certificate of another tenant: the configuration admits, the certificate does not.
var gate = Build(cached: Admitting(), stored: Admitting());
var result = await gate.Validator.ValidateRequestAsync(
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), gate.Items),
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
Assert.False(result.IsValid);
Assert.Null(Stamp(gate));
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(It.IsAny<string>()), Times.Never);
}
// ---------------------------------------------------------------- the device validation chain
[Fact]
public async Task A_validation_switched_on_since_runs_the_chain_once_more_and_no_further()
{
// The Cloud PC validation is on in both rows, but this gate has no Cloud PC validator wired:
// nothing admits the device, on either pass. What counts here is the control flow.
var cached = Admitting(c => { c.AllowInsecureAgentRequests = false; c.ValidateCloudPcDevice = true; });
var stored = Admitting(c => { c.AllowInsecureAgentRequests = false; c.ValidateCloudPcDevice = true; c.ValidateIntuneDeviceBinding = true; });
var gate = Build(cached, stored);
var result = await Validate(gate);
Assert.False(result.IsValid);
Assert.Equal("Device not registered", result.ErrorMessage);
Assert.Equal(RequestRowMarkers.ConfigConfirm.Rerun, Stamp(gate));
gate.Config.Verify(x => x.TryGetConfigurationAsync(TenantId), Times.Exactly(2));
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(TenantId), Times.Once);
}
[Fact]
public async Task The_chain_does_not_run_again_when_the_stored_row_asks_nothing_new()
{
var cached = Admitting(c => { c.AllowInsecureAgentRequests = false; c.ValidateCloudPcDevice = true; });
var gate = Build(cached, stored: ConfigRepoMockExtensions.Clone(cached));
var result = await Validate(gate);
Assert.Equal("Device not registered", result.ErrorMessage);
Assert.Equal(RequestRowMarkers.ConfigConfirm.Confirmed, Stamp(gate));
gate.Config.Verify(x => x.TryGetConfigurationAsync(TenantId), Times.Once);
}
[Fact]
public void Only_a_validation_that_was_off_and_is_now_on_asks_for_another_pass()
{
var used = new TenantConfiguration { ValidateAutopilotDevice = true };
Assert.False(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateAutopilotDevice = true }));
Assert.False(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration()));
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateAutopilotDevice = true, ValidateCorporateIdentifier = true }));
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateDeviceAssociation = true }));
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateCloudPcDevice = true }));
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateIntuneDeviceBinding = true }));
}
// ---------------------------------------------------------------- the patch marker
[Fact]
public void A_field_patch_counts_the_sent_fields_the_stored_row_already_held()
{
static PatchOutcome Applied(params string[] changed)
=> new(true, PatchFailure.None, null, null, changed, new Dictionary<string, string>(), null);
// Wire names are camelCase, applied names are the model's property names.
Assert.Equal(0, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(new[] { "logLevel" }, Applied("LogLevel")));
Assert.Equal(1, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(
new[] { "enableTimezoneAutoSet", "rebootOnComplete" }, Applied("RebootOnComplete")));
Assert.Equal(1, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(new[] { "diagnosticsUploadMode" }, Applied()));
Assert.Equal(0, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(
new[] { "logLevel" }, PatchOutcome.Fail(PatchFailure.ValidationFailed, "invalid")));
}
}
@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The agent counts short-lived console processes and reports them as one console_script_activity
/// per window. A script that keeps spawning them is not enrollment progress: the count never heals
/// a Stalled session. A console the agent reports as such stays real activity.
/// </summary>
public class ConsoleScriptActivityClassificationTests
{
private static readonly DateTime At = new(2026, 11, 11, 14, 41, 35, DateTimeKind.Utc);
[Fact]
public void A_count_of_script_processes_alone_is_no_real_activity()
{
var classification = EventIngestProcessor.ClassifyEvents(new List<EnrollmentEvent>
{
Event(Constants.EventTypes.ConsoleScriptActivity),
});
Assert.False(classification.HasNonPeriodicRealEvent);
}
[Fact]
public void A_reported_console_is_real_activity()
{
var classification = EventIngestProcessor.ClassifyEvents(new List<EnrollmentEvent>
{
Event(Constants.EventTypes.ConsoleScriptActivity),
Event(Constants.EventTypes.OobeConsoleSpawned),
});
Assert.True(classification.HasNonPeriodicRealEvent);
}
private static EnrollmentEvent Event(string eventType) => new()
{
EventId = Guid.NewGuid().ToString(),
Timestamp = At,
EventType = eventType,
Source = "ConsoleBypassWatcher",
Data = new Dictionary<string, object> { ["count"] = 3 },
};
}
@@ -0,0 +1,55 @@
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Graph;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// POST tenants/{tenantId}/scripts/display-names: a lookup that ran into a time limit says so in
/// its answer, so the portal asks once more, and the request row tells a cut first lookup, a
/// follow-up and a follow-up that was cut again apart. A first lookup within its limits is the
/// normal case and carries neither the flag nor a marker.
/// </summary>
public class GetScriptDisplayNamesFunctionTests
{
private const string TenantId = "00000000-0000-4000-8000-000000000002";
private const string Ref = "Platform:1f9d3a52-4c1e-4b7a-9a01-52f3aaaa1001";
[Theory]
[InlineData(false, false, null)]
[InlineData(true, false, "timedOut")]
[InlineData(false, true, "retry")]
[InlineData(true, true, "retryTimedOut")]
public async Task A_cut_lookup_is_flagged_in_the_answer_and_marked_on_the_request_row(
bool timedOut, bool isFollowUp, string? expectedMarker)
{
var resolver = new Mock<IScriptDisplayNameResolver>();
resolver
.Setup(r => r.ResolveAsync(TenantId, It.IsAny<IReadOnlyCollection<ScriptRef>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((string _, IReadOnlyCollection<ScriptRef> refs, CancellationToken _) =>
new ScriptDisplayNameLookup(refs.ToDictionary(r => r, _ => (string?)null), timedOut));
var function = new GetScriptDisplayNamesFunction(NullLogger<GetScriptDisplayNamesFunction>.Instance, resolver.Object);
var body = "{\"refs\":[\"" + Ref + "\"]" + (isFollowUp ? ",\"retry\":true" : "") + "}";
var (req, context) = EndpointHarness.Request(TenantId, jsonBody: body);
var response = await function.Run(req, TenantId);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
response.Body.Position = 0;
using var doc = JsonDocument.Parse(response.Body);
Assert.True(doc.RootElement.GetProperty("refs").TryGetProperty(Ref, out _));
// The key is left out, not false, when no time limit fired.
Assert.Equal(timedOut, doc.RootElement.TryGetProperty("timedOut", out var flag));
if (timedOut) Assert.True(flag.GetBoolean());
if (expectedMarker == null)
Assert.False(context.Items.ContainsKey(RequestRowMarkers.ScriptNameLookupKey));
else
Assert.Equal(expectedMarker, context.Items[RequestRowMarkers.ScriptNameLookupKey]);
}
}
@@ -25,10 +25,21 @@ internal sealed class FakeGraphFeatureDetector : IGraphFeatureDetector
ctx?.GrantedRoles ?? (IReadOnlySet<string>)new HashSet<string>());
}
public Task<GraphTenantTokenContext?> TryGetTokenContextAsync(string tenantId, CancellationToken ct = default)
/// <summary>Tenants whose token never arrives: the call waits until the caller's budget cancels it.</summary>
public HashSet<string> HangingTenants { get; } = new();
/// <summary>When set, <see cref="TryGetTokenContextAsync"/> throws it.</summary>
public Exception? TokenContextThrows { get; set; }
public async Task<GraphTenantTokenContext?> TryGetTokenContextAsync(string tenantId, CancellationToken ct = default)
{
if (TokenContextThrows != null) throw TokenContextThrows;
if (HangingTenants.Contains(tenantId))
{
await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false);
}
ContextByTenant.TryGetValue(tenantId, out var ctx);
return Task.FromResult(ctx);
return ctx;
}
/// <summary>When set for a tenant, <see cref="GetSnapshotAsync"/> reports it as transient.</summary>
@@ -1,12 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.ApplicationInsights;
using Microsoft.ApplicationInsights.Extensibility;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests.GraphResolution;
@@ -28,9 +31,43 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, refs);
Assert.Single(result);
Assert.Null(result[refs[0]]);
Assert.Single(result.Names);
Assert.Null(result.Names[refs[0]]);
Assert.Empty(fix.Http.Requests); // Graph never called when permission gate fails
Assert.Empty(fix.IncompleteLines); // "no token or no role" is an answer, not a cut
Assert.False(result.TimedOut);
}
[Fact]
public async Task Token_that_outlasts_its_budget_is_reported_as_an_incomplete_lookup()
{
var fix = new Fixture(tokenAcquireBudget: TimeSpan.FromMilliseconds(50));
fix.Detector.HangingTenants.Add(TenantId);
var r = new ScriptRef(ScriptKind.Platform, "abc");
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result.Names[r]);
Assert.Empty(fix.Http.Requests);
var line = Assert.Single(fix.IncompleteLines);
Assert.Contains($"tenant={TenantId} ", line);
Assert.Contains("reasons=TokenTimeout ", line);
Assert.Contains("requested=1 ", line);
Assert.True(result.TimedOut);
}
[Fact]
public async Task Failing_token_context_is_reported_as_an_incomplete_lookup()
{
var fix = new Fixture();
fix.Detector.TokenContextThrows = new InvalidOperationException("simulated");
var r = new ScriptRef(ScriptKind.Platform, "abc");
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result.Names[r]);
Assert.Contains("reasons=TokenError ", Assert.Single(fix.IncompleteLines));
Assert.False(result.TimedOut); // a failure is not a time limit: asking again is not suggested
}
[Fact]
@@ -58,7 +95,7 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Equal("MyScript.ps1", result[r]);
Assert.Equal("MyScript.ps1", result.Names[r]);
Assert.Empty(fix.Http.Requests);
}
@@ -77,11 +114,78 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Equal("Bootstrap", result[r]);
Assert.Equal("Bootstrap", result.Names[r]);
Assert.Single(fix.Http.Requests);
// Both list entries cached for future calls.
Assert.Equal(2, fix.Repo.Entries.Count);
Assert.True(fix.Repo.Meta.ContainsKey((TenantId, ScriptKind.Platform)));
Assert.Empty(fix.IncompleteLines);
}
[Fact]
public async Task Script_missing_from_the_list_is_still_resolved_by_id()
{
// The list leaves out some scripts that a single GET returns. Treating "not in the list"
// as "not found" would drop their names.
var fix = new Fixture();
fix.GivenPermission();
fix.Http.When("deviceHealthScripts?", HttpStatusCode.OK,
"{\"value\":[{\"id\":\"listed\",\"displayName\":\"Listed\"}]}");
fix.Http.When("deviceHealthScripts/unlisted", HttpStatusCode.OK,
"{\"id\":\"unlisted\",\"displayName\":\"Unlisted\"}");
var listed = new ScriptRef(ScriptKind.Remediation, "listed");
var unlisted = new ScriptRef(ScriptKind.Remediation, "unlisted");
var result = await fix.Sut.ResolveAsync(TenantId, new[] { listed, unlisted });
Assert.Equal("Listed", result.Names[listed]);
Assert.Equal("Unlisted", result.Names[unlisted]);
Assert.Equal(2, fix.Http.Requests.Count);
Assert.False(fix.Repo.Entries[(TenantId, ScriptKind.Remediation, "unlisted")].IsNotFound);
Assert.Empty(fix.IncompleteLines);
Assert.False(result.TimedOut);
}
[Fact]
public async Task Timed_out_list_pull_is_reported_and_skips_the_single_lookups()
{
var fix = new Fixture();
fix.GivenPermission();
fix.Http.WhenTimedOut("deviceManagementScripts?");
fix.Http.When("deviceManagementScripts/abc", HttpStatusCode.OK,
"{\"id\":\"abc\",\"displayName\":\"SHOULD-NOT-BE-RETURNED\"}");
var r = new ScriptRef(ScriptKind.Platform, "abc");
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result.Names[r]);
Assert.Single(fix.Http.Requests);
Assert.False(fix.Repo.Meta.ContainsKey((TenantId, ScriptKind.Platform)));
Assert.Contains("reasons=ListTimeout:Platform ", Assert.Single(fix.IncompleteLines));
Assert.True(result.TimedOut);
}
[Fact]
public async Task Budget_ending_between_list_pages_stores_nothing_and_leaves_the_kind_unfetched()
{
// A list cut after its first page is incomplete. Stored together with the meta row it
// would count as the tenant's whole list for a week.
var fix = new Fixture();
fix.GivenPermission();
using var caller = new CancellationTokenSource();
fix.Http.OnRequest = _ => caller.Cancel();
fix.Http.When("deviceManagementScripts?", HttpStatusCode.OK,
"{\"value\":[{\"id\":\"id-1\",\"displayName\":\"Bootstrap\"}]," +
"\"@odata.nextLink\":\"https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts?$skiptoken=2\"}");
var r = new ScriptRef(ScriptKind.Platform, "id-1");
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r }, caller.Token);
Assert.Equal("Bootstrap", result.Names[r]); // page one was read before the cut
Assert.Single(fix.Http.Requests);
Assert.Empty(fix.Repo.Entries);
Assert.False(fix.Repo.Meta.ContainsKey((TenantId, ScriptKind.Platform)));
Assert.True(result.TimedOut);
}
[Fact]
@@ -102,7 +206,7 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Equal("Net-New", result[r]);
Assert.Equal("Net-New", result.Names[r]);
Assert.Single(fix.Http.Requests);
Assert.Contains("deviceHealthScripts/net-new-id", fix.Http.Requests[0]);
// Per-ID fetch also writes to cache.
@@ -125,9 +229,40 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result[r]);
Assert.Null(result.Names[r]);
Assert.True(fix.Repo.Entries.ContainsKey((TenantId, ScriptKind.Platform, "deleted-id")));
Assert.True(fix.Repo.Entries[(TenantId, ScriptKind.Platform, "deleted-id")].IsNotFound);
Assert.Empty(fix.IncompleteLines); // a 404 is an answer
Assert.False(result.TimedOut);
}
[Fact]
public async Task Timed_out_single_lookup_is_reported_and_ends_the_loop()
{
var fix = new Fixture();
fix.GivenPermission();
fix.Repo.Meta[(TenantId, ScriptKind.Remediation)] = new ScriptNameCacheMeta
{
TenantId = TenantId,
Kind = ScriptKind.Remediation,
LastFullRefreshAt = fix.FixedNow - TimeSpan.FromDays(1),
};
fix.Http.WhenTimedOut("deviceHealthScripts/first");
fix.Http.When("deviceHealthScripts/second", HttpStatusCode.OK,
"{\"id\":\"second\",\"displayName\":\"SHOULD-NOT-BE-RETURNED\"}");
var refs = new[]
{
new ScriptRef(ScriptKind.Remediation, "first"),
new ScriptRef(ScriptKind.Remediation, "second"),
};
var result = await fix.Sut.ResolveAsync(TenantId, refs);
Assert.All(refs, r => Assert.Null(result.Names[r]));
Assert.Single(fix.Http.Requests);
Assert.Empty(fix.Repo.Entries); // a cut is not an answer: nothing is cached as not found
Assert.Contains("reasons=LookupTimeout ", Assert.Single(fix.IncompleteLines));
Assert.True(result.TimedOut);
}
[Theory]
@@ -152,8 +287,8 @@ public class ScriptDisplayNameResolverTests
var first = await fix.Sut.ResolveAsync(TenantId, new[] { r });
var second = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(first[r]);
Assert.Null(second[r]);
Assert.Null(first.Names[r]);
Assert.Null(second.Names[r]);
Assert.Single(fix.Http.Requests);
var entry = fix.Repo.Entries[(TenantId, ScriptKind.Platform, "nameless")];
Assert.False(entry.IsNotFound);
@@ -168,7 +303,7 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, Array.Empty<ScriptRef>());
Assert.Empty(result);
Assert.Empty(result.Names);
Assert.Empty(fix.Http.Requests);
}
@@ -182,7 +317,7 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result[r]);
Assert.Null(result.Names[r]);
}
[Fact]
@@ -200,9 +335,11 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result[r]); // per-ID never ran
Assert.Null(result.Names[r]); // per-ID never ran
Assert.Single(fix.Http.Requests); // only the failed full-pull
Assert.Contains("deviceManagementScripts?", fix.Http.Requests[0]);
Assert.Contains("reasons=ListError:Platform ", Assert.Single(fix.IncompleteLines));
Assert.False(result.TimedOut); // Graph refused: an immediate second lookup would add to the throttle
}
[Fact]
@@ -234,9 +371,11 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, refs);
Assert.All(refs, r => Assert.Null(result[r]));
Assert.All(refs, r => Assert.Null(result.Names[r]));
Assert.Single(fix.Http.Requests); // second call never issued
Assert.Contains("deviceHealthScripts/first", fix.Http.Requests[0]);
Assert.Contains("reasons=LookupError ", Assert.Single(fix.IncompleteLines));
Assert.False(result.TimedOut);
}
[Fact]
@@ -257,7 +396,7 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result[r]);
Assert.Null(result.Names[r]);
Assert.Empty(fix.Http.Requests);
}
@@ -269,19 +408,28 @@ public class ScriptDisplayNameResolverTests
public FakeScriptNameCacheRepository Repo { get; } = new();
public StubHttpMessageHandler Http { get; } = new();
public StubHttpClientFactory HttpFactory { get; }
public Mock<ILogger<ScriptDisplayNameResolver>> Logger { get; } = new();
public ScriptDisplayNameResolver Sut { get; }
public DateTimeOffset FixedNow { get; } = new(2026, 5, 18, 12, 0, 0, TimeSpan.Zero);
public Fixture()
public Fixture(TimeSpan? tokenAcquireBudget = null)
{
HttpFactory = new StubHttpClientFactory(Http);
Sut = new ScriptDisplayNameResolver(
Detector, Repo, HttpFactory,
NullLogger<ScriptDisplayNameResolver>.Instance,
Logger.Object,
new TelemetryClient(new TelemetryConfiguration()),
new FakeTimeProvider(FixedNow));
new FakeTimeProvider(FixedNow),
tokenAcquireBudget);
}
/// <summary>The summary lines written for lookups that ended without every answer.</summary>
public List<string> IncompleteLines => Logger.Invocations
.Where(i => i.Method.Name == nameof(ILogger.Log) && i.Arguments[0] is LogLevel.Warning)
.Select(i => i.Arguments[2]?.ToString() ?? string.Empty)
.Where(m => m.StartsWith("Script-name lookup incomplete:", StringComparison.Ordinal))
.ToList();
public void GivenPermission()
{
Detector.ContextByTenant[TenantId] = new GraphTenantTokenContext
@@ -12,8 +12,7 @@ namespace AutopilotMonitor.Functions.Tests.GraphResolution;
/// <list type="bullet">
/// <item>Positive rows older than 7 days are removed.</item>
/// <item>Positive rows younger than 7 days are kept.</item>
/// <item>NotFound rows older than 1 hour are removed.</item>
/// <item>NotFound rows younger than 1 hour are kept.</item>
/// <item>NotFound rows follow the same 7 days: older ones are removed, younger ones are kept.</item>
/// <item>A failed delete on one row doesn't poison the run — others still get processed.</item>
/// </list>
/// </summary>
@@ -55,18 +54,20 @@ public class ScriptNameCacheCleanupFunctionTests
}
[Fact]
public async Task Deletes_notfound_rows_older_than_1_hour()
public async Task Deletes_notfound_rows_only_after_the_same_7_days()
{
// A not-found row that goes after hours instead of days sends one Graph lookup per
// unknown ID on the first view after every cleanup run.
var fix = new Fixture();
var deletedId = "deleted-1h-ago";
var recentId = "recent-30min";
var deletedId = "not-found-8-days-ago";
var recentId = "not-found-2-hours-ago";
fix.Repo.Entries[(TenantId, ScriptKind.Remediation, deletedId)] = new ScriptDisplayNameEntry
{
TenantId = TenantId,
Kind = ScriptKind.Remediation,
ScriptId = deletedId,
DisplayName = null,
FetchedAt = fix.FixedNow - TimeSpan.FromHours(2),
FetchedAt = fix.FixedNow - TimeSpan.FromDays(8),
IsNotFound = true,
};
fix.Repo.Entries[(TenantId, ScriptKind.Remediation, recentId)] = new ScriptDisplayNameEntry
@@ -75,7 +76,7 @@ public class ScriptNameCacheCleanupFunctionTests
Kind = ScriptKind.Remediation,
ScriptId = recentId,
DisplayName = null,
FetchedAt = fix.FixedNow - TimeSpan.FromMinutes(30),
FetchedAt = fix.FixedNow - TimeSpan.FromHours(2),
IsNotFound = true,
};
@@ -16,18 +16,34 @@ namespace AutopilotMonitor.Functions.Tests.GraphResolution;
internal sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly List<(string UrlSubstring, HttpStatusCode Status, string Body)> _scripted = new();
private readonly List<string> _timedOut = new();
public List<string> Requests { get; } = new();
/// <summary>Runs when a request arrives, before it is answered (e.g. to cancel the caller mid-flight).</summary>
public Action<string>? OnRequest { get; set; }
public StubHttpMessageHandler When(string urlSubstring, HttpStatusCode status, string body)
{
_scripted.Add((urlSubstring, status, body));
return this;
}
/// <summary>Scripts a request that ends the way a per-request timeout does, without waiting.</summary>
public StubHttpMessageHandler WhenTimedOut(string urlSubstring)
{
_timedOut.Add(urlSubstring);
return this;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri!.ToString();
Requests.Add(url);
OnRequest?.Invoke(url);
if (_timedOut.Any(s => url.Contains(s, StringComparison.OrdinalIgnoreCase)))
{
throw new TaskCanceledException($"StubHttpMessageHandler: scripted timeout for {url}");
}
var match = _scripted.FirstOrDefault(s => url.Contains(s.UrlSubstring, StringComparison.OrdinalIgnoreCase));
if (match.Equals(default((string, HttpStatusCode, string))))
{
@@ -76,6 +76,34 @@ public class GraphWireParityTests
new GetScriptDisplayNamesResponse { Refs = new Dictionary<string, string?>() });
}
[Fact]
public void GetScriptDisplayNamesResponse_carries_timedOut_only_when_a_time_limit_fired()
{
// The portal asks once more when it sees timedOut. Null leaves the key out, so an
// answer within the limits keeps the shapes pinned above.
var payload = new Dictionary<string, string?>
{
["Platform:6cef8fa7-9163-40cf-8f56-52f3aaaa1006"] = null,
};
AssertParity(
new { refs = payload, timedOut = true },
new GetScriptDisplayNamesResponse { Refs = payload, TimedOut = true });
}
[Fact]
public void ScriptDisplayNamesRequest_reads_the_retry_flag_and_tolerates_its_absence()
{
var options = ApiJsonOptions.Create();
const string Ref = "\"Platform:7df090b8-a274-41d0-9067-52f3aaaa1007\"";
var followUp = JsonSerializer.Deserialize<ScriptDisplayNamesRequest>("{\"refs\":[" + Ref + "],\"retry\":true}", options);
var first = JsonSerializer.Deserialize<ScriptDisplayNamesRequest>("{\"refs\":[" + Ref + "]}", options);
Assert.True(followUp!.Retry);
Assert.Null(first!.Retry);
}
[Fact]
public void GetScriptDisplayNamesResponse_dictionary_keys_are_not_camel_cased()
{
@@ -0,0 +1,116 @@
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// <c>X-Tenant-Config: stored</c> (<c>readStoredConfig</c>): a signed-in caller that has just written a
/// tenant configuration reads the stored row instead of this instance's cached copy — for its own tenant
/// and for a target it may reach. Without the header nothing changes: no extra read on any request.
/// </summary>
public partial class PolicyEnforcementMiddlewareTests
{
private static string FlagsPath(string tenantId) => $"/api/config/{tenantId}/feature-flags";
private static void StoreRows(Harness h)
=> h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
.ReturnsAsync((string tenantId) => TenantConfiguration.CreateDefault(tenantId));
private static int Reads(Harness h, string tenantId)
=> h.ConfigRepo.Invocations.Count(i =>
i.Method.Name == nameof(AutopilotMonitor.Shared.DataAccess.IConfigRepository.GetTenantConfigurationAsync)
&& string.Equals(i.Arguments[0] as string, tenantId, StringComparison.OrdinalIgnoreCase));
[Fact]
public async Task Without_the_header_a_request_reads_the_cached_copy_only()
{
var h = BuildHarness();
StoreRows(h);
h.AsTenantAdmin(TenantA, "admin@contoso.com");
var caller = AuthedPrincipal(TenantA, "admin@contoso.com");
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller)).Allowed); // warms the cache
h.ConfigRepo.Invocations.Clear();
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller)).Allowed);
Assert.Equal(0, Reads(h, TenantA));
}
[Fact]
public async Task With_the_header_the_callers_tenant_is_read_from_storage_despite_a_cached_copy()
{
var h = BuildHarness();
StoreRows(h);
h.AsTenantAdmin(TenantA, "admin@contoso.com");
var caller = AuthedPrincipal(TenantA, "admin@contoso.com");
await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller);
h.ConfigRepo.Invocations.Clear();
var r = await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller, readStoredConfig: true);
Assert.True(r.Allowed);
Assert.Equal(1, Reads(h, TenantA));
}
[Fact]
public async Task With_the_header_a_Global_Admin_reads_its_own_and_the_target_tenant()
{
// A Global Admin skips the suspension gate, so without the header nothing here reads the target.
var h = BuildHarness();
StoreRows(h);
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
var caller = AuthedPrincipal(TenantA, "ga@contoso.com");
await h.Middleware.DecideAsync("GET", FlagsPath(TenantB), null, caller); // warms whatever the decision caches
h.ConfigRepo.Invocations.Clear();
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantB), null, caller)).Allowed);
Assert.Equal(0, Reads(h, TenantA) + Reads(h, TenantB));
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantB), null, caller, readStoredConfig: true)).Allowed);
Assert.Equal(1, Reads(h, TenantA));
Assert.Equal(1, Reads(h, TenantB));
}
[Fact]
public async Task The_header_never_makes_an_instance_read_a_tenant_the_caller_may_not_reach()
{
var h = BuildHarness();
StoreRows(h);
h.AsTenantAdmin(TenantA, "admin@contoso.com");
var r = await h.Middleware.DecideAsync(
"GET", FlagsPath(TenantB), null, AuthedPrincipal(TenantA, "admin@contoso.com"), readStoredConfig: true);
Assert.False(r.Allowed);
Assert.Equal(0, Reads(h, TenantB));
}
[Fact]
public async Task Without_a_signed_in_caller_the_header_reads_nothing()
{
var h = BuildHarness();
StoreRows(h);
var r = await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, principal: null, readStoredConfig: true);
Assert.False(r.Allowed);
Assert.Empty(h.ConfigRepo.Invocations);
}
[Fact]
public async Task A_storage_failure_on_that_read_does_not_fail_the_request()
{
var h = BuildHarness();
StoreRows(h);
h.AsTenantAdmin(TenantA, "admin@contoso.com");
var caller = AuthedPrincipal(TenantA, "admin@contoso.com");
await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller); // a cached copy exists
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
.ThrowsAsync(new InvalidOperationException("storage unavailable"));
var r = await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller, readStoredConfig: true);
Assert.True(r.Allowed);
}
}
@@ -0,0 +1,97 @@
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// <c>GET config/{tenantId}</c> is the baseline of the settings editor and of every read-modify-write
/// in the portal, <c>GET config/{tenantId}/feature-flags</c> decides its banners and gates. Both
/// answer with the tenant's row or with an error — never with the synthesized default the plain
/// cached read hands out when storage fails.
/// </summary>
public sealed class PortalConfigReadTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private static (TenantConfigurationService Service, StoredTenantConfig Store) Service(TenantConfiguration? row)
{
var repo = new Mock<IConfigRepository>();
var store = repo.BackWithRow(row);
var service = new TenantConfigurationService(
repo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
return (service, store);
}
private static TenantConfiguration Row(string manufacturers)
{
var row = TenantConfiguration.CreateDefault(TenantId);
row.ManufacturerWhitelist = manufacturers;
return row;
}
private static string? Manufacturers(Microsoft.Azure.Functions.Worker.Http.HttpResponseData response)
{
response.Body.Position = 0;
using var doc = JsonDocument.Parse(response.Body);
return doc.RootElement.GetProperty("manufacturerWhitelist").GetString();
}
[Fact]
public async Task Config_answers_with_the_tenants_row()
{
var (service, _) = Service(Row("Dell*"));
var (req, _) = EndpointHarness.Request(TenantId);
var response = await new GetTenantConfigurationFunction(NullLogger<GetTenantConfigurationFunction>.Instance, service).Run(req, TenantId);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("Dell*", Manufacturers(response));
}
[Fact]
public async Task Config_answers_a_storage_failure_with_an_error_not_with_defaults()
{
var (service, store) = Service(Row("Dell*"));
store.ThrowOnRead = new InvalidOperationException("storage unavailable");
var (req, _) = EndpointHarness.Request(TenantId);
var response = await new GetTenantConfigurationFunction(NullLogger<GetTenantConfigurationFunction>.Instance, service).Run(req, TenantId);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
[Fact]
public async Task Config_still_creates_the_default_row_for_a_tenant_without_one()
{
var (service, store) = Service(null);
var (req, _) = EndpointHarness.Request(TenantId);
var response = await new GetTenantConfigurationFunction(NullLogger<GetTenantConfigurationFunction>.Instance, service).Run(req, TenantId);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(store.Row);
Assert.Equal(TenantConfiguration.CreateDefault(TenantId).ManufacturerWhitelist, Manufacturers(response));
}
[Fact]
public async Task Flags_answer_a_storage_failure_with_an_error_not_with_defaults()
{
var (service, store) = Service(Row("Dell*"));
store.ThrowOnRead = new InvalidOperationException("storage unavailable");
var (req, _) = EndpointHarness.Request(TenantId);
// The read fails before the homing service or the enrollment counter is asked.
var function = new GetTenantFeatureFlagsFunction(
NullLogger<GetTenantFeatureFlagsFunction>.Instance, service, appHomingService: null!, Mock.Of<IMetricsRepository>());
var response = await function.Run(req, TenantId);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
}
@@ -68,7 +68,7 @@ public class RuleSubmissionServiceTests
MarkSessionAsFailed = true,
Notify = true,
NotifyChannelIds = new List<string> { "chan-1" },
DerivedFromTemplateRuleId = "ANALYZE-SEC-004",
DerivedFromTemplateRuleId = "ANALYZE-SEC-006",
Explanation = "The PAC file could not be fetched.",
Conditions = new List<RuleCondition>
{
@@ -89,7 +89,7 @@ public class RuleSubmissionServiceTests
Assert.Equal("ANALYZE-CUSTOM-003", node["ruleId"]!.GetValue<string>());
Assert.Equal("Alice Admin", node["author"]!.GetValue<string>());
Assert.Equal("ANALYZE-SEC-004", node["derivedFromTemplateRuleId"]!.GetValue<string>());
Assert.Equal("ANALYZE-SEC-006", node["derivedFromTemplateRuleId"]!.GetValue<string>());
Assert.Single(node["conditions"]!.AsArray());
}
@@ -430,8 +430,8 @@ public class RuleSubmissionServiceTests
Assert.Null(s.ContactEmail);
Assert.Equal("Proxy PAC unreachable", s.Title);
Assert.Equal("network", s.Category);
Assert.Equal("ANALYZE-SEC-004", s.DerivedFromTemplateRuleId);
Assert.Contains(s.ValidationFindings, f => f.Level == "info" && f.Message.Contains("ANALYZE-SEC-004"));
Assert.Equal("ANALYZE-SEC-006", s.DerivedFromTemplateRuleId);
Assert.Contains(s.ValidationFindings, f => f.Level == "info" && f.Message.Contains("ANALYZE-SEC-006"));
Assert.DoesNotContain(s.ValidationFindings, f => f.Level == "error");
Assert.Equal(5, s.SourceFireStats!.FireCount);
Assert.Equal(50, s.SourceFireStats.SessionsEvaluated);
@@ -27,14 +27,14 @@ public class RuleSubmissionsWireParityTests
AttributionName = "Community contribution",
SubmittedAt = T0,
Status = "approved",
ValidationFindings = new List<RuleSubmissionFinding> { new() { Level = "info", Message = "Derived from template ANALYZE-SEC-004" } },
ValidationFindings = new List<RuleSubmissionFinding> { new() { Level = "info", Message = "Derived from template ANALYZE-SEC-006" } },
SourceFireStats = new RuleSubmissionFireStats { Days = 30, FireCount = 42, SessionsEvaluated = 310, EvaluationCount = 312 },
ReviewedBy = "ga@example.com",
ReviewedAt = T0.AddDays(1),
ReviewComment = "Taken.",
WillBeAdapted = true,
PublishedRuleId = "ANALYZE-NET-002",
DerivedFromTemplateRuleId = "ANALYZE-SEC-004",
DerivedFromTemplateRuleId = "ANALYZE-SEC-006",
};
private static object ItemLiteral() => new
@@ -54,14 +54,14 @@ public class RuleSubmissionsWireParityTests
attributionName = "Community contribution",
submittedAt = T0,
status = "approved",
validationFindings = new List<object> { new { level = "info", message = "Derived from template ANALYZE-SEC-004" } },
validationFindings = new List<object> { new { level = "info", message = "Derived from template ANALYZE-SEC-006" } },
sourceFireStats = new { days = 30, fireCount = 42, sessionsEvaluated = 310, evaluationCount = 312 },
reviewedBy = "ga@example.com",
reviewedAt = T0.AddDays(1),
reviewComment = "Taken.",
willBeAdapted = true,
publishedRuleId = "ANALYZE-NET-002",
derivedFromTemplateRuleId = "ANALYZE-SEC-004",
derivedFromTemplateRuleId = "ANALYZE-SEC-006",
};
[Fact]
@@ -33,7 +33,7 @@ public class TableRuleSubmissionRepositoryTests
Status = RuleSubmissionStatuses.Approved,
ValidationFindings = new List<RuleSubmissionFinding>
{
new() { Level = "info", Message = "Derived from template ANALYZE-SEC-004" },
new() { Level = "info", Message = "Derived from template ANALYZE-SEC-006" },
},
SourceFireStats = new RuleSubmissionFireStats { Days = 30, FireCount = 42, SessionsEvaluated = 310, EvaluationCount = 312 },
ReviewedBy = "ga@example.com",
@@ -41,7 +41,7 @@ public class TableRuleSubmissionRepositoryTests
ReviewComment = "Taken, generalised for the community.",
WillBeAdapted = true,
PublishedRuleId = "ANALYZE-NET-002",
DerivedFromTemplateRuleId = "ANALYZE-SEC-004",
DerivedFromTemplateRuleId = "ANALYZE-SEC-006",
};
[Fact]
@@ -0,0 +1,207 @@
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// A tenant configuration is cached per backend instance, and a write clears only the cache of the
/// instance that served it. These tests model two instances (two services, each with its own cache)
/// over one stored row and pin the two ways the stored row reaches the other instance without
/// shortening the cache: <see cref="TenantConfigurationService.RefreshFromStoreAsync"/> for a request
/// that follows its caller's own write, and <see cref="TenantConfigurationService.ConfirmBeforeRefusalAsync"/>
/// before a refusal.
/// </summary>
public sealed class TenantConfigReadAfterWriteTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private static readonly DateTimeOffset Start = new(2026, 11, 11, 8, 0, 0, TimeSpan.Zero);
private sealed record Instances(
TenantConfigurationService A, TenantConfigurationService B, StoredTenantConfig Store, Mock<IConfigRepository> Repo, TestTimeProvider Clock);
private static Instances TwoInstances(Action<TenantConfiguration>? seed = null)
{
var row = TenantConfiguration.CreateDefault(TenantId);
seed?.Invoke(row);
var repo = new Mock<IConfigRepository>();
var store = repo.BackWithRow(row);
var clock = new TestTimeProvider(Start);
TenantConfigurationService Instance() => new(
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()),
ManagedTenantProIndex.None, clock);
return new Instances(Instance(), Instance(), store, repo, clock);
}
private static Task<TenantConfigUpdate> AllowOnly(TenantConfigurationService instance, string manufacturers)
=> instance.UpdateAsync(TenantId, row =>
{
row.ManufacturerWhitelist = manufacturers;
return true;
}, "test", "hardware");
// ---------------------------------------------------------------- the hazard itself
[Fact]
public async Task A_write_through_one_instance_leaves_the_other_instances_cached_copy_untouched()
{
var s = TwoInstances();
await s.B.GetConfigurationAsync(TenantId); // B holds a copy from before the write
await AllowOnly(s.A, "Dell*");
Assert.Equal("Dell*", (await s.A.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
// This is the behaviour the two reads below exist for; it changes only with the cache itself.
Assert.NotEqual("Dell*", (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
}
// ---------------------------------------------------------------- RefreshFromStoreAsync
[Fact]
public async Task Refresh_puts_the_stored_row_into_the_instance_cache_for_every_later_read()
{
var s = TwoInstances();
await s.B.GetConfigurationAsync(TenantId);
await AllowOnly(s.A, "Dell*");
await s.B.RefreshFromStoreAsync(TenantId);
Assert.Equal("Dell*", (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
Assert.Equal("Dell*", (await s.B.GetConfigurationIfExistsAsync(TenantId))!.ManufacturerWhitelist);
var (viaGate, exists) = await s.B.TryGetConfigurationAsync(TenantId);
Assert.True(exists);
Assert.Equal("Dell*", viaGate.ManufacturerWhitelist);
}
[Fact]
public async Task A_failed_refresh_keeps_the_cached_copy_and_does_not_fail_the_request()
{
var s = TwoInstances();
var before = (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist;
await AllowOnly(s.A, "Dell*");
s.Store.ThrowOnRead = new InvalidOperationException("storage unavailable");
await s.B.RefreshFromStoreAsync(TenantId);
Assert.Equal(before, (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
}
[Fact]
public async Task Refresh_of_a_tenant_without_a_row_creates_nothing()
{
var repo = new Mock<IConfigRepository>();
var store = repo.BackWithRow(null);
var service = new TenantConfigurationService(
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()));
await service.RefreshFromStoreAsync(TenantId);
Assert.Null(store.Row);
Assert.Empty(store.Writes);
}
// ---------------------------------------------------------------- ConfirmBeforeRefusalAsync
[Fact]
public async Task Confirm_returns_the_stored_row_and_refreshes_the_cache()
{
var s = TwoInstances();
await s.B.GetConfigurationAsync(TenantId);
await AllowOnly(s.A, "Dell*,Contoso");
var stored = await s.B.ConfirmBeforeRefusalAsync(TenantId);
Assert.Equal("Dell*,Contoso", stored!.ManufacturerWhitelist);
Assert.Equal("Dell*,Contoso", (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
}
[Fact]
public async Task Confirm_reads_storage_once_per_interval_per_tenant()
{
var s = TwoInstances();
await s.B.GetConfigurationAsync(TenantId);
s.Repo.Invocations.Clear();
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync(TenantId));
s.Clock.SetUtcNow(Start + TenantConfigurationService.RefusalConfirmInterval - TimeSpan.FromSeconds(1));
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId));
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId));
s.Repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Once);
s.Clock.SetUtcNow(Start + TenantConfigurationService.RefusalConfirmInterval);
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync(TenantId));
s.Repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Exactly(2));
}
[Fact]
public async Task Confirm_counts_each_tenant_on_its_own_and_ignores_the_case_of_the_id()
{
var s = TwoInstances();
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync(TenantId));
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId.ToUpperInvariant()));
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync("22222222-2222-2222-2222-222222222222"));
}
[Fact]
public async Task Confirm_answers_null_when_the_read_fails_or_no_row_exists_so_the_refusal_stands()
{
var s = TwoInstances();
s.Store.ThrowOnRead = new InvalidOperationException("storage unavailable");
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId));
var repo = new Mock<IConfigRepository>();
repo.BackWithRow(null);
var service = new TenantConfigurationService(
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()));
Assert.Null(await service.ConfirmBeforeRefusalAsync(TenantId));
Assert.Null(await service.ConfirmBeforeRefusalAsync(string.Empty));
}
// ---------------------------------------------------------------- the bootstrap feature switch
[Fact]
public async Task Bootstrap_gate_sees_a_plan_change_another_instance_wrote()
{
var s = TwoInstances();
await s.B.GetConfigurationAsync(TenantId); // B: still the free plan
Assert.False(TenantEntitlementService.IsBootstrapEnabled((await s.B.GetConfigurationAsync(TenantId)), DateTime.UtcNow));
await s.A.UpdateAsync(TenantId, row =>
{
row.BootstrapTokenEnabled = true;
return true;
}, "test", "bootstrap");
Assert.True(await BootstrapFeatureGate.IsEnabledAsync(s.B, TenantId));
}
[Fact]
public async Task Bootstrap_gate_reads_nothing_extra_while_the_cached_copy_says_yes()
{
var s = TwoInstances(row => row.BootstrapTokenEnabled = true);
await s.B.GetConfigurationAsync(TenantId);
s.Repo.Invocations.Clear();
Assert.True(await BootstrapFeatureGate.IsEnabledAsync(s.B, TenantId));
s.Repo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task Bootstrap_gate_refuses_a_disabled_feature_and_an_unknown_tenant()
{
var s = TwoInstances();
Assert.False(await BootstrapFeatureGate.IsEnabledAsync(s.B, TenantId));
var repo = new Mock<IConfigRepository>();
repo.BackWithRow(null);
var service = new TenantConfigurationService(
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()));
Assert.False(await BootstrapFeatureGate.IsEnabledAsync(service, TenantId));
}
}
@@ -0,0 +1,91 @@
using System.Text.RegularExpressions;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Ratchet for the portal's "I have just written the configuration" hint. After a successful write
/// under <c>/api/config/</c> (or one of the listed routes) the portal asks for the stored tenant
/// configuration for a few minutes, because a write clears only the cache of the instance that
/// served it (web <c>lib/storedConfigHint.ts</c>, <c>writesTenantConfig</c>). A function that writes
/// the tenant row from a route the portal does not recognise brings the outdated reads back for
/// that route — so every such function lives under <c>Functions/Config</c> with routes below
/// <c>config/</c>, or is listed here with the reason it needs no hint.
/// </summary>
public class TenantConfigWriteRouteGuardTests
{
private static readonly Regex TenantConfigWrite = new(
@"_(?:tenantConfigService|configService|tenantConfigurationService)\s*\.\s*(?:UpdateAsync|CreateOrUpdateAsync)\("
+ @"|_patchService\s*\.\s*(?:ApplyFieldPatchAsync|RevertAsync)\(",
RegexOptions.Compiled);
private static readonly Regex WriteRoute = new(
@"HttpTrigger\([^)]*?""(?:post|put|patch|delete)""[^)]*?Route\s*=\s*""(?<route>[^""]+)""",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline);
/// <summary>Writers outside <c>Functions/Config</c>: file → (why, the portal route the web must list, if any).</summary>
private static readonly IReadOnlyDictionary<string, (string Why, string? PortalRoute)> Baseline =
new Dictionary<string, (string, string?)>(StringComparer.Ordinal)
{
["Functions/Infrastructure/AuthFunction.cs"] =
("the terms confirmation is on the portal's list; the sign-in writes of auth/me answer with what they wrote", "/api/auth/dpa-acceptance"),
["Functions/Admin/TenantOffboardFunction.cs"] =
("writes the offboarding tombstone; the tenant's members are locked out from then on", null),
};
private static string RepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
dir = dir.Parent;
Assert.NotNull(dir);
return dir!.FullName;
}
[Fact]
public void A_function_that_writes_the_tenant_row_uses_a_route_the_portal_recognises()
{
var functionsRoot = Path.Combine(RepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
var portalList = File.ReadAllText(Path.Combine(
RepoRoot(), "src", "Web", "autopilot-monitor-web", "lib", "storedConfigHint.ts"));
Assert.Contains("\"/api/config/\"", portalList);
var seen = new HashSet<string>(StringComparer.Ordinal);
var offenders = new List<string>();
foreach (var path in Directory.EnumerateFiles(Path.Combine(functionsRoot, "Functions"), "*.cs", SearchOption.AllDirectories))
{
var source = File.ReadAllText(path);
if (!TenantConfigWrite.IsMatch(source))
continue;
var rel = Path.GetRelativePath(functionsRoot, path).Replace('\\', '/');
seen.Add(rel);
if (Baseline.TryGetValue(rel, out var entry))
{
if (entry.PortalRoute != null && !portalList.Contains($"\"{entry.PortalRoute}\"", StringComparison.Ordinal))
offenders.Add($"{rel}: the portal no longer lists {entry.PortalRoute}");
continue;
}
if (!rel.StartsWith("Functions/Config/", StringComparison.Ordinal))
{
offenders.Add($"{rel}: writes the tenant row outside Functions/Config");
continue;
}
foreach (Match route in WriteRoute.Matches(source))
{
var value = route.Groups["route"].Value;
if (!value.StartsWith("config/", StringComparison.Ordinal) && !value.StartsWith("global/config", StringComparison.Ordinal))
offenders.Add($"{rel}: write route '{value}' is not under config/");
}
}
Assert.True(offenders.Count == 0,
"A tenant-configuration write the portal's hint does not cover — move the route under config/, or add the file to the "
+ "baseline with its reason and, for a portal route, extend writesTenantConfig in lib/storedConfigHint.ts:\n "
+ string.Join("\n ", offenders));
var stale = Baseline.Keys.Where(k => !seen.Contains(k)).ToList();
Assert.True(stale.Count == 0,
"Baseline entries that no longer write the tenant row — remove them:\n " + string.Join("\n ", stale));
}
}
@@ -71,8 +71,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
}
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
{
return req.CreateResponse(HttpStatusCode.NotFound);
}
@@ -83,8 +83,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
var registration = request.Registration;
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(registration.TenantId);
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, registration.TenantId))
{
return new RegisterSessionOutput { HttpResponse = req.CreateResponse(HttpStatusCode.NotFound) };
}
@@ -72,8 +72,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
}
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
{
return req.CreateResponse(HttpStatusCode.NotFound);
}
@@ -53,8 +53,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
var request = read.Value!;
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
{
return await req.ForbiddenAsync("Bootstrap token feature is not enabled for this tenant");
}
@@ -2,6 +2,7 @@ using System;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
@@ -94,8 +95,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
// Feature gate — same generic message as not-found so a disabled tenant is
// indistinguishable from an unknown code (no enumeration oracle).
var tenantConfig = await _configService.GetConfigurationAsync(session.TenantId);
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, session.TenantId))
{
_logger.LogWarning("Bootstrap script for code {Code} rejected — feature disabled for tenant {TenantId}", code, session.TenantId);
return await ScriptResponseAsync(
@@ -42,8 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
var tenantId = requestCtx.TargetTenantId;
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
{
// Return empty list instead of error — feature simply not visible
var emptyResponse = req.CreateResponse(HttpStatusCode.OK);
@@ -47,8 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
var userIdentifier = requestCtx.UserPrincipalName;
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
{
return await req.ForbiddenAsync("Bootstrap token feature is not enabled for this tenant");
}
@@ -75,8 +75,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
}
// Check if the bootstrap feature is enabled for the session's tenant (Pro plan or GA flag)
var tenantConfig = await _configService.GetConfigurationAsync(session.TenantId);
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, session.TenantId))
{
_logger.LogWarning("Bootstrap code {Code} rejected — feature disabled for tenant {TenantId}", code, session.TenantId);
return await req.NotFoundAsync("Code not found, expired, or revoked");
@@ -36,7 +36,12 @@ namespace AutopilotMonitor.Functions.Functions.Config
_logger.LogInformation($"GetTenantConfiguration: {requestCtx.TargetTenantId} by user {userIdentifier}");
var config = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
// The tenant's row or an error, never a synthesized default: this answer is the baseline
// of the settings editor and of every read-modify-write in the portal, and the plain
// cached read answers a storage failure with defaults. A tenant without a row still
// gets its default row.
var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId)
?? await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
// Only a caller with WRITE authority over THIS target tenant sees the full config including
// DiagnosticsBlobSasUrl / webhook URLs / custom headers (they manage those in the Settings
@@ -47,8 +47,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
///
/// Adding a field here is a deliberate decision that the field is non-sensitive: it must not
/// expose webhook URLs, SAS tokens, admin allowlists, or any other admin-only data. The full
/// configuration (which does contain such data) lives behind GET /api/config/{tenantId}, gated
/// to TenantAdminOrGA.
/// configuration (which does contain such data) lives behind GET /api/config/{tenantId}, which
/// redacts its secrets for every caller without write authority over the tenant.
/// </summary>
[Function("GetTenantFeatureFlags")]
public async Task<HttpResponseData> Run(
@@ -60,7 +60,10 @@ namespace AutopilotMonitor.Functions.Functions.Config
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var requestCtx = req.GetRequestContext();
var config = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
// The tenant's row or an error, never a synthesized default (see GetTenantConfiguration):
// the flags decide banners and gates, and a failed answer is not cached by the portal.
var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId)
?? await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
var appHomingFunnelActive = await _appHomingService.IsFunnelEligibleAsync(config);
// Only the setup guide needs the enrollment history; a hidden guide skips the read.
var hasEnrollmentSessions = config.SetupGuideDismissedAt == null
@@ -1,9 +1,12 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
@@ -88,6 +91,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
request.Reason,
callerTier);
StampUnchangedFields(req, request.Fields.Keys, outcome);
return await WriteOutcome(req, outcome);
}
catch (Exception ex)
@@ -96,6 +100,26 @@ namespace AutopilotMonitor.Functions.Functions.Config
}
}
/// <summary>
/// Counts the sent fields the stored row already held onto the request row. The portal sends
/// only what its user changed, so such a field means the page showed an outdated configuration.
/// </summary>
private static void StampUnchangedFields(HttpRequestData req, IEnumerable<string> sentFields, PatchOutcome outcome)
{
var unchanged = UnchangedFieldCount(sentFields, outcome);
if (unchanged > 0)
RequestRowMarkers.Stamp(req, RequestRowMarkers.ConfigPatchUnchangedKey, unchanged.ToString(CultureInfo.InvariantCulture));
}
/// <summary>Sent fields (wire names) a successful patch did not change; zero for a failed patch.</summary>
internal static int UnchangedFieldCount(IEnumerable<string> sentFields, PatchOutcome outcome)
{
if (!outcome.Success)
return 0;
var applied = new HashSet<string>(outcome.AppliedFields, StringComparer.OrdinalIgnoreCase);
return sentFields.Count(field => !applied.Contains(field));
}
/// <summary>
/// Tags the backup Source with the write path: the Collect Logs intent, else MCP (tools stamp
/// X-Client-Source: mcp) or the plain API.
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Graph;
@@ -77,18 +78,22 @@ public class GetScriptDisplayNamesFunction
var distinctRefs = parsedRefs.Distinct().ToList();
var resolved = await _resolver.ResolveAsync(requestCtx.TargetTenantId, distinctRefs, default);
var lookup = await _resolver.ResolveAsync(requestCtx.TargetTenantId, distinctRefs, default);
var payload = new Dictionary<string, string?>(distinctRefs.Count);
foreach (var r in distinctRefs)
{
payload[r.ToString()] = resolved.TryGetValue(r, out var name) ? name : null;
payload[r.ToString()] = lookup.Names.TryGetValue(r, out var name) ? name : null;
}
var marker = RequestRowMarkers.ScriptNameLookup.For(lookup.TimedOut, isRetry: body.Retry == true);
if (marker != null) RequestRowMarkers.Stamp(req, RequestRowMarkers.ScriptNameLookupKey, marker);
return await req.OkAsync(new GetScriptDisplayNamesResponse
{
Refs = payload,
Malformed = malformed.Count > 0 ? malformed : null,
TimedOut = lookup.TimedOut ? true : null,
});
}
catch (Exception ex)
@@ -13,9 +13,9 @@ namespace AutopilotMonitor.Functions.Functions.Maintenance;
/// <list type="bullet">
/// <item>Positive (found) rows older than <see cref="PositiveTtl"/> are deleted so a
/// renamed Intune script eventually surfaces under its new name.</item>
/// <item>Negative (NotFound) rows expire much faster (<see cref="NotFoundTtl"/>) — they
/// exist only to suppress repeated 404 fetches for genuinely deleted scripts;
/// once the cache is gone the resolver will lazily re-check.</item>
/// <item>Negative (NotFound) rows live just as long (<see cref="NotFoundTtl"/>): they
/// suppress repeated 404 fetches for an ID Graph does not know. A list pull that
/// contains the ID after all overwrites the row.</item>
/// <item>Per-tenant meta rows (<c>"{Kind}_$meta"</c>) live forever; they're tiny and
/// steer the resolver's "do a full pull vs. per-ID fallback" decision.</item>
/// </list>
@@ -27,8 +27,11 @@ public class ScriptNameCacheCleanupFunction
/// <summary>Positive entries are usable for 7 days; covers all reasonable troubleshooting timeframes.</summary>
public static readonly TimeSpan PositiveTtl = TimeSpan.FromDays(7);
/// <summary>Negative-cache rows expire in 1h so deleted/renamed scripts come back quickly.</summary>
public static readonly TimeSpan NotFoundTtl = TimeSpan.FromHours(1);
/// <summary>
/// Same lifetime as a name. A shorter one asks Graph again for every unknown ID on the
/// first view after each cleanup run.
/// </summary>
public static readonly TimeSpan NotFoundTtl = PositiveTtl;
/// <summary>03:00 UTC daily — outside European working hours, off the busy maintenance window.</summary>
private const string Cron = "0 0 3 * * *";
@@ -434,10 +434,6 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
RegisteredAt = DateTime.UtcNow
});
/// <summary>
/// 410 Gone response for the cascade-delete guard. Mirrors the V2 ingest 410 contract so
/// the agent treats register and telemetry uniformly when a session is being deleted.
/// </summary>
/// <summary>
/// Clamps <see cref="SessionRegistration.StartedAt"/> to the same plausibility window
/// events get (<see cref="EventTimestampValidator.SanitizeTimestamp"/>): outside
@@ -492,6 +488,11 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
ErrorCode = Constants.AgentErrorCodes.SessionOwnerMismatch,
});
/// <summary>
/// 410 Gone for the cascade-delete guard: a cascade owns the session, or its tombstone is
/// still fresh. Same status as the telemetry ingest. The agent ends monitoring on it and
/// removes itself; a WhiteGlove Part 2 start registers under a new session id instead.
/// </summary>
private static Task<HttpResponseData> WriteSessionLockedAsync(HttpRequestData req, SessionDeletionLockedException locked)
=> req.JsonAsync(HttpStatusCode.Gone, new RegisterSessionResponse
{
@@ -0,0 +1,24 @@
using AutopilotMonitor.Functions.Services;
namespace AutopilotMonitor.Functions.Helpers;
/// <summary>
/// The feature switch in front of every bootstrap route (Pro plan, or the per-tenant flag). A "no"
/// is confirmed against the stored row first (<see cref="TenantConfigurationService.ConfirmBeforeRefusalAsync"/>):
/// the cached copy can predate the plan change that enabled the feature, and a tenant that has just
/// become Pro creates and uses its first code at once. False for an unknown tenant.
/// </summary>
internal static class BootstrapFeatureGate
{
public static async Task<bool> IsEnabledAsync(TenantConfigurationService configService, string tenantId)
{
var (config, exists) = await configService.TryGetConfigurationAsync(tenantId);
if (!exists)
return false;
if (TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
return true;
var stored = await configService.ConfirmBeforeRefusalAsync(tenantId);
return stored != null && TenantEntitlementService.IsBootstrapEnabled(stored, DateTime.UtcNow);
}
}
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Http;
namespace AutopilotMonitor.Functions.Helpers;
/// <summary>
/// The <c>X-Tenant-Config: stored</c> request header. A client sends it for a few minutes after it
/// wrote a tenant configuration, because a write clears only the cache of the instance that served
/// it: the instance that answers reads the stored row first. Caller-supplied, so it only ever costs
/// the caller's own request one read — it grants nothing.
/// </summary>
internal static class StoredConfigHeader
{
public const string HeaderName = "X-Tenant-Config";
public const string Stored = "stored";
/// <summary>True when the request asks for the stored row instead of this instance's cached copy.</summary>
public static bool IsSet(HttpContext? httpContext)
=> httpContext != null && string.Equals(
httpContext.Request.Headers[HeaderName].FirstOrDefault(), Stored, StringComparison.OrdinalIgnoreCase);
}
@@ -96,12 +96,18 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
httpContext.Request.Headers[ViewAsHeaders.Tenant].FirstOrDefault(),
httpContext.Request.Headers[ViewAsHeaders.Role].FirstOrDefault());
// A signed-in caller that has just written a tenant configuration asks for the stored row
// (StoredConfigHeader); the request row counts how often that happens.
var readStoredConfig = principal != null && StoredConfigHeader.IsSet(httpContext);
if (readStoredConfig)
context.Items[RequestRowMarkers.ConfigRefreshKey] = RequestRowMarkers.ConfigRefreshHint;
// All authorization + RequestContext construction is the transport-agnostic DecideAsync seam.
// Fail-closed on service errors: return 503 (not 500) so clients can retry.
PolicyResult result;
try
{
result = await DecideAsync(httpMethod, requestPath, queryTenantId, principal, ClientSourceHeader.IsMcp(httpContext), viewAs);
result = await DecideAsync(httpMethod, requestPath, queryTenantId, principal, ClientSourceHeader.IsMcp(httpContext), viewAs, readStoredConfig);
}
catch (Exception ex)
{
@@ -224,10 +230,13 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
/// <paramref name="isMcpClient"/> is the self-declared <c>X-Client-Source: mcp</c> marker: it only ever
/// narrows (the tenant MCP switch), never grants. <paramref name="viewAs"/> (the view-as headers) only
/// ever narrows too: a Global Admin is decided as the emulated member of the viewed tenant, read-only.
/// <paramref name="readStoredConfig"/> (<see cref="StoredConfigHeader"/>) reads the stored configuration of
/// the acting and the target tenant into this instance's cache first, so this decision and the function
/// behind it see what the caller has just written through another instance.
/// </summary>
internal async Task<PolicyResult> DecideAsync(
string httpMethod, string requestPath, string? queryTenantId, ClaimsPrincipal? principal,
bool isMcpClient = false, ViewAsTarget? viewAs = null)
bool isMcpClient = false, ViewAsTarget? viewAs = null, bool readStoredConfig = false)
{
// Sanitized copies for logging only — never used for routing/authorization decisions.
var logPath = LogSanitizer.Clean(requestPath);
@@ -255,6 +264,12 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
viewing = viewAs;
}
var actingTenantId = viewing?.TenantId ?? jwtTenantId;
// Before the roles and the suspension gate below, which read this tenant's configuration too.
readStoredConfig = readStoredConfig && principal != null;
if (readStoredConfig)
await _tenantConfigService.RefreshFromStoreAsync(actingTenantId);
var roles = viewing == null ? CallerRoles.Real(this, principal) : CallerRoles.Emulated(viewing.Role);
var decision = await EvaluateCatalogPolicyAsync(principal, catalogEntry, roles, actingTenantId);
@@ -327,6 +342,11 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
decision.UserIdentifier, decision.UserRole, "CrossTenant", catalogEntry.Policy);
}
// The target of an admitted cross-tenant call (Global Admin, delegated reader) likewise; only
// past the cross-tenant check, so nobody makes this instance read a tenant it may not reach.
if (readStoredConfig && !string.Equals(targetTenantId, actingTenantId, StringComparison.OrdinalIgnoreCase))
await _tenantConfigService.RefreshFromStoreAsync(targetTenantId);
// Tenant suspension gate — the operator kill-switch (TenantConfiguration.Disabled/DisabledUntil) and
// the offboarding tombstone. Enforced HERE, on the shared authorization path, so it covers every
// tenant-scoped API call and not just the SPA's auth/me login probe (which also carries it, for the
@@ -231,6 +231,20 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
requestTelemetry.Properties[key] = text;
}
// TENANT-CONFIG FRESHNESS — the stored row was read instead of this instance's cached copy
// (a caller's own write, a refusal about to happen), or a field patch found values the
// client believed it was changing.
foreach (var key in RequestRowMarkers.ConfigFreshnessKeys)
{
if (context.Items.TryGetValue(key, out var value) && value is string text)
requestTelemetry.Properties[key] = text;
}
// SCRIPT-NAME LOOKUP — set by GetScriptDisplayNames when a lookup ran into a time limit
// or is the portal's follow-up to one.
if (context.Items.TryGetValue(RequestRowMarkers.ScriptNameLookupKey, out var scriptNameLookup) && scriptNameLookup is string scriptNameLookupOutcome)
requestTelemetry.Properties[RequestRowMarkers.ScriptNameLookupKey] = scriptNameLookupOutcome;
var reqCtx = context.GetRequestContext();
var tenantId = ResolveTenantId(
reqCtx.IdentityTenantId(),
@@ -57,6 +57,64 @@ namespace AutopilotMonitor.Functions.Security
public const string SessionUnknown = "session_unknown";
}
/// <summary>
/// The agent gate read the stored tenant configuration before refusing a request the cached
/// copy would have refused (<c>TenantConfigurationService.ConfirmBeforeRefusalAsync</c>).
/// Absent on requests that pass on the cached copy and within the confirm interval.
/// </summary>
public const string ConfigConfirmKey = "ConfigConfirm";
/// <summary>Closed set of <see cref="ConfigConfirmKey"/> values.</summary>
public static class ConfigConfirm
{
/// <summary>The stored row no longer refuses: another instance's write had outdated the copy.</summary>
public const string Averted = "averted";
/// <summary>The stored row refuses as well.</summary>
public const string Confirmed = "confirmed";
/// <summary>The stored row switches on a validation the chain had not asked; the chain ran again.</summary>
public const string Rerun = "rerun";
}
/// <summary>
/// A signed-in request asked for the stored tenant configuration (<c>StoredConfigHeader</c>):
/// its caller wrote the configuration within the last minutes.
/// </summary>
public const string ConfigRefreshKey = "ConfigRefresh";
/// <summary>The one <see cref="ConfigRefreshKey"/> value.</summary>
public const string ConfigRefreshHint = "hint";
/// <summary>
/// On a tenant-configuration field patch: how many of the sent fields the stored row already
/// held. The client believed they differed, so it was showing an outdated configuration.
/// Absent when every sent field changed.
/// </summary>
public const string ConfigPatchUnchangedKey = "ConfigPatchUnchanged";
/// <summary>
/// On a script display-name lookup: one of <see cref="ScriptNameLookup"/>. Absent on a
/// first lookup that stayed within its time limits, which is the normal case.
/// </summary>
public const string ScriptNameLookupKey = "ScriptNameLookup";
/// <summary>Closed set of <see cref="ScriptNameLookupKey"/> values.</summary>
public static class ScriptNameLookup
{
/// <summary>A first lookup ran into a time limit; the portal asks again once.</summary>
public const string TimedOut = "timedOut";
/// <summary>The portal's follow-up lookup, answered within the limits.</summary>
public const string Retry = "retry";
/// <summary>The follow-up ran into a time limit too: the names stay missing until a reload.</summary>
public const string RetryTimedOut = "retryTimedOut";
/// <summary>The value for one lookup, or null when the row gets no marker.</summary>
public static string? For(bool timedOut, bool isRetry)
{
if (isRetry) return timedOut ? RetryTimedOut : Retry;
return timedOut ? TimedOut : null;
}
}
/// <summary>
/// Outcome of the Intune device lookup behind the client certificate
/// (<see cref="IntuneDeviceBindingOutcome"/> name). Absent when no lookup result exists for
@@ -90,6 +148,12 @@ namespace AutopilotMonitor.Functions.Security
CertDeviceOwnerTypeKey, CertDeviceEnrollmentTypeKey,
};
/// <summary>The tenant-configuration freshness keys; copied onto the request row like the ones above.</summary>
public static readonly string[] ConfigFreshnessKeys =
{
ConfigConfirmKey, ConfigRefreshKey, ConfigPatchUnchangedKey,
};
/// <summary>
/// Stamps one Intune device lookup onto the request row. <paramref name="headerSerial"/> is
/// the agent-supplied serial, compared case-insensitively against the one Intune recorded.
@@ -139,7 +139,48 @@ namespace AutopilotMonitor.Functions.Security
/// add-on permission counts toward the add-on notice.
/// </param>
/// <returns>Security validation result</returns>
public async Task<SecurityValidationResult> ValidateRequestAsync(HttpRequestData req, string tenantId, string? sessionId = null, bool selfReportedCloudPc = false)
public Task<SecurityValidationResult> ValidateRequestAsync(HttpRequestData req, string tenantId, string? sessionId = null, bool selfReportedCloudPc = false)
=> ValidateRequestAsync(req, tenantId, sessionId, selfReportedCloudPc, configConfirmed: false);
/// <summary>
/// Whether a gate the tenant configuration alone decides would refuse this request: the
/// suspension, the bootstrap feature for a token request, "no validation switched on", the
/// hardware whitelist. Mirrors those gates of the validation below in their order; pure, logs
/// nothing. A gate added there that the configuration decides belongs here too, or it keeps
/// refusing on an outdated copy.
/// </summary>
internal bool RefusesByConfiguration(TenantConfiguration config, HttpRequestData req)
{
if (config.IsCurrentlyDisabled())
return true;
// A request the bootstrap token admits is decided by the feature switch alone.
if (GetBootstrapToken(req) != null && _bootstrapSessionService != null)
return !Services.TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow);
if (!config.HasAnyDeviceValidation() && !config.AllowInsecureAgentRequests)
return true;
return !HardwareWhitelistValidator.ValidateHardware(
req.Headers.Contains("X-Device-Manufacturer") ? req.Headers.GetValues("X-Device-Manufacturer").FirstOrDefault() : null,
req.Headers.Contains("X-Device-Model") ? req.Headers.GetValues("X-Device-Model").FirstOrDefault() : null,
config.GetManufacturerWhitelist(),
config.GetModelWhitelist()).IsValid;
}
/// <summary>
/// Whether <paramref name="stored"/> switches on a device validation the chain did not ask
/// while it ran on <paramref name="used"/>.
/// </summary>
internal static bool SwitchesOnUnaskedValidation(TenantConfiguration used, TenantConfiguration stored)
=> (stored.ValidateAutopilotDevice && !used.ValidateAutopilotDevice)
|| (stored.ValidateCorporateIdentifier && !used.ValidateCorporateIdentifier)
|| (stored.ValidateDeviceAssociation && !used.ValidateDeviceAssociation)
|| (stored.ValidateCloudPcDevice && !used.ValidateCloudPcDevice)
|| (stored.ValidateIntuneDeviceBinding && !used.ValidateIntuneDeviceBinding);
private async Task<SecurityValidationResult> ValidateRequestAsync(
HttpRequestData req, string tenantId, string? sessionId, bool selfReportedCloudPc, bool configConfirmed)
{
// 0. Verify tenant is known and not suspended (cheapest gate — runs before cert/rate/hardware)
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
@@ -156,6 +197,23 @@ namespace AutopilotMonitor.Functions.Security
};
}
// A refusal the configuration decides is never taken from a copy another instance's write
// has outdated: the stored row is read first, before any gate has run or logged. Requests
// the cached copy admits read nothing here. An unknown tenant needs no confirmation —
// nothing is cached for a missing row, so that answer came from storage.
if (!configConfirmed && RefusesByConfiguration(config, req))
{
configConfirmed = true;
var stored = await _configService.ConfirmBeforeRefusalAsync(tenantId);
if (stored != null)
{
RequestRowMarkers.Stamp(req, RequestRowMarkers.ConfigConfirmKey, RefusesByConfiguration(stored, req)
? RequestRowMarkers.ConfigConfirm.Confirmed
: RequestRowMarkers.ConfigConfirm.Averted);
config = stored;
}
}
if (config.IsCurrentlyDisabled())
{
_logger.LogWarning("Rejected agent request: suspended tenant {TenantId} (reason: {Reason})",
@@ -592,6 +650,21 @@ namespace AutopilotMonitor.Functions.Security
};
}
// Definitive: no switched-on validation knows the device. When the stored row switches
// on one this pass has not asked (another instance's write), the chain runs once more
// on it — the read above put that row into this instance's cache.
if (!configConfirmed)
{
var stored = await _configService.ConfirmBeforeRefusalAsync(tenantId);
if (stored != null && SwitchesOnUnaskedValidation(config, stored))
{
RequestRowMarkers.Stamp(req, RequestRowMarkers.ConfigConfirmKey, RequestRowMarkers.ConfigConfirm.Rerun);
return await ValidateRequestAsync(req, tenantId, sessionId, selfReportedCloudPc, configConfirmed: true);
}
if (stored != null)
RequestRowMarkers.Stamp(req, RequestRowMarkers.ConfigConfirmKey, RequestRowMarkers.ConfigConfirm.Confirmed);
}
LogRequestRejection("device", tenantId, req, sessionId,
extraReason: deviceValidationError,
thumbprint: certValidation.Thumbprint);
@@ -483,6 +483,9 @@ namespace AutopilotMonitor.Functions.Services
// A platform run corrected after the fact from IME's saved result (D-316): registry
// observation of an earlier run, not progress — a stalled session stays Stalled.
Constants.EventTypes.ScriptOutputReconciliation => true,
// The agent's count of short-lived console processes: a script that keeps spawning
// them is not enrollment progress — a stalled session stays Stalled.
Constants.EventTypes.ConsoleScriptActivity => true,
_ => false
};
@@ -11,15 +11,22 @@ namespace AutopilotMonitor.Functions.Services.GraphResolution;
/// script recently?), the Graph fetch (list-full-pull or per-ID fallback), and the negative-
/// cache write for 404s.
/// <para>
/// Returns a dictionary where the value for any unresolved ref is <c>null</c>. Never throws
/// to the caller — Graph timeouts / 429s / 5xx degrade to "no name" so the UI can still
/// render IDs. Cache failures behave the same way.
/// The value for any unresolved ref is <c>null</c>. Never throws to the caller — Graph
/// timeouts / 429s / 5xx degrade to "no name" so the UI can still render IDs. Cache failures
/// behave the same way.
/// </para>
/// </summary>
public interface IScriptDisplayNameResolver
{
Task<IReadOnlyDictionary<ScriptRef, string?>> ResolveAsync(
Task<ScriptDisplayNameLookup> ResolveAsync(
string tenantId,
IReadOnlyCollection<ScriptRef> refs,
CancellationToken ct = default);
}
/// <summary>
/// One lookup's outcome. <see cref="TimedOut"/> is true when a time limit of the resolver ended
/// the lookup before every ref had an answer, so asking again can return more names. A failed
/// Graph call leaves it false: asking again at once would only add to a throttle or an outage.
/// </summary>
public sealed record ScriptDisplayNameLookup(IReadOnlyDictionary<ScriptRef, string?> Names, bool TimedOut);
@@ -1,6 +1,7 @@
using AutopilotMonitor.Shared;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Net;
@@ -33,11 +34,11 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
internal static readonly TimeSpan TotalBudget = TimeSpan.FromSeconds(5);
/// <summary>
/// Tight cap on the token-acquire path specifically. The default
/// <see cref="GraphTokenService"/> retries 5+15+30 s with no cancellation in older code paths;
/// for this optional feature we want a hard wall well below <see cref="TotalBudget"/>.
/// Cap on the token-acquire path, whose retry chain (5+15+30 s) would otherwise outlast
/// <see cref="TotalBudget"/>. The first mint on a cold instance often needs more than
/// 1.5 s; a lower value drops every name on the first lookup after an idle period.
/// </summary>
internal static readonly TimeSpan TokenAcquireBudget = TimeSpan.FromMilliseconds(1500);
internal static readonly TimeSpan TokenAcquireBudget = TimeSpan.FromSeconds(3);
private const string GraphBetaBase = Constants.GraphBaseUrl + "/beta";
private const int PageSize = 100;
@@ -48,6 +49,7 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
private readonly ILogger<ScriptDisplayNameResolver> _logger;
private readonly TelemetryClient _telemetry;
private readonly TimeProvider _time;
private readonly TimeSpan _tokenAcquireBudget;
public ScriptDisplayNameResolver(
IGraphFeatureDetector detector,
@@ -66,7 +68,8 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
IHttpClientFactory httpClientFactory,
ILogger<ScriptDisplayNameResolver> logger,
TelemetryClient telemetry,
TimeProvider time)
TimeProvider time,
TimeSpan? tokenAcquireBudget = null)
{
_detector = detector;
_cache = cache;
@@ -74,38 +77,69 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
_logger = logger;
_telemetry = telemetry;
_time = time;
_tokenAcquireBudget = tokenAcquireBudget ?? TokenAcquireBudget;
}
public async Task<IReadOnlyDictionary<ScriptRef, string?>> ResolveAsync(
// Reasons in the "lookup incomplete" line. The three time limits also set
// ScriptDisplayNameLookup.TimedOut, which tells the caller that asking again can help.
private const string TokenTimeout = "TokenTimeout";
private const string TokenError = "TokenError";
private const string LookupTimeout = "LookupTimeout";
private const string LookupError = "LookupError";
public async Task<ScriptDisplayNameLookup> ResolveAsync(
string tenantId,
IReadOnlyCollection<ScriptRef> refs,
CancellationToken ct = default)
{
var result = new Dictionary<ScriptRef, string?>();
if (refs == null || refs.Count == 0) return result;
if (refs == null || refs.Count == 0) return new ScriptDisplayNameLookup(result, TimedOut: false);
foreach (var r in refs) result[r] = null;
if (string.IsNullOrWhiteSpace(tenantId)) return result;
if (string.IsNullOrWhiteSpace(tenantId)) return new ScriptDisplayNameLookup(result, TimedOut: false);
// Total budget starts BEFORE the detector call. A slow Graph token-acquire used to
// be able to burn ~50 s before this method even decided whether to proceed; the
// linked CTS + the detector's CT support cap that to TokenAcquireBudget.
var started = Stopwatch.GetTimestamp();
using var budgetCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
budgetCts.CancelAfter(TotalBudget);
var budget = budgetCts.Token;
// 1. Permission gate — with a tight sub-budget so the optional feature can't hang
// waiting for a slow Azure AD response on tenants with consent-propagation delay.
GraphTenantTokenContext? ctx;
GraphTenantTokenContext? ctx = null;
string? tokenProblem = null;
using (var tokenCts = CancellationTokenSource.CreateLinkedTokenSource(budget))
{
tokenCts.CancelAfter(TokenAcquireBudget);
ctx = await SafeAsync(_detector.TryGetTokenContextAsync(tenantId, tokenCts.Token)).ConfigureAwait(false);
tokenCts.CancelAfter(_tokenAcquireBudget);
try
{
ctx = await _detector.TryGetTokenContextAsync(tenantId, tokenCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Counted below as a timeout when it was this class's cap that fired.
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Script-name resolver: token context failed for tenant={Tenant}", tenantId);
tokenProblem = TokenError;
}
if (ctx == null && tokenProblem == null && tokenCts.IsCancellationRequested && !ct.IsCancellationRequested)
{
tokenProblem = TokenTimeout;
}
}
if (ctx == null)
{
// No token means no cache read either, so resolved and cacheHits are 0 by construction.
if (tokenProblem != null) LogIncomplete(tenantId, new[] { tokenProblem }, refs.Count, resolved: 0, cacheHits: 0, started);
return new ScriptDisplayNameLookup(result, TimedOut: tokenProblem == TokenTimeout);
}
if (ctx == null) return result;
if (!ctx.GrantedRoles.Contains(GraphAppPermissions.DeviceManagementScriptsReadAll))
{
return result;
return new ScriptDisplayNameLookup(result, TimedOut: false);
}
// 2. Cache lookup for all refs.
@@ -131,13 +165,16 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
var triggeredFullPull = false;
var missing = refs.Where(r => !cached.ContainsKey(r)).ToList();
if (missing.Count == 0) return result;
if (missing.Count == 0) return new ScriptDisplayNameLookup(result, TimedOut: false);
// 3. Decide per kind: list-full-pull when meta stale, else per-ID fallback.
// Track kinds whose full-pull hit a TRANSIENT failure (429/503/network/timeout).
// Falling back to per-ID for those kinds in the same call would just amplify the
// throttling Graph is trying to apply, so we suppress per-ID for that kind here.
var skipPerIdKinds = new HashSet<ScriptKind>();
// Why refs may end without an answer; see LogIncomplete.
var incomplete = new List<string>();
var timedOut = false;
foreach (var kind in missing.Select(r => r.Kind).Distinct())
{
@@ -172,6 +209,8 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
{
_logger.LogInformation("Script-name resolver: full-pull cancelled for tenant={Tenant} kind={Kind}", tenantId, kind);
skipPerIdKinds.Add(kind);
incomplete.Add($"ListTimeout:{kind}");
timedOut = true;
}
catch (HttpRequestException ex)
{
@@ -181,6 +220,7 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
"Script-name resolver: full-pull transient for tenant={Tenant} kind={Kind} — skipping per-ID fallback for this kind",
tenantId, kind);
skipPerIdKinds.Add(kind);
incomplete.Add($"ListError:{kind}");
}
catch (Exception ex)
{
@@ -192,12 +232,19 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
}
// 4. Per-ID fallback for whatever the full-pull (or its absence) did not produce.
// This step stays after a successful list too: the list leaves out some scripts
// that a single GET returns, so "not in the list" never means "not found".
var stillMissing = refs
.Where(r => result[r] == null && !cached!.ContainsKey(r) && !skipPerIdKinds.Contains(r.Kind))
.ToList();
foreach (var r in stillMissing)
{
if (budget.IsCancellationRequested) break;
if (budget.IsCancellationRequested)
{
incomplete.Add(LookupTimeout);
timedOut = true;
break;
}
try
{
var (displayName, notFound) = await FetchSingleAsync(r.Kind, r.Id, ctx.AccessToken, budget).ConfigureAwait(false);
@@ -216,6 +263,8 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
catch (OperationCanceledException)
{
_logger.LogInformation("Script-name resolver: per-ID fetch cancelled for tenant={Tenant} ref={Ref}", tenantId, r);
incomplete.Add(LookupTimeout);
timedOut = true;
break;
}
catch (HttpRequestException ex)
@@ -225,28 +274,48 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
_logger.LogWarning(ex,
"Script-name resolver: per-ID transient for tenant={Tenant} ref={Ref} — aborting remaining per-ID fallbacks",
tenantId, r);
incomplete.Add(LookupError);
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Script-name resolver: per-ID fetch failed for tenant={Tenant} ref={Ref}", tenantId, r);
if (!incomplete.Contains(LookupError)) incomplete.Add(LookupError);
}
}
var resolved = result.Values.Count(v => v != null);
if (incomplete.Count > 0 && !ct.IsCancellationRequested)
{
LogIncomplete(tenantId, incomplete, refs.Count, resolved, cacheHits, started);
}
// Usage telemetry — fires only when the optional permission was active AND the
// resolver actually did work. Volume = once per session-view that has scripts,
// which is a meaningful "tenant is using this feature" pulse.
EmitResolved(tenantId, refs.Count, result, cacheHits, triggeredFullPull);
EmitResolved(tenantId, refs.Count, resolved, cacheHits, triggeredFullPull);
return result;
return new ScriptDisplayNameLookup(result, timedOut);
}
private void EmitResolved(string tenantId, int refsRequested, Dictionary<ScriptRef, string?> result, int cacheHits, bool triggeredFullPull)
/// <summary>
/// One Warning per lookup that ended without an answer for every ref because a limit of
/// this class fired or Graph failed. Worker logs below Warning are not collected, and the
/// health marker reads the message prefix and the <c>reasons=</c> field: keep both stable.
/// </summary>
private void LogIncomplete(
string tenantId, IReadOnlyCollection<string> reasons, int requested, int resolved, int cacheHits, long started)
{
_logger.LogWarning(
"Script-name lookup incomplete: tenant={Tenant} reasons={Reasons} requested={Requested} resolved={Resolved} cacheHits={CacheHits} elapsedMs={ElapsedMs}",
tenantId, string.Join(",", reasons), requested, resolved, cacheHits,
(long)Stopwatch.GetElapsedTime(started).TotalMilliseconds);
}
private void EmitResolved(string tenantId, int refsRequested, int resolved, int cacheHits, bool triggeredFullPull)
{
try
{
var resolved = 0;
foreach (var v in result.Values) { if (v != null) resolved++; }
_telemetry.TrackEvent("ScriptDisplayNamesResolved", new Dictionary<string, string>
{
["TenantId"] = tenantId,
@@ -314,6 +383,10 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
url = nextUrl;
}
// A budget that ends between two pages leaves the loop with pages still to read. That
// list is incomplete and must not be stored, or the kind counts as fetched for a week.
if (!string.IsNullOrEmpty(url)) ct.ThrowIfCancellationRequested();
if (entries.Count > 0)
{
try
@@ -29,25 +29,47 @@ namespace AutopilotMonitor.Functions.Services
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
/// <summary>
/// How often <see cref="ConfirmBeforeRefusalAsync"/> reads the stored row per tenant on this
/// instance: a burst of refused requests costs one read per interval, not one each.
/// </summary>
internal static readonly TimeSpan RefusalConfirmInterval = TimeSpan.FromSeconds(15);
// Concurrent misses for one tenant (cold start, after invalidation) share one repository
// read instead of each issuing their own (audit 2026-09-05 F12).
private readonly ConcurrentDictionary<string, Lazy<Task<TenantConfiguration>>> _inFlight = new();
// When this instance last read the stored row before a refusal, per tenant.
private readonly ConcurrentDictionary<string, DateTimeOffset> _refusalConfirmedAt = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _time;
public TenantConfigurationService(
IConfigRepository configRepo,
ILogger<TenantConfigurationService> logger,
IMemoryCache cache,
ManagedTenantProIndex proIndex)
: this(configRepo, logger, cache, proIndex, TimeProvider.System)
{
}
/// <summary>Test seam — a clock the test moves across <see cref="RefusalConfirmInterval"/>.</summary>
internal TenantConfigurationService(
IConfigRepository configRepo,
ILogger<TenantConfigurationService> logger,
IMemoryCache cache,
ManagedTenantProIndex proIndex,
TimeProvider time)
{
_configRepo = configRepo;
_logger = logger;
_cache = cache;
_proIndex = proIndex;
_time = time;
}
/// <summary>
/// Test seam — no delegation index: no tenant is ever projected as managed. Production DI always
/// resolves the four-argument constructor (the longest satisfiable one).
/// resolves the four-parameter constructor (the longest public one it can satisfy).
/// </summary>
public TenantConfigurationService(IConfigRepository configRepo, ILogger<TenantConfigurationService> logger, IMemoryCache cache)
: this(configRepo, logger, cache, ManagedTenantProIndex.None)
@@ -320,12 +342,11 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// Cache-BYPASSING read (refreshes this instance's cache on hit); null when no row exists,
/// never auto-creates. For read-modify-write side-effect writers that persist the WHOLE
/// entity (e.g. AuthFunction's LastAuthClientId tracking): a cached view can be up to
/// 5 minutes stale on OTHER instances, and blindly saving it would silently rewind any
/// field another writer just changed — most critically HomedAppClientId right after a
/// consent-driven app-homing flip (the exact login that follows a flip is the one that
/// carries a changed audience and triggers the tracking write).
/// never auto-creates. A write clears only the cache of the instance that served it, so a
/// cached view can be up to 5 minutes stale on every OTHER instance. Callers that must not
/// act on such a view read here: a verdict on the tenant's current state (activation), and
/// the two wrappers below — <see cref="RefreshFromStoreAsync"/> for a request that follows
/// its caller's own write, <see cref="ConfirmBeforeRefusalAsync"/> before a refusal.
/// </summary>
public virtual async Task<TenantConfiguration?> GetConfigurationFreshAsync(string tenantId)
{
@@ -339,6 +360,75 @@ namespace AutopilotMonitor.Functions.Services
return await ProjectAsync(config);
}
/// <summary>
/// Reads the stored row into this instance's cache, so that everything the current request
/// reads afterwards sees it. For a request whose caller has just written the configuration
/// through another instance. Never throws: on a storage failure the cached copy stays in use
/// and the request goes on.
/// </summary>
public virtual async Task RefreshFromStoreAsync(string tenantId)
{
if (string.IsNullOrEmpty(tenantId))
return;
try
{
await GetConfigurationFreshAsync(tenantId);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Stored configuration of tenant {TenantId} could not be re-read; the cached copy stays in use", tenantId);
}
}
/// <summary>
/// The stored row for a caller about to refuse a request because of the configuration, or
/// null when the cached answer stands. A refusal is never decided on a copy another
/// instance's write has outdated: an admin allows a manufacturer or switches a validation on,
/// and the next device must not be turned away for up to 5 minutes. Requests that pass never
/// come here, so the hot path keeps reading the cache.
/// <para>
/// Null within <see cref="RefusalConfirmInterval"/> of the last read for this tenant (by then
/// this instance's cache holds that read), when no row exists, and when the read fails — the
/// caller then refuses as the cached copy says.
/// </para>
/// </summary>
public virtual async Task<TenantConfiguration?> ConfirmBeforeRefusalAsync(string tenantId)
{
if (string.IsNullOrEmpty(tenantId))
return null;
// The factories can run more than once under contention; each run decides `claimed` anew.
var now = _time.GetUtcNow();
var claimed = false;
_refusalConfirmedAt.AddOrUpdate(
tenantId,
_ =>
{
claimed = true;
return now;
},
(_, last) =>
{
claimed = now - last >= RefusalConfirmInterval;
return claimed ? now : last;
});
if (!claimed)
return null;
try
{
return await GetConfigurationFreshAsync(tenantId);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Stored configuration of tenant {TenantId} could not be read before a refusal; the cached copy decides", tenantId);
return null;
}
}
/// <summary>
/// Returns (config, exists). exists=false when no row was found — does NOT auto-create.
/// Use for agent security gates where unknown tenants must be rejected: storage errors are
@@ -59,6 +59,13 @@ describe('get_tenant_config — forced redaction pin', () => {
const path = apiFetchMock.mock.calls[0][0] as string;
expect(path).toBe(`/api/config/${TENANT}?view=redacted`);
});
it('asks for the stored row — the read that checks update_tenant_config must not come from an instance cache', async () => {
apiFetchMock.mockResolvedValueOnce({ tenantId: TENANT });
await captureToolHandlers(true, true).get_tenant_config({ tenantId: TENANT });
expect(apiFetchMock.mock.calls[0][1]).toEqual({ headers: { 'X-Tenant-Config': 'stored' } });
});
});
describe('get_tenant_config_schema — request shape', () => {
@@ -2445,6 +2445,8 @@ export interface GetScriptDisplayNamesResponse {
refs: Record<string, string | null>;
/** Ref tokens from the request that failed to parse, or null when there were none — the key is omitted when null (the empty-body and empty-refs early-exit sites never wrote this key at all; they leave it null). */
malformed?: string[];
/** True when the lookup ran into one of its time limits before every ref had an answer, so asking again can return more names. Null otherwise — the key is omitted. A failed Graph call does not set it. */
timedOut?: boolean;
}
/** Response of GET sessions/{sessionId}/annotations: the annotation lanes visible to the caller plus the server-computed list of lanes the caller may write. */
@@ -4755,6 +4757,8 @@ export interface SaveNotificationEmailRequest {
/** Body of POST tenants/{tenantId}/scripts/display-names: Graph script references to resolve. */
export interface ScriptDisplayNamesRequest {
refs?: string[];
/** True on the one follow-up lookup a client sends after a response with timedOut. The lookup itself runs the same; the flag only labels the request. */
retry?: boolean | null;
}
/** SearchSessions envelope. Sessions carries full SessionSummary items, or dictionary projections of them when the caller passed a fields= subset. */
@@ -222,6 +222,7 @@ export const EVENT_TYPES_CATALOG = {
'autologon_analysis',
'oobe_console_spawned',
'console_prefetch_detected',
'console_script_activity',
'security_warning',
'security_audit',
'provisioning_package_scan',
@@ -1818,7 +1818,11 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
// view=redacted is load-bearing: without it the backend serves a GA the
// clear-text secrets, which must never enter model context (pinned by
// security-guards tests).
const data = await apiFetch<TenantConfiguration>(`/api/config/${encodeURIComponent(args.tenantId)}?view=redacted`);
// X-Tenant-Config asks for the stored row: the backend caches a configuration per
// instance, and this read is how a caller checks what update_tenant_config just wrote.
const data = await apiFetch<TenantConfiguration>(
`/api/config/${encodeURIComponent(args.tenantId)}?view=redacted`,
{ headers: { 'X-Tenant-Config': 'stored' } });
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
} catch (error: unknown) {
return toolError('get_tenant_config', args, error);
+19 -11
View File
@@ -1009,27 +1009,35 @@ namespace AutopilotMonitor.Shared
// raw facts only (Winlogon registry indicators; DefaultPassword presence-only, never the
// value) at Info severity. AutoLogon-enabled alone is NOT graded — it is the fingerprint
// of Windows' own ESP auto-logon on every normal Autopilot enrollment. Backend analyze-
// rules grade only the unambiguous cases: a plaintext DefaultPassword on disk (ANALYZE-
// SEC-003, escalated) and an optional kiosk allow-list template (ANALYZE-SEC-004, off by
// default). AutoLogon may be legitimate (kiosk) OR an enrollment/OOBE manipulation vector.
// rules grade only the unambiguous case: a plaintext DefaultPassword on disk (ANALYZE-
// SEC-003, escalated). AutoLogon may be legitimate (kiosk) OR an enrollment/OOBE
// manipulation vector.
public const string AutoLogonAnalysis = "autologon_analysis";
// OOBE-console / Shift+F10 detection (per-tenant opt-OUT, AnalyzerConfiguration.
// EnableConsoleBypassDetection, default ON). Two complementary Warning signals:
// oobe_console_spawned — LIVE: an interactive-session cmd.exe (SessionID != 0) with a
// bare, non-scripted command line (no /c|/k) — an interactive
// console a human can type into. confidence=high; an instant-close
// cmd whose command line was unreadable is confidence=low. Stopped
// on real-user desktop arrival (Shift+F10 gone). NOT parent-based:
// the Shift+F10 cmd's parent is a short-lived non-winlogon launcher
// (field 24H2). Misses presses BEFORE the agent installed.
// EnableConsoleBypassDetection, default ON). Two complementary Warning signals and one count:
// oobe_console_spawned — LIVE: an interactive-session cmd.exe (SessionID != 0) that is
// still running when probed, with a bare, non-scripted command
// line (no /c|/k) — an interactive console a human can type
// into. confidence=high; one that stays open after its command
// (/k), or whose command line WMI does not return, is
// confidence=low. Stopped on real-user desktop arrival (Shift+F10
// gone). NOT parent-based: the Shift+F10 cmd's parent is a
// short-lived non-winlogon launcher (field 24H2), so
// parentProcessName is "unknown" there. Misses presses BEFORE the
// agent installed.
// console_prefetch_detected — STARTUP FORENSIC: a CMD.EXE-*.pf prefetch artifact whose
// last-run is after boot. Covers the pre-agent OOBE window the
// live watcher cannot see, but cannot attribute the run to
// Shift+F10 vs. a legitimate install-launched cmd once ESP runs
// (cmd.exe shares one .pf). Both carry coverageComplete=false +
// an honest coverageNote — detection is best-effort, not gapless.
// console_script_activity — Info: interactive-session cmd.exe that had already exited when
// probed (script children), counted per 60 s window instead of
// listed: count, Windows sessions, owner SIDs and the parents
// with their image name (never a command line). Not a console.
public const string OobeConsoleSpawned = "oobe_console_spawned";
public const string ConsolePrefetchDetected = "console_prefetch_detected";
public const string ConsoleScriptActivity = "console_script_activity";
// Termination / diagnostics / server actions
public const string EnrollmentSummaryShown = "enrollment_summary_shown";
@@ -47,6 +47,14 @@ namespace AutopilotMonitor.Shared.Models
/// channel is exactly what was refused.
/// </summary>
TelemetryRejectedSessionUnknown = 5,
/// <summary>
/// POST /api/agent/register-session answered 410 Gone at agent start: the session this
/// device resumes was deleted or is being deleted. The agent ends monitoring and removes
/// itself; this single report is the last thing the device says about the session. A
/// WhiteGlove Part 2 start registers a new session instead and sends no report.
/// </summary>
RegistrationRejectedSessionGone = 6,
}
/// <summary>
@@ -4,8 +4,7 @@ namespace AutopilotMonitor.Shared.Models.Graph
{
/// <summary>
/// One cached display-name row. Negative results (<see cref="IsNotFound"/> = true) live
/// for a shorter TTL than positive results so deleted/renamed Intune scripts get
/// re-resolved quickly.
/// as long as positive ones.
/// </summary>
public sealed class ScriptDisplayNameEntry
{
@@ -25,6 +25,13 @@ namespace AutopilotMonitor.Shared.Models
/// wrote this key at all; they leave it null).
/// </summary>
public IReadOnlyList<string>? Malformed { get; set; }
/// <summary>
/// True when the lookup ran into one of its time limits before every ref had an answer,
/// so asking again can return more names. Null otherwise — the key is omitted. A failed
/// Graph call does not set it.
/// </summary>
public bool? TimedOut { get; set; }
}
/// <summary>
@@ -65,5 +72,11 @@ namespace AutopilotMonitor.Shared.Models
public class ScriptDisplayNamesRequest : IApiRequest
{
public List<string>? Refs { get; set; }
/// <summary>
/// True on the one follow-up lookup a client sends after a response with
/// <c>timedOut</c>. The lookup itself runs the same; the flag only labels the request.
/// </summary>
public bool? Retry { get; set; }
}
}
@@ -187,13 +187,15 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [
{ value: "local_admin_analysis", label: "local_admin_analysis", category: "diagnostics",
description: "Local administrator / user-profile audit detecting pre-enrollment admin account creation (an Autopilot bypass technique); emitted at startup and shutdown for delta detection." },
{ value: "autologon_analysis", label: "autologon_analysis", category: "diagnostics",
description: "Winlogon AutoLogon facts (raw, Info-only): AutoAdminLogon/ForceAutoLogon, default user/domain, AutoLogon count, and presence-only of a plaintext DefaultPassword. AutoLogon-enabled alone is not graded (Windows' own ESP auto-logon looks identical on every normal enrollment); backend analyze-rules grade only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) plus an optional kiosk allow-list template (ANALYZE-SEC-004). AutoLogon can be a legitimate kiosk or an enrollment/OOBE manipulation vector." },
description: "Winlogon AutoLogon facts (raw, Info-only): AutoAdminLogon/ForceAutoLogon, default user/domain (a sign-in address as its domain only), AutoLogon count, and presence-only of a plaintext DefaultPassword. AutoLogon-enabled alone is not graded (Windows' own ESP auto-logon looks identical on every normal enrollment); backend analyze-rules grade only a plaintext DefaultPassword on disk (ANALYZE-SEC-003). AutoLogon can be a legitimate kiosk or an enrollment/OOBE manipulation vector." },
{ value: "gather_result", label: "gather_result", category: "diagnostics",
description: "Output of another gather rule (can be chained)." },
{ value: "provisioning_package_scan", label: "provisioning_package_scan", category: "diagnostics",
description: "Single per-device provisioning-package (PPKG) scan at DeviceSetup-phase start. Carries an artifacts[] array — one entry per registry package, .ppkg file, or Recovery\\Customizations residue (each with a scalar identity) — plus .ppkg file metadata, registry package metadata, and best-effort content indicators. The PPKG analyze rules (ANALYZE-SEC-005/006) iterate the array against an allow-list regex. PPKGs can be legitimate bulk enrollment or a manipulation vector." },
{ value: "oobe_console_spawned", label: "oobe_console_spawned", category: "diagnostics",
description: "LIVE detection of an interactive console opened during enrollment — the Shift+F10 OOBE bypass. Flags an interactive-session cmd.exe (SessionID != 0) with a bare, non-scripted command line (no /c or /k); confidence=high, or confidence=low when the command line was unreadable (instant-close). Warning severity. On by default, opt-out per tenant (EnableConsoleBypassDetection). Stopped once the real-user desktop arrives (Shift+F10 no longer possible). Best-effort, not parent-based: only consoles spawned after the agent started are observable (coverageComplete=false)." },
description: "LIVE detection of an interactive console opened during enrollment — the Shift+F10 OOBE bypass. Flags an interactive-session cmd.exe (SessionID != 0) that is still running when probed, with a bare, non-scripted command line (no /c or /k): confidence=high. A cmd /k that stays open after its command, or a running cmd whose command line cannot be read, is confidence=low. Short-lived cmd.exe (script children) are counted in console_script_activity instead. Warning severity. On by default, opt-out per tenant (EnableConsoleBypassDetection). Stopped once the real-user desktop arrives (Shift+F10 no longer possible). Best-effort, not parent-based: only consoles spawned after the agent started are observable (coverageComplete=false)." },
{ value: "console_script_activity", label: "console_script_activity", category: "diagnostics",
description: "Short-lived cmd.exe processes in an interactive session that had already exited when probed — script activity, not a console. Counted per 60-second window instead of listed: count, Windows sessions, owner SIDs and the parent processes with their image name (never a command line). Info severity. Part of the OOBE console detection (EnableConsoleBypassDetection)." },
{ value: "console_prefetch_detected", label: "console_prefetch_detected", category: "diagnostics",
description: "STARTUP-FORENSIC complement to oobe_console_spawned: a CMD.EXE-*.pf prefetch artifact whose last-run is after boot, covering the pre-agent OOBE window. Warning severity. Attribution is coarse — cmd.exe shares one prefetch file, so once ESP runs the timestamp cannot be attributed to Shift+F10 vs. a legitimate install-launched cmd (coverageComplete=false)." },
];
@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* The session page takes its session from `?id=`, so a link from one session to another
* keeps the route mounted. Its content must be keyed by that id: the event list is
* append-only (see mergeNewEvents), and without a remount the previous session's events
* stay in it and mix into the new session's timeline.
*/
describe("session page remounts per session", () => {
const source = readFileSync(join(__dirname, "..", "page.tsx"), "utf8");
it("keys its content by the session id from the URL", () => {
expect(source).toContain('const id = useSearchParams()?.get("id") ?? "";');
expect(source).toContain("<SessionDetailContent key={id} />");
});
it("renders the content from that one keyed place only", () => {
expect(source.match(/<SessionDetailContent\b/g)).toHaveLength(1);
});
});
@@ -63,14 +63,22 @@ import { DOCS_PATHS } from "@/lib/docsPaths";
import type { SubmitSessionReportRequest } from "@/lib/generated/wire-types.generated";
export default function SessionDetailPage() {
// useSearchParams() in SessionDetailContent requires a Suspense boundary for static prerender.
// useSearchParams() below requires a Suspense boundary for static prerender.
return (
<Suspense fallback={null}>
<SessionDetailContent />
<SessionDetailForCurrentId />
</Suspense>
);
}
function SessionDetailForCurrentId() {
// A link to another session only changes `?id=`, so this route stays mounted. The key
// gives every session its own state: without it the previous session's events stay in
// the append-only list and mix into the new session's timeline.
const id = useSearchParams()?.get("id") ?? "";
return <SessionDetailContent key={id} />;
}
function SessionDetailContent() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -182,7 +190,8 @@ function SessionDetailContent() {
// Resolves Intune script display names via the optional Graph add-on permission.
// Returns an empty map when the tenant hasn't granted DeviceManagementScripts.Read.All.
// Map keys are "{Kind}:{Id}" -- the renderer uses lookupScriptDisplayName to read them.
const scriptDisplayNamesByRefKey = useScriptDisplayNames(sessionTenantId ?? tenantId, events, getAccessToken);
// The lookup waits for the full event walk, so it runs once with every script of the session.
const scriptDisplayNamesByRefKey = useScriptDisplayNames(sessionTenantId ?? tenantId, events, getAccessToken, eventsApi.initialWalkComplete);
const { analysisResults, loadingAnalysis, analysisLoaded, vulnerabilityReport, fetchAnalysisResults, fetchVulnerabilityReport, persistFailureRuleIds, stoppedRuleIds } = analysis;
const { showScriptOutput, enableSoftwareInventoryAnalyzer, enableIntegrityBypassAnalyzer } = tenantConfig;
const {
@@ -90,7 +90,7 @@ function ValidationTour() {
const { user, getAccessToken } = useAuth();
const { tenantId } = useTenant();
const {
canEditConfig, error, autopilotConsentInProgress, beginDeviceValidationConsentFlow, detectExistingAccess,
config, canEditConfig, error, autopilotConsentInProgress, beginDeviceValidationConsentFlow, detectExistingAccess,
validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation, validateCloudPcDevice, validateIntuneDeviceBinding,
} = useTenantConfig();
const [target, setTarget] = useState<"autopilot" | "preparation">("autopilot");
@@ -101,7 +101,8 @@ function ValidationTour() {
const anyGate = validateAutopilotDevice || validateCorporateIdentifier || validateDeviceAssociation
|| validateCloudPcDevice || validateIntuneDeviceBinding;
const readOnly = !!user?.viewAs || !canEditConfig;
const failed = !!error && !anyGate && !autopilotConsentInProgress;
// Only with a loaded configuration: a failed load sets the same error and says nothing about the approval.
const failed = !!config && !!error && !anyGate && !autopilotConsentInProgress;
useEffect(() => {
if (anyGate && tenantId) markSetup(tenantId, { validation: true });
@@ -29,18 +29,20 @@ const ACCESS_RESTORED_MESSAGE = "Access restored. The notice on the dashboard ca
import { parseSasExpiry } from "./components/DiagnosticsSection";
import { COMMUNITY_DEFAULT, parseEditionInfo, type EditionInfo } from "@/lib/edition";
import { TenantConfiguration, TenantAdmin } from "./types";
import { SECTION_FIELD_MAP, type SectionFieldSpec, type SettingsSectionName } from "./sectionFieldMap";
import type { SettingsSectionName } from "./sectionFieldMap";
import {
DEFAULT_SETTINGS_FORM,
configFromForm,
formFromConfig,
hardwareRejectionNotify,
sectionSavePayload,
sectionsWithUnsavedEdits,
settingsFormSetters,
type SectionSaveOverrides,
type SettingsForm,
type SettingsFormSetters,
} from "./lib/settingsForm";
import { changedTenantConfigFields, patchTenantConfigFields } from "@/lib/tenantConfigSave";
import { noteTenantConfigWritten } from "@/lib/storedConfigHint";
import { patchTenantConfigFields } from "@/lib/tenantConfigSave";
import { subscribeTenantConfigPatches } from "@/lib/tenantConfigPatches";
import { leaveOrAsk } from "@/lib/unsavedChanges";
import { looksLikeGuid, type MemberKind } from "@/lib/principalKeys";
@@ -309,8 +311,10 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
trackEvent("app_homing_flipped", { source });
setHomingFlipped(true);
setAppHomingFunnelActive(false);
// The flip changed appHomingFunnelActive in the feature flags the dashboard banner reads.
// The flip changed appHomingFunnelActive in the feature flags the dashboard banner reads. The
// backend wrote it during a GET, which the fetch layer does not see as a configuration write.
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
noteTenantConfigWritten();
setHomingMissingRoles(null);
homingPendingRef.current = false;
setConfig(prev => (prev ? { ...prev, homedAppClientId: primaryClientId() ?? prev.homedAppClientId } : prev));
@@ -500,7 +504,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// Save configuration (shared by all sections)
// -----------------------------------------------------------------------
// Resolves to null when the save went through, otherwise to the text of the failure.
const runSectionSave = useCallback(async (sectionName: SettingsSectionName, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; validateCloudPcDevice?: boolean; validateIntuneDeviceBinding?: boolean; unrestrictedMode?: boolean }): Promise<string | null> => {
const runSectionSave = useCallback(async (sectionName: SettingsSectionName, overrides?: SectionSaveOverrides): Promise<string | null> => {
// Read-only viewers (Operators) have no save affordances; this guard covers any path
// that still reaches a save (the backend would 403 the PATCH regardless).
if (!tenantId || !config || !canEditConfig) return "This configuration cannot be edited right now.";
@@ -526,17 +530,13 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setError(null);
setSuccessMessage(null);
// A gate toggle passes its new value explicitly: the state update it made before calling
// has not rendered yet, so the form still holds the previous value.
const gateOverrides = Object.fromEntries(Object.entries(overrides ?? {}).filter(([, value]) => value != null));
const updatedConfig: TenantConfiguration = { ...configFromForm(config, form), ...gateOverrides };
// Per-section PATCH: send ONLY this section's fields (plus documented write-throughs)
// that actually differ from the loaded config. The other ~90 fields never round-trip,
// so a stale read cannot revert unrelated fields (the 2026-07-31 incident class), and
// GA-only toggles a tenant admin cannot edit are simply never in the payload.
const spec: SectionFieldSpec = SECTION_FIELD_MAP[sectionName];
const patchFields = changedTenantConfigFields(config, updatedConfig, [...spec.fields, ...(spec.alsoWrites ?? [])]);
// Per-section PATCH: send ONLY what the admin changed in this section (plus documented
// write-throughs), as sectionSavePayload decides it. Untouched fields never round-trip, so a
// page loaded before somebody else's write cannot revert it (the 2026-07-31 incident class),
// and GA-only toggles a tenant admin cannot edit are simply never in the payload. A gate
// toggle passes its new value explicitly: the state update it made before calling has not
// rendered yet, so the form still holds the previous value.
const patchFields = sectionSavePayload(config, form, sectionName, overrides);
// The PATCH response carries applied field names + masked diff, not the config: the
// backend verified exactly these fields changed, so merge them locally.
@@ -635,8 +635,10 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setGraphAccessRejected(false);
setGraphAccessAppMissing(false);
// The state just changed in the feature flags the dashboard banner reads.
// The state just changed in the feature flags the dashboard banner reads; the backend wrote
// it during the probe (a GET), so the fetch layer has not noted a configuration write.
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
noteTenantConfigWritten();
return true;
},
[saveConfiguration],
@@ -1,18 +1,23 @@
/**
* The Settings form projections and the unsaved-changes check built on them. A freshly loaded row
* must never count as edited (otherwise the sidebar marks pages nobody touched and leaving asks for
* nothing), an edit must mark exactly its own section, and the merge after a save must clear it.
* The Settings form projections, what a save sends and the unsaved-changes check built on them. A
* freshly loaded row must never count as edited (otherwise the sidebar marks pages nobody touched
* and leaving asks for nothing), an edit must mark exactly its own section, the merge after a save
* must clear it — and a save sends what the admin changed, nothing else: a form default the row
* never stored must not travel, or a page loaded before somebody else's write reverts that write.
* Rows are built here by hand; expectations are test-local lists, never derived from the map.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { changedTenantConfigFields } from "@/lib/tenantConfigSave";
import { SECTION_FIELD_MAP, type SettingsSectionName } from "../../sectionFieldMap";
import { SHARED_MANIFEST } from "@/lib/generated/shared-manifests.generated";
import { SAVE_BAR_SECTIONS, type SettingsSectionName } from "../../sectionFieldMap";
import type { NotificationChannel, TenantConfiguration } from "../../types";
import {
DEFAULT_SETTINGS_FORM,
configFromForm,
formFromConfig,
hardwareRejectionNotify,
sectionSavePayload,
sectionsWithUnsavedEdits,
settingsFormSetters,
type SettingsForm,
@@ -108,12 +113,9 @@ const LOADED_ROWS: Record<string, () => TenantConfiguration> = {
}),
};
/** What runSectionSave sends for a section, and the row after its merge. */
/** The row after a section save: what the save sends, merged like the settings provider merges it. */
function saveAndMerge(row: TenantConfiguration, form: SettingsForm, section: SettingsSectionName): TenantConfiguration {
const spec = SECTION_FIELD_MAP[section];
const alsoWrites = "alsoWrites" in spec ? spec.alsoWrites : [];
const patch = changedTenantConfigFields(row, configFromForm(row, form), [...spec.fields, ...alsoWrites]);
return { ...row, ...patch } as TenantConfiguration;
return { ...row, ...sectionSavePayload(row, form, section) } as TenantConfiguration;
}
function unsaved(row: TenantConfiguration, form: SettingsForm): string[] {
@@ -299,23 +301,229 @@ describe("formFromConfig / configFromForm", () => {
expect(config.webhookUrl).toBeUndefined();
});
it("a single log-level edit on a row that stores every agent parameter sends only the log level", () => {
// A row missing a field would also send that field's default on the first save, as before.
const row: TenantConfiguration = {
...fullRow(),
enableGeoLocation: true,
enableTimezoneAutoSet: false,
enableDoGroupIdAutoSet: false,
keepAwakeDuringUserEsp: false,
enableImeMatchLog: false,
enableGatherRuleDebugLog: false,
showScriptOutput: true,
enrollmentSummaryTimeoutSeconds: 60,
enrollmentSummaryLaunchRetrySeconds: 120,
};
});
describe("sectionSavePayload", () => {
it("is the one place that decides what the settings provider sends", () => {
// A second diff next to it (the raw row against the form) is how untouched defaults travelled.
const provider = readFileSync(join(__dirname, "..", "..", "TenantConfigContext.tsx"), "utf8");
expect(provider).toContain("sectionSavePayload(config, form, sectionName, overrides)");
expect(provider).not.toContain("changedTenantConfigFields");
});
it.each(Object.entries(LOADED_ROWS))("a freshly loaded row sends nothing in any section: %s", (_, build) => {
const row = build();
for (const section of SAVE_BAR_SECTIONS) {
expect(sectionSavePayload(row, formFromConfig(row), section)).toEqual({});
}
});
it("a single edit sends only that field, also on a row that never stored the fields beside it", () => {
// minimalRow stores none of the agent parameters: their form defaults must stay at home.
const row = minimalRow();
const form = { ...formFromConfig(row), logLevel: "Debug" };
const spec = SECTION_FIELD_MAP.agentParameters;
expect(changedTenantConfigFields(row, configFromForm(row, form), spec.fields)).toEqual({ logLevel: "Debug" });
expect(sectionSavePayload(row, form, "agentParameters")).toEqual({ logLevel: "Debug" });
});
it("a field the row never stored travels once the admin changes it, and no longer after he takes it back", () => {
const row = minimalRow();
const loaded = formFromConfig(row);
expect(sectionSavePayload(row, { ...loaded, enableTimezoneAutoSet: true }, "agentParameters")).toEqual({ enableTimezoneAutoSet: true });
expect(sectionSavePayload(row, { ...loaded, enableTimezoneAutoSet: false }, "agentParameters")).toEqual({});
});
it("a page loaded before somebody else's write does not revert that write", () => {
// Loaded: the timezone was never set. Since then the setup guide stored it as on (another
// instance answered this page from its older copy). The admin edits the log level and saves.
const loadedBefore = minimalRow();
const storedNow: TenantConfiguration = { ...minimalRow(), enableTimezoneAutoSet: true, enableSoftwareInventoryAnalyzer: true };
const form = { ...formFromConfig(loadedBefore), logLevel: "Debug" };
const afterSave = { ...storedNow, ...sectionSavePayload(loadedBefore, form, "agentParameters") } as TenantConfiguration;
expect(afterSave.logLevel).toBe("Debug");
expect(afterSave.enableTimezoneAutoSet).toBe(true);
expect(afterSave.enableSoftwareInventoryAnalyzer).toBe(true);
});
it.each(Object.entries(LOADED_ROWS))("unsaved means exactly: a save would send something: %s", (_, build) => {
const row = build();
const edits: SettingsForm[] = [
formFromConfig(row),
{ ...formFromConfig(row), logLevel: "Trace" },
{ ...formFromConfig(row), manufacturerWhitelist: "Contoso*" },
toggleHardwareNotify(formFromConfig(row), !hardwareRejectionNotify(formFromConfig(row))),
];
for (const form of edits) {
const sending = SAVE_BAR_SECTIONS.filter((section) => Object.keys(sectionSavePayload(row, form, section)).length > 0);
expect([...sending].sort()).toEqual(unsaved(row, form));
}
});
describe("sections that save on change", () => {
it("send exactly the switch that was flipped", () => {
const row = fullRow();
expect(sectionSavePayload(row, formFromConfig(row), "autopilotValidation", { validateCloudPcDevice: true })).toEqual({ validateCloudPcDevice: true });
expect(sectionSavePayload(row, formFromConfig(row), "unrestrictedMode", { unrestrictedMode: true })).toEqual({ unrestrictedMode: true });
});
it("send the flipped switch even when the loaded copy already holds that value", () => {
// The copy may be outdated; an explicit click is never swallowed by it.
const row = fullRow();
expect(row.validateAutopilotDevice).toBe(true);
expect(sectionSavePayload(row, formFromConfig(row), "autopilotValidation", { validateAutopilotDevice: true })).toEqual({ validateAutopilotDevice: true });
});
it("send nothing from the form: a gate that only differs there is not a click", () => {
const row = fullRow();
const form = { ...formFromConfig(row), validateDeviceAssociation: true, validateIntuneDeviceBinding: true };
expect(sectionSavePayload(row, form, "autopilotValidation")).toEqual({});
expect(sectionSavePayload(row, form, "autopilotValidation", { validateCloudPcDevice: false })).toEqual({ validateCloudPcDevice: false });
});
it("drop a field the section does not own", () => {
const row = fullRow();
expect(sectionSavePayload(row, formFromConfig(row), "unrestrictedMode", { validateAutopilotDevice: false })).toEqual({});
});
});
it("a section with a Save bar ignores switch values", () => {
const row = fullRow();
expect(sectionSavePayload(row, formFromConfig(row), "agentParameters", { unrestrictedMode: true })).toEqual({});
});
describe("hardware card", () => {
it("a whitelist edit sends the whitelist and leaves the channels alone, whatever their stored formatting", () => {
for (const build of [fullRow, LOADED_ROWS["channels stored with other formatting"], LOADED_ROWS["malformed JSON lists"]]) {
const row = build();
const form = { ...formFromConfig(row), manufacturerWhitelist: "Dell*" };
expect(sectionSavePayload(row, form, "hardwareWhitelist")).toEqual({ manufacturerWhitelist: "Dell*" });
}
});
it("a whitelist edit does not carry a channel edit waiting on the notifications card", () => {
const row = fullRow();
const loaded = formFromConfig(row);
const form = {
...loaded,
manufacturerWhitelist: "Dell*",
notificationChannels: loaded.notificationChannels.map((c) => ({ ...c, name: `${c.name}!` })),
};
expect(sectionSavePayload(row, form, "hardwareWhitelist")).toEqual({ manufacturerWhitelist: "Dell*" });
});
it("the moved switch sends the flag and its write-through into the channels", () => {
const row = fullRow();
const form = toggleHardwareNotify(formFromConfig(row), true);
expect(sectionSavePayload(row, form, "hardwareWhitelist")).toEqual({
webhookNotifyOnHardwareRejection: true,
notificationChannelsJson: JSON.stringify(form.notificationChannels),
});
});
it("the switch moved and moved back sends nothing, also when the legacy flag behind it differs", () => {
const row = { ...fullRow(), webhookNotifyOnHardwareRejection: true };
const form = toggleHardwareNotify(toggleHardwareNotify(formFromConfig(row), true), false);
expect(form.webhookNotifyOnHardwareRejection).toBe(false);
expect(sectionSavePayload(row, form, "hardwareWhitelist")).toEqual({});
expect(sectionSavePayload(row, { ...form, modelWhitelist: "Latitude*" }, "hardwareWhitelist")).toEqual({ modelWhitelist: "Latitude*" });
});
it("without channels the switch is the legacy flag alone", () => {
const row = { ...minimalRow(), webhookNotifyOnHardwareRejection: false };
expect(sectionSavePayload(row, toggleHardwareNotify(formFromConfig(row), true), "hardwareWhitelist")).toEqual({ webhookNotifyOnHardwareRejection: true });
});
});
describe("tenant with the legacy single webhook", () => {
const legacy = LOADED_ROWS["legacy single webhook without channels"];
it("a whitelist edit leaves the webhook as it is stored", () => {
const row = legacy();
const form = { ...formFromConfig(row), manufacturerWhitelist: "Dell*" };
expect(sectionSavePayload(row, form, "hardwareWhitelist")).toEqual({ manufacturerWhitelist: "Dell*" });
});
it("deleting the synthesized channel clears the legacy webhook with it", () => {
const row = legacy();
const form = { ...formFromConfig(row), notificationChannels: [] };
expect(sectionSavePayload(row, form, "notifications")).toEqual({
notificationChannelsJson: "",
webhookProviderType: 0,
webhookUrl: null,
});
});
it("the hardware switch materializes the channel and does not touch the legacy webhook fields", () => {
const row = legacy();
const form = toggleHardwareNotify(formFromConfig(row), false);
const payload = sectionSavePayload(row, form, "hardwareWhitelist");
expect(Object.keys(payload).sort()).toEqual(["notificationChannelsJson", "webhookNotifyOnHardwareRejection"]);
expect(JSON.parse(payload.notificationChannelsJson as string)).toHaveLength(1);
});
});
});
describe("form defaults", () => {
// A field the admin never saved stays empty in the row, and the form shows its default for good.
// That default has to be what the backend does with an empty field; the generated manifest carries
// the model's and the agent's defaults, so a change there without the form fails here.
it("a default row shows exactly the form's defaults", () => {
const defaults = SHARED_MANIFEST.tenantConfiguration.defaults as unknown as TenantConfiguration;
expect(formFromConfig(defaults)).toEqual(DEFAULT_SETTINGS_FORM);
});
it("match what the agent does when a setting was never stored", () => {
const { responseDefaults: agent, collectorDefaults: collectors, analyzerDefaults: analyzers } = SHARED_MANIFEST.agentConfig;
const form = formFromConfig(minimalRow());
expect({
selfDestructOnComplete: form.selfDestructOnComplete,
keepLogFile: form.keepLogFile,
enableGeoLocation: form.enableGeoLocation,
enableImeMatchLog: form.enableImeMatchLog,
enableGatherRuleDebugLog: form.enableGatherRuleDebugLog,
logLevel: form.logLevel,
rebootOnComplete: form.rebootOnComplete,
rebootDelaySeconds: form.rebootDelaySeconds,
showEnrollmentSummary: form.showEnrollmentSummary,
enrollmentSummaryTimeoutSeconds: form.enrollmentSummaryTimeoutSeconds,
enrollmentSummaryLaunchRetrySeconds: form.enrollmentSummaryLaunchRetrySeconds,
diagnosticsUploadMode: form.diagnosticsUploadMode,
unrestrictedMode: form.unrestrictedMode,
enableTimezoneAutoSet: form.enableTimezoneAutoSet,
enableDoGroupIdAutoSet: form.enableDoGroupIdAutoSet,
}).toEqual({
selfDestructOnComplete: agent.selfDestructOnComplete,
keepLogFile: agent.keepLogFile,
enableGeoLocation: agent.enableGeoLocation,
enableImeMatchLog: agent.enableImeMatchLog,
enableGatherRuleDebugLog: agent.enableGatherRuleDebugLog,
logLevel: agent.logLevel,
rebootOnComplete: agent.rebootOnComplete,
rebootDelaySeconds: agent.rebootDelaySeconds,
showEnrollmentSummary: agent.showEnrollmentSummary,
enrollmentSummaryTimeoutSeconds: agent.enrollmentSummaryTimeoutSeconds,
enrollmentSummaryLaunchRetrySeconds: agent.enrollmentSummaryLaunchRetrySeconds,
diagnosticsUploadMode: agent.diagnosticsUploadMode,
unrestrictedMode: agent.unrestrictedMode,
enableTimezoneAutoSet: agent.enableTimezoneAutoSet,
enableDoGroupIdAutoSet: agent.enableDoGroupIdAutoSet,
});
expect({
enableLocalAdminAnalyzer: form.enableLocalAdminAnalyzer,
localAdminAllowedAccounts: form.localAdminAllowedAccounts,
enableSoftwareInventoryAnalyzer: form.enableSoftwareInventoryAnalyzer,
enableIntegrityBypassAnalyzer: form.enableIntegrityBypassAnalyzer,
enableRealmJoinWatcher: form.enableRealmJoinWatcher,
enableConfigMgrWatcher: form.enableConfigMgrWatcher,
keepAwakeDuringUserEsp: form.keepAwakeDuringUserEsp,
enableConsoleBypassDetection: form.enableConsoleBypassDetection,
}).toEqual(analyzers);
// minimalRow stores the collector values itself; the defaults are what a row without them shows.
const bare = formFromConfig({ ...minimalRow(), enablePerformanceCollector: undefined, performanceCollectorIntervalSeconds: undefined } as unknown as TenantConfiguration);
expect(bare.enablePerformanceCollector).toBe(collectors.enablePerformanceCollector);
expect(bare.performanceCollectorInterval).toBe(collectors.performanceIntervalSeconds);
expect(bare.helloWaitTimeoutSeconds).toBe(collectors.helloWaitTimeoutSeconds);
});
});
@@ -4,6 +4,7 @@ import { HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS } from "./helloWaitTimeout";
import {
SAVE_BAR_SECTIONS,
SECTION_FIELD_MAP,
type SectionFieldSpec,
type SettingsSectionName,
type TenantConfigFieldName,
} from "../sectionFieldMap";
@@ -342,28 +343,80 @@ export function hardwareRejectionNotify(
: form.webhookNotifyOnHardwareRejection;
}
/** The switch a section without a Save bar has just flipped, by field. */
export type SectionSaveOverrides = Partial<Record<TenantConfigFieldName, boolean>>;
/** The stored row and the form, both written back in the stored shape; computed once per comparison. */
interface SavedAndEdited {
savedForm: SettingsForm;
saved: TenantConfiguration;
edited: TenantConfiguration;
}
function savedAndEdited(config: TenantConfiguration, form: SettingsForm): SavedAndEdited {
const savedForm = formFromConfig(config);
return { savedForm, saved: configFromForm(config, savedForm), edited: configFromForm(config, form) };
}
/** What a Save bar section sends; see {@link sectionSavePayload}. */
function saveBarPayload(
config: TenantConfiguration,
form: SettingsForm,
section: SettingsSectionName,
{ savedForm, saved, edited }: SavedAndEdited,
): Record<string, unknown> {
const spec: SectionFieldSpec = SECTION_FIELD_MAP[section];
const writeThroughs = spec.alsoWrites ?? [];
if (section === "hardwareWhitelist") {
// The rejection switch is a view over the channel flags: the legacy flag behind it and its
// write-through into the channels travel only when what the switch shows has changed.
const payload = changedTenantConfigFields(saved, edited, spec.fields.filter((f) => f !== "webhookNotifyOnHardwareRejection"));
if (hardwareRejectionNotify(savedForm) !== hardwareRejectionNotify(form)) {
Object.assign(payload, changedTenantConfigFields(saved, edited, ["webhookNotifyOnHardwareRejection", ...writeThroughs]));
}
return payload;
}
const payload = changedTenantConfigFields(saved, edited, spec.fields);
// A write-through rides along with an edit and is compared with the stored row itself: it clears
// what the projection never shows (the legacy single webhook behind the notification channels).
if (Object.keys(payload).length > 0) {
Object.assign(payload, changedTenantConfigFields(config, edited, writeThroughs));
}
return payload;
}
/**
* The sections with a Save bar whose fields the user changed: the stored and the edited values
* pass through the same projection before they are compared, so only an edit counts. A section
* that saves on change (`immediate`) never counts.
* The fields saving `section` sends: what the admin changed, nothing else. The stored and the
* edited values pass through the same projection before they are compared, so a model default
* the row never stored, a trim or a synthesized channel never travels. That is what keeps a save
* from writing a form default over a value somebody else has stored since this page loaded.
*
* A section that saves on change (`immediate`) sends exactly the switch that was flipped, also
* when the loaded copy already holds that value: the copy may be outdated, and the backend ignores
* a value it already has.
*/
export function sectionSavePayload(
config: TenantConfiguration,
form: SettingsForm,
section: SettingsSectionName,
overrides: SectionSaveOverrides = {},
): Record<string, unknown> {
const spec: SectionFieldSpec = SECTION_FIELD_MAP[section];
if (spec.immediate) {
return Object.fromEntries(
Object.entries(overrides).filter(([field, value]) => value != null && (spec.fields as readonly string[]).includes(field)),
);
}
return saveBarPayload(config, form, section, savedAndEdited(config, form));
}
/**
* The sections with a Save bar that hold unsaved edits: exactly those whose save would send
* something. A section that saves on change (`immediate`) never counts.
*/
export function sectionsWithUnsavedEdits(config: TenantConfiguration, form: SettingsForm): ReadonlySet<SettingsSectionName> {
const savedForm = formFromConfig(config);
const saved = configFromForm(config, savedForm);
const edited = configFromForm(config, form);
const unsaved = new Set<SettingsSectionName>();
for (const section of SAVE_BAR_SECTIONS) {
let fields: readonly TenantConfigFieldName[] = SECTION_FIELD_MAP[section].fields;
let switchChanged = false;
if (section === "hardwareWhitelist") {
// Compare what the switch shows, not the legacy flag behind it: with channels the switch
// writes through to them, and the legacy flag alone may not move.
fields = fields.filter((f) => f !== "webhookNotifyOnHardwareRejection");
switchChanged = hardwareRejectionNotify(savedForm) !== hardwareRejectionNotify(form);
}
if (switchChanged || Object.keys(changedTenantConfigFields(saved, edited, fields)).length > 0) {
unsaved.add(section);
}
}
return unsaved;
const projections = savedAndEdited(config, form);
return new Set(SAVE_BAR_SECTIONS.filter((section) => Object.keys(saveBarPayload(config, form, section, projections)).length > 0));
}
@@ -23,11 +23,12 @@ export interface SectionFieldSpec {
}
/**
* Section → exact TenantConfiguration fields each Settings section saves. TenantConfigContext
* builds its per-section PATCH from this map: only the section's fields (plus alsoWrites) that
* actually DIFFER from the loaded config are sent — the other ~90 fields never round-trip, so
* a stale read can no longer revert unrelated fields (the 2026-07-31 incident class). The same
* map decides which sections can hold unsaved edits and where the sidebar marks them.
* Section → exact TenantConfiguration fields each Settings section saves. The per-section PATCH
* is built from this map (lib/settingsForm.ts, sectionSavePayload): only the section's fields the
* admin CHANGED travel, plus its alsoWrites — untouched fields never round-trip, in this section
* or any other, so a page loaded before somebody else's write cannot revert it (the 2026-07-31
* incident class). The same map decides which sections can hold unsaved edits and where the
* sidebar marks them.
*/
export const SECTION_FIELD_MAP = {
hardwareWhitelist: {
@@ -1,5 +1,10 @@
"use client";
import { Suspense, useEffect, useRef } from "react";
import { useSearchParams } from "next/navigation";
import { useAuth } from "@/contexts/AuthContext";
import { useContinueSetup } from "@/hooks/useSetupChecklist";
import { parseSetupStep } from "@/lib/setupGuide";
import { useSetupGuideDialog } from "@/lib/setupGuideStore";
import { SetupGuideModal } from "./SetupGuideModal";
@@ -7,9 +12,44 @@ import { SetupGuideModal } from "./SetupGuideModal";
* Mounts the setup guide dialog once, in the root layout; the dashboard card, the sidebar entry,
* the help menu and the settings tour open it through the module store. Unmounted while closed,
* so every opening starts at the requested (or first open) step.
*
* `?setup-guide=settings|hardware|validation|deploy` opens it at that step, also for a tenant
* that finished the setup: nothing is reset, the steps show the tenant's real configuration.
*/
export function SetupGuideHost() {
const { open, step } = useSetupGuideDialog();
if (!open) return null;
return <SetupGuideModal initialStep={step} />;
return (
<>
{/* useSearchParams() needs a Suspense boundary for the static prerender. */}
<Suspense fallback={null}>
<SetupGuideDeepLink />
</Suspense>
{open && <SetupGuideModal initialStep={step} />}
</>
);
}
/**
* Opens the guide once each time the parameter arrives, not only on the first mount: a link
* opened without a session goes through the sign-in and reaches the page again by a client-side
* navigation, where this layout stays mounted. Admins only, like the help menu entry.
*/
function SetupGuideDeepLink() {
const requested = parseSetupStep(useSearchParams()?.get("setup-guide"));
const { user, isAuthenticated, isLoading } = useAuth();
const continueSetup = useContinueSetup();
const allowed = isAuthenticated && !isLoading && !!(user?.isTenantAdmin || user?.isGlobalAdmin);
const handled = useRef(false);
useEffect(() => {
if (requested === null) {
handled.current = false;
return;
}
if (handled.current || !allowed) return;
handled.current = true;
continueSetup(requested, "deeplink");
}, [requested, allowed, continueSetup]);
return null;
}
@@ -31,6 +31,7 @@ import {
} from "@/lib/setupGuide";
import { closeSetupGuide, markSetup, startSetupTour } from "@/lib/setupGuideStore";
import { recordSetupGuideStep, writeGuideConfigFields } from "@/lib/setupGuideWrites";
import { subscribeTenantConfigPatches } from "@/lib/tenantConfigPatches";
const STEP_TITLES: Record<SetupStepId, string> = {
settings: "Recommended settings",
@@ -73,6 +74,13 @@ export function SetupGuideModal({ initialStep }: { initialStep: SetupStepId | nu
};
}, [tenantId, getAccessToken]);
// The guide's own writes go into the copy the steps read: after "Back" a step shows what was
// just applied instead of offering it again.
useEffect(() => subscribeTenantConfigPatches((patchedTenantId, fields) => {
if (patchedTenantId !== tenantId) return;
setConfig((prev) => (prev ? ({ ...prev, ...fields } as TenantConfiguration) : prev));
}), [tenantId]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeSetupGuide();
@@ -160,7 +168,14 @@ export function SetupGuideModal({ initialStep }: { initialStep: SetupStepId | nu
)}
{step === "validation" && <ValidationStep onContinue={() => goTo("validation")} />}
{step === "deploy" && <DeployStep config={config} />}
{step === "complete" && <CompleteStep />}
{step === "complete" && (
<CompleteStep
onRestart={() => {
trackEvent("setup_guide_opened", { source: "again", step: "settings" });
goTo("settings");
}}
/>
)}
{checklist?.dismissed && !checklist.done.deploy && canEdit && (
<RestoreLine tenantId={tenantId} getAccessToken={getAccessToken} />
@@ -396,7 +411,7 @@ function HardwareStep({ config, loadError, tenantId, canEdit, getAccessToken, on
</p>
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Accepted now</p>
<div className="flex flex-wrap gap-1.5">
{entries.length === 0 && <span className="text-sm text-red-600 dark:text-red-400">Nothing is accepted: every device would be rejected.</span>}
{entries.length === 0 && <span className="text-sm text-red-600 dark:text-red-400">Add at least one manufacturer.</span>}
{entries.map((entry) => (
<span key={entry} className={`${chip} pr-1 bg-blue-50 text-blue-900 border border-blue-200 dark:bg-blue-900/30 dark:text-blue-100 dark:border-blue-800`}>
{entry}
@@ -568,14 +583,14 @@ function DeployStep({ config }: { config: TenantConfiguration | null }) {
);
}
function CompleteStep() {
function CompleteStep({ onRestart }: { onRestart: () => void }) {
return (
<>
<div className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
Your tenant receives enrollments. Every setting the guide touched stays under Configuration.
</div>
<Footer>
<span />
<button type="button" className={linkButton} onClick={onRestart}>Go through the steps again</button>
<button type="button" className={primaryButton} onClick={closeSetupGuide}>Close</button>
</Footer>
</>
@@ -26,6 +26,7 @@ import { clearCachedAuthFetch } from '@/lib/cachedAuthFetch';
import { trackEvent } from '@/lib/appInsights';
import { seedDashboardFirstFetch } from '@/lib/dashboardSeed';
import { captureSignupConsentFromUrl, hasSignupConsent, stripSignupConsentParam } from '@/lib/signupConsent';
import { storedConfigHeaders } from '@/lib/storedConfigHint';
import { abandonViewAs, captureViewAsFromUrl, stripViewAsParams, viewAsHeaders } from '@/lib/viewAs';
import { peekPostLoginReturnUrl } from '@/lib/postLoginReturn';
import { DEFAULT_PORTAL_LANDING } from '@/lib/hostRouting';
@@ -228,7 +229,7 @@ const msalInitPromise = msalInstance
// behind it (consumed once by authenticatedFetch, see lib/dashboardSeed.ts).
seedDashboardFirstFetch(tokenResponse.accessToken, accounts[0].tenantId);
const res = await fetch(api.auth.me({ signupConsent: hasSignupConsent() }), {
headers: { 'Authorization': `Bearer ${tokenResponse.accessToken}`, ...viewAsHeaders() },
headers: { 'Authorization': `Bearer ${tokenResponse.accessToken}`, ...viewAsHeaders(), ...storedConfigHeaders() },
signal: AbortSignal.timeout(8000),
});
return res.ok ? (await res.json()) as AuthMeResponse : null;
@@ -472,6 +473,8 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
headers: {
'Authorization': `Bearer ${tokenResponse.accessToken}`,
...viewAsHeaders(),
// After the terms tick or a settings save, auth/me must not come from an older copy.
...storedConfigHeaders(),
},
signal: authMeController.signal,
});
@@ -0,0 +1,118 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../appInsights", () => ({ trackEvent: vi.fn() }));
vi.mock("../viewAs", () => ({ viewAsHeaders: vi.fn(() => ({})) }));
function response(status: number): Response {
return new Response(status === 204 ? null : "{}", { status, headers: { "Content-Type": "application/json" } });
}
const fetchMock = () => globalThis.fetch as ReturnType<typeof vi.fn>;
const token = () => vi.fn().mockResolvedValue("tok");
const hintOf = (call: number) => (fetchMock().mock.calls[call][1].headers as Headers).get("X-Tenant-Config");
/**
* A browser with localStorage and a freshly loaded fetch layer. The mark has an in-memory part
* that lives as long as the page, so every page load gets its own module instance.
*/
async function pageLoad(browser: Map<string, string>) {
vi.stubGlobal("window", {
localStorage: {
getItem: (key: string) => browser.get(key) ?? null,
setItem: (key: string, value: string) => void browser.set(key, value),
},
});
vi.resetModules();
return (await import("../authenticatedFetch")).authenticatedFetch;
}
describe("authenticatedFetch after a configuration write", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("asks for the stored row on every later call", async () => {
const authenticatedFetch = await pageLoad(new Map());
fetchMock().mockImplementation(() => Promise.resolve(response(200)));
await authenticatedFetch("/api/config/t1", token());
await authenticatedFetch("/api/config/t1/fields", token(), { method: "PATCH", body: "{}" });
await authenticatedFetch("/api/config/t1", token());
await authenticatedFetch("/api/sessions", token());
expect([hintOf(0), hintOf(1), hintOf(2), hintOf(3)]).toEqual([null, null, "stored", "stored"]);
});
it("leaves the cache mode alone: with no-store the browser would preflight every call again", async () => {
const authenticatedFetch = await pageLoad(new Map());
fetchMock().mockImplementation(() => Promise.resolve(response(200)));
await authenticatedFetch("/api/config/t1/fields", token(), { method: "PATCH", body: "{}" });
await authenticatedFetch("/api/config/t1", token());
await authenticatedFetch("/api/sessions", token(), { cache: "no-cache" });
expect(hintOf(1)).toBe("stored");
expect(fetchMock().mock.calls[1][1].cache).toBeUndefined();
expect(fetchMock().mock.calls[2][1].cache).toBe("no-cache");
});
it("still asks after a reload: the consent redirect returns to a fresh page within seconds", async () => {
const browser = new Map<string, string>();
fetchMock().mockImplementation(() => Promise.resolve(response(200)));
await (await pageLoad(browser))("/api/config/t1/setup-guide", token(), { method: "POST", body: "{}" });
await (await pageLoad(browser))("/api/config/t1", token());
expect(hintOf(1)).toBe("stored");
});
it("counts the terms confirmation: auth/me must not answer from an older copy", async () => {
const authenticatedFetch = await pageLoad(new Map());
fetchMock().mockResolvedValueOnce(response(204)).mockResolvedValueOnce(response(200));
await authenticatedFetch("/api/auth/dpa-acceptance", token(), { method: "POST" });
await authenticatedFetch("/api/auth/me", token());
expect(hintOf(1)).toBe("stored");
});
it("counts a write that needed the 401 retry", async () => {
const authenticatedFetch = await pageLoad(new Map());
fetchMock()
.mockResolvedValueOnce(response(401))
.mockResolvedValueOnce(response(200))
.mockResolvedValueOnce(response(200));
const getToken = vi.fn().mockResolvedValueOnce("stale").mockResolvedValue("fresh");
await authenticatedFetch("/api/config/t1/fields", getToken, { method: "PATCH", body: "{}" });
await authenticatedFetch("/api/config/t1", getToken);
expect(hintOf(2)).toBe("stored");
});
it("leaves no mark after a failed write", async () => {
const authenticatedFetch = await pageLoad(new Map());
fetchMock().mockResolvedValueOnce(response(400)).mockResolvedValueOnce(response(200));
await authenticatedFetch("/api/config/t1/fields", token(), { method: "PATCH", body: "{}" });
await authenticatedFetch("/api/config/t1", token());
expect(hintOf(1)).toBeNull();
});
it("leaves no mark after a write that is not a configuration write", async () => {
const authenticatedFetch = await pageLoad(new Map());
fetchMock().mockImplementation(() => Promise.resolve(response(200)));
await authenticatedFetch("/api/realtime/groups/join", token(), { method: "POST", body: "{}" });
await authenticatedFetch("/api/sessions/abc", token(), { method: "DELETE" });
await authenticatedFetch("/api/config/t1", token());
expect(hintOf(2)).toBeNull();
expect(fetchMock().mock.calls[2][1].cache).toBeUndefined();
});
});
@@ -3,8 +3,13 @@ import {
extractScriptRefsFromEvents,
fetchScriptDisplayNames,
lookupScriptDisplayName,
lookUpWithOneFollowUp,
mergeFollowUpNames,
formatRefKey,
scriptNamesLookupKey,
SCRIPT_DISPLAY_NAMES_CHUNK_SIZE,
type DisplayNamesByRefKey,
type ScriptNamesAnswer,
} from "../scriptDisplayNames";
function mockResponse(status: number, body: unknown = {}): Response {
@@ -76,7 +81,7 @@ describe("fetchScriptDisplayNames (POST shape)", () => {
it("returns empty when no refs are passed (no HTTP call)", async () => {
const getToken = vi.fn().mockResolvedValue("tok");
const result = await fetchScriptDisplayNames(TENANT, [], getToken);
const { names: result } = await fetchScriptDisplayNames(TENANT, [], getToken);
expect(result).toEqual({});
expect(globalThis.fetch).not.toHaveBeenCalled();
});
@@ -87,7 +92,7 @@ describe("fetchScriptDisplayNames (POST shape)", () => {
refs: { "Platform:abc": "Bootstrap.ps1", "Remediation:rem-1": null },
}));
const result = await fetchScriptDisplayNames(
const { names: result } = await fetchScriptDisplayNames(
TENANT,
[{ kind: "Platform", id: "abc" }, { kind: "Remediation", id: "rem-1" }],
getToken,
@@ -110,7 +115,7 @@ describe("fetchScriptDisplayNames (POST shape)", () => {
const getToken = vi.fn().mockResolvedValue("tok");
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockResponse(500, { error: "boom" }));
const result = await fetchScriptDisplayNames(
const { names: result } = await fetchScriptDisplayNames(
TENANT,
[{ kind: "Platform", id: "abc" }],
getToken,
@@ -138,7 +143,7 @@ describe("fetchScriptDisplayNames (POST shape)", () => {
.mockResolvedValueOnce(mockResponse(200, { refs: firstChunkResponse }))
.mockResolvedValueOnce(mockResponse(200, { refs: secondChunkResponse }));
const result = await fetchScriptDisplayNames(TENANT, refs, getToken);
const { names: result } = await fetchScriptDisplayNames(TENANT, refs, getToken);
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
expect(Object.keys(result)).toHaveLength(SCRIPT_DISPLAY_NAMES_CHUNK_SIZE + 1);
@@ -164,7 +169,7 @@ describe("fetchScriptDisplayNames (POST shape)", () => {
.mockResolvedValueOnce(mockResponse(200, { refs: { "Platform:id-0": "first-batch-ok" } }))
.mockResolvedValueOnce(mockResponse(503, { error: "throttled" }));
const result = await fetchScriptDisplayNames(TENANT, refs, getToken);
const { names: result } = await fetchScriptDisplayNames(TENANT, refs, getToken);
// Don't discard the work the first chunk already did.
expect(result["Platform:id-0"]).toBe("first-batch-ok");
@@ -188,7 +193,7 @@ describe("fetchScriptDisplayNames (POST shape)", () => {
// Must not throw — the merged-so-far map should come back, with the failed chunk's
// ids simply absent. Earlier behaviour bubbled the network error to the hook, which
// then discarded the successful first chunk too.
const result = await fetchScriptDisplayNames(TENANT, refs, getToken);
const { names: result } = await fetchScriptDisplayNames(TENANT, refs, getToken);
expect(result["Platform:id-0"]).toBe("first-batch-ok");
expect(result["Platform:id-200"]).toBeUndefined();
@@ -202,6 +207,138 @@ describe("fetchScriptDisplayNames (POST shape)", () => {
await expect(fetchScriptDisplayNames(TENANT, refs, getToken)).rejects.toBeInstanceOf(TokenExpiredError);
});
it("passes on the backend's timedOut flag and reports false when it is absent", async () => {
const getToken = vi.fn().mockResolvedValue("tok");
const refs = [{ kind: "Platform" as const, id: "abc" }];
(globalThis.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(mockResponse(200, { refs: { "Platform:abc": null }, timedOut: true }))
.mockResolvedValueOnce(mockResponse(200, { refs: { "Platform:abc": "Bootstrap.ps1" } }));
const cut = await fetchScriptDisplayNames(TENANT, refs, getToken);
const complete = await fetchScriptDisplayNames(TENANT, refs, getToken);
expect(cut.timedOut).toBe(true);
expect(complete.timedOut).toBe(false);
});
it("labels only the follow-up request with retry", async () => {
const getToken = vi.fn().mockResolvedValue("tok");
const refs = [{ kind: "Platform" as const, id: "abc" }];
(globalThis.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(mockResponse(200, { refs: {} }))
.mockResolvedValueOnce(mockResponse(200, { refs: {} }));
await fetchScriptDisplayNames(TENANT, refs, getToken);
await fetchScriptDisplayNames(TENANT, refs, getToken, true);
const bodies = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.map(c => JSON.parse(c[1].body as string));
expect(bodies[0]).toEqual({ refs: ["Platform:abc"] });
expect(bodies[1]).toEqual({ refs: ["Platform:abc"], retry: true });
});
});
describe("mergeFollowUpNames", () => {
it("adds what the follow-up resolved and keeps what only the first answer had", () => {
const first = { "Platform:a": "A", "Platform:b": null, "Remediation:c": "C" };
const followUp = { "Platform:a": "A", "Platform:b": "B", "Remediation:c": null };
expect(mergeFollowUpNames(first, followUp)).toEqual({
"Platform:a": "A",
"Platform:b": "B",
// The follow-up was cut short at another point and lost this name; it stays.
"Remediation:c": "C",
});
});
it("keeps every name when the follow-up comes back empty", () => {
const first = { "Platform:a": "A", "Platform:b": null };
expect(mergeFollowUpNames(first, {})).toEqual(first);
});
});
describe("lookUpWithOneFollowUp", () => {
const answer = (names: DisplayNamesByRefKey, timedOut: boolean): ScriptNamesAnswer => ({ names, timedOut });
/** `cancelDuring` makes the question stale while that step is in flight (a session switch, say). */
function run(answers: ScriptNamesAnswer[], cancelDuring?: "firstAsk" | "wait" | "followUp") {
const asks: boolean[] = [];
const published: DisplayNamesByRefKey[] = [];
let waits = 0;
let cancelled = false;
const done = lookUpWithOneFollowUp(
async isFollowUp => {
asks.push(isFollowUp);
if (cancelDuring === (isFollowUp ? "followUp" : "firstAsk")) cancelled = true;
return answers[asks.length - 1];
},
names => { published.push(names); },
async () => {
waits++;
if (cancelDuring === "wait") cancelled = true;
},
() => cancelled,
);
return { done, asks, published, waits: () => waits };
}
it("asks once when the answer is complete", async () => {
const r = run([answer({ "Platform:a": "A" }, false)]);
await r.done;
expect(r.asks).toEqual([false]);
expect(r.waits()).toBe(0);
expect(r.published).toEqual([{ "Platform:a": "A" }]);
});
it("waits and asks exactly once more when a time limit cut the answer short", async () => {
const r = run([
answer({ "Platform:a": "A", "Platform:b": null }, true),
answer({ "Platform:a": "A", "Platform:b": "B" }, false),
]);
await r.done;
expect(r.asks).toEqual([false, true]);
expect(r.waits()).toBe(1);
// The first names are on screen while the follow-up runs, then the full set replaces them.
expect(r.published).toEqual([
{ "Platform:a": "A", "Platform:b": null },
{ "Platform:a": "A", "Platform:b": "B" },
]);
});
it("stops after one follow-up even when that is cut short too", async () => {
const r = run([
answer({ "Platform:a": "A", "Platform:b": null }, true),
answer({ "Platform:a": null, "Platform:b": null }, true),
answer({ "Platform:a": "A", "Platform:b": "B" }, false),
]);
await r.done;
expect(r.asks).toEqual([false, true]);
expect(r.waits()).toBe(1);
// The follow-up knew less than the first answer: nothing already shown is taken away.
expect(r.published[1]).toEqual({ "Platform:a": "A", "Platform:b": null });
});
it("publishes nothing and sends no follow-up once the question is no longer current", async () => {
const answers = [answer({ "Platform:a": null }, true), answer({ "Platform:a": "A" }, false)];
const duringFirstAsk = run(answers, "firstAsk");
await duringFirstAsk.done;
expect(duringFirstAsk.published).toEqual([]);
expect(duringFirstAsk.asks).toEqual([false]);
const duringWait = run(answers, "wait");
await duringWait.done;
expect(duringWait.asks).toEqual([false]); // the follow-up is never sent
expect(duringWait.published).toEqual([{ "Platform:a": null }]);
const duringFollowUp = run(answers, "followUp");
await duringFollowUp.done;
expect(duringFollowUp.asks).toEqual([false, true]);
expect(duringFollowUp.published).toEqual([{ "Platform:a": null }]); // its answer is dropped
});
});
describe("lookupScriptDisplayName", () => {
@@ -231,6 +368,34 @@ describe("lookupScriptDisplayName", () => {
});
});
describe("scriptNamesLookupKey", () => {
const TENANT_ID = "11111111-1111-1111-1111-111111111111";
const REFS_KEY = "Platform:abc|Remediation:def";
it("is empty until the caller's event list is complete", () => {
// An empty key keeps the hook from fetching: the session page loads events page by page,
// and a lookup per page would repeat the request for every page that adds a script.
expect(scriptNamesLookupKey(TENANT_ID, REFS_KEY, false)).toBe("");
});
it("joins tenant and refset once the caller is ready", () => {
expect(scriptNamesLookupKey(TENANT_ID, REFS_KEY, true)).toBe(`${TENANT_ID}::${REFS_KEY}`);
});
it("is empty without a tenant or without script refs", () => {
expect(scriptNamesLookupKey(null, REFS_KEY, true)).toBe("");
expect(scriptNamesLookupKey(undefined, REFS_KEY, true)).toBe("");
expect(scriptNamesLookupKey("", REFS_KEY, true)).toBe("");
expect(scriptNamesLookupKey(TENANT_ID, "", true)).toBe("");
});
it("changes with the tenant and with the refset, so an answer never serves another question", () => {
const key = scriptNamesLookupKey(TENANT_ID, REFS_KEY, true);
expect(scriptNamesLookupKey("22222222-2222-2222-2222-222222222222", REFS_KEY, true)).not.toBe(key);
expect(scriptNamesLookupKey(TENANT_ID, "Platform:abc", true)).not.toBe(key);
});
});
describe("formatRefKey", () => {
it("formats canonical Kind:Id", () => {
expect(formatRefKey({ kind: "Platform", id: "abc" })).toBe("Platform:abc");
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { TenantConfiguration } from "@/lib/generated/wire-types.generated";
import {
deriveSetupChecklist,
parseSetupStep,
parseSetupTour,
recommendedPatch,
recommendedStatus,
@@ -138,3 +139,15 @@ describe("parseSetupTour", () => {
expect(parseSetupTour(null)).toBeNull();
});
});
describe("parseSetupStep", () => {
it("accepts the four steps a link may name", () => {
expect(["settings", "hardware", "validation", "deploy"].map(parseSetupStep)).toEqual(["settings", "hardware", "validation", "deploy"]);
});
it("ignores anything else, so a link can never open a state the guide does not have", () => {
for (const raw of ["complete", "Settings", "", "reset", "showme:timezone", null, undefined]) {
expect(parseSetupStep(raw)).toBeNull();
}
});
});

Some files were not shown because too many files have changed in this diff Show More