mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aeb2be6d64 | |||
| 0474223290 | |||
| 1bdb71be59 | |||
| ccf4f2e054 | |||
| d192a8e0fa | |||
| 06d92460ed | |||
| fc3900429c |
+14
-14
@@ -169,9 +169,9 @@
|
||||
{
|
||||
"patternId": "IME-DETECTED-INSTALLED-1",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DetectionState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"Installed\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DetectionState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}Installed\"{1,2}",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format).",
|
||||
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -236,9 +236,9 @@
|
||||
{
|
||||
"patternId": "IME-ENFORCEMENT-SUCCESS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":(?:\"\"\\w+\"\"|null),\"\"NewValue\"\":\"\"Success\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}\\w+\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's enforcement state transitions to Success in an Execution state report delta. OldValue is deliberately unpinned: besides InProgressDownloadCompleted it can be Error (retry succeeding in a later check-in), InProgressPendingReboot (post-reboot completion) or null (first write) — the previously pinned InProgressDownloadCompleted missed those legitimate success paths (IME 1.97 audit 2026-08-17).",
|
||||
"description": "Detects when an app's enforcement state transitions to Success in an Execution state report delta. OldValue is deliberately unpinned: besides InProgressDownloadCompleted it can be Error (retry succeeding in a later check-in), InProgressPendingReboot (post-reboot completion) or null (first write) — the previously pinned InProgressDownloadCompleted missed those legitimate success paths (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -255,9 +255,9 @@
|
||||
{
|
||||
"patternId": "IME-ERROR-DOWNLOAD",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"InProgress\"\",\"\"NewValue\"\":\"\"ErrorDownloadingContent\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgress\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}ErrorDownloadingContent\"{1,2}",
|
||||
"action": "updateStateError",
|
||||
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent.",
|
||||
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent. Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -285,12 +285,12 @@
|
||||
{
|
||||
"patternId": "IME-ERROR-REPORT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":(?:\"\"(?<from>\\w+)\"\"|null),\"\"NewValue\"\":\"\"(?<to>\\w+)\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
|
||||
"action": "updateStateError",
|
||||
"parameters": {
|
||||
"checkTo": "true"
|
||||
},
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17).",
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -415,25 +415,25 @@
|
||||
{
|
||||
"patternId": "IME-NOT-APPLICABLE-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"ApplicabilityState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"ScriptRequirementRuleNotMet\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}ApplicabilityState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}ScriptRequirementRuleNotMet\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false).",
|
||||
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-NOT-APPLICABLE-3",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DesiredState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"None\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}None\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's desired state is set to None (not targeted or not applicable).",
|
||||
"description": "Detects when an app's desired state is set to None (not targeted or not applicable). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-NOT-APPLICABLE-4",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DesiredState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"NotPresent\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}NotPresent\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required).",
|
||||
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-DETECTED-INSTALLED-1",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DetectionState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"Installed\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DetectionState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}Installed\"{1,2}",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format).",
|
||||
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ENFORCEMENT-SUCCESS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":(?:\"\"\\w+\"\"|null),\"\"NewValue\"\":\"\"Success\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}\\w+\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's enforcement state transitions to Success in an Execution state report delta. OldValue is deliberately unpinned: besides InProgressDownloadCompleted it can be Error (retry succeeding in a later check-in), InProgressPendingReboot (post-reboot completion) or null (first write) — the previously pinned InProgressDownloadCompleted missed those legitimate success paths (IME 1.97 audit 2026-08-17).",
|
||||
"description": "Detects when an app's enforcement state transitions to Success in an Execution state report delta. OldValue is deliberately unpinned: besides InProgressDownloadCompleted it can be Error (retry succeeding in a later check-in), InProgressPendingReboot (post-reboot completion) or null (first write) — the previously pinned InProgressDownloadCompleted missed those legitimate success paths (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ERROR-DOWNLOAD",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"InProgress\"\",\"\"NewValue\"\":\"\"ErrorDownloadingContent\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgress\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}ErrorDownloadingContent\"{1,2}",
|
||||
"action": "updateStateError",
|
||||
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent.",
|
||||
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent. Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ERROR-REPORT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":(?:\"\"(?<from>\\w+)\"\"|null),\"\"NewValue\"\":\"\"(?<to>\\w+)\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
|
||||
"action": "updateStateError",
|
||||
"parameters": { "checkTo": "true" },
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17).",
|
||||
"parameters": {
|
||||
"checkTo": "true"
|
||||
},
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-NOT-APPLICABLE-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"ApplicabilityState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"ScriptRequirementRuleNotMet\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}ApplicabilityState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}ScriptRequirementRuleNotMet\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false).",
|
||||
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-NOT-APPLICABLE-3",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DesiredState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"None\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}None\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's desired state is set to None (not targeted or not applicable).",
|
||||
"description": "Detects when an app's desired state is set to None (not targeted or not applicable). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-NOT-APPLICABLE-4",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DesiredState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"NotPresent\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}NotPresent\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required).",
|
||||
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
{
|
||||
EspSkipConfigurationProbe.TestOverride = _ => (null, null);
|
||||
EspSkipConfigurationProbe.FullTestOverride = _ => EspFirstSyncSnapshot.Empty;
|
||||
// Same reasoning for the IME-install-source probe: a dev machine with real
|
||||
// EnterpriseDesktopAppManagement CSP state would nondeterministically enrich
|
||||
// ime_agent_version payloads in adapter tests.
|
||||
ImeMsiInstallSourceProbe.TestOverride = (_, _) => ImeMsiInstallSource.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+70
-2
@@ -46,7 +46,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
Directory.CreateDirectory(SpoolFolder);
|
||||
}
|
||||
|
||||
public DiagnosticsPackageService Build()
|
||||
public DiagnosticsPackageService Build(System.Action<AgentConfiguration>? mutateConfig = null)
|
||||
{
|
||||
// BackendApiClient is required by the public ctor but BuildArchiveBytes
|
||||
// never touches it — construct with a throwaway HttpClient. Tests only
|
||||
@@ -61,8 +61,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
bootstrapToken: null,
|
||||
agentVersion: "0.0.0",
|
||||
logger: Logger);
|
||||
var cfg = Cfg();
|
||||
mutateConfig?.Invoke(cfg);
|
||||
return new DiagnosticsPackageService(
|
||||
Cfg(),
|
||||
cfg,
|
||||
Logger,
|
||||
apiClient,
|
||||
agentLogFolderOverride: LogsTmp.Path,
|
||||
@@ -131,6 +133,72 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
Assert.Contains("AgentMarkers/agent_clean_exit.marker", entries);
|
||||
}
|
||||
|
||||
// ============================================================ package manifest ====
|
||||
// The agent-log snapshot is zipped BEFORE packaging finishes, so packaging problems
|
||||
// are invisible in the uploaded archive's own log (field case: missing evtx in
|
||||
// sessions a11102f4/3ae7528b). The manifest travels INSIDE the ZIP instead.
|
||||
|
||||
private static string? ReadZipEntryText(byte[] bytes, string entryName)
|
||||
{
|
||||
using var ms = new MemoryStream(bytes);
|
||||
using var archive = new System.IO.Compression.ZipArchive(ms, System.IO.Compression.ZipArchiveMode.Read);
|
||||
var entry = archive.GetEntry(entryName);
|
||||
if (entry == null) return null;
|
||||
using var reader = new StreamReader(entry.Open());
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_always_writes_package_manifest_with_added_entries()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
File.WriteAllText(Path.Combine(rig.StateFolder, "snapshot.json"), "{}");
|
||||
|
||||
var bytes = rig.Build().BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var manifest = ReadZipEntryText(bytes, "package-manifest.txt");
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Contains("ADDED: AgentState/snapshot.json", manifest);
|
||||
// Empty standard folders (e.g. ImeLogs) surface as NO MATCH lines — the silent
|
||||
// nothing-found case is exactly what the manifest exists to make visible.
|
||||
Assert.Contains("NO MATCH:", manifest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_manifest_records_guard_blocked_additional_path()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var blockedPath = @"C:\Windows\System32\config\SYSTEM";
|
||||
|
||||
var bytes = rig.Build(cfg => cfg.DiagnosticsLogPaths.Add(
|
||||
new AutopilotMonitor.Shared.Models.DiagnosticsLogPath { Path = blockedPath }))
|
||||
.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var manifest = ReadZipEntryText(bytes, "package-manifest.txt");
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Contains($"BLOCKED (path guard): {blockedPath}", manifest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_manifest_records_users_path_blocked_even_in_unrestricted()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
// The test temp dir lives under C:\Users — blocked by the always-on privacy guard
|
||||
// even in unrestricted mode. The manifest must say so instead of staying silent.
|
||||
var configuredPath = Path.Combine(rig.DataFolder, "does-not-exist.evtx");
|
||||
|
||||
var bytes = rig.Build(cfg =>
|
||||
{
|
||||
cfg.UnrestrictedMode = true;
|
||||
cfg.DiagnosticsLogPaths.Add(new AutopilotMonitor.Shared.Models.DiagnosticsLogPath { Path = configuredPath });
|
||||
})
|
||||
.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var manifest = ReadZipEntryText(bytes, "package-manifest.txt");
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Contains($"BLOCKED (path guard): {configuredPath}", manifest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_excludes_top_level_session_id_and_bootstrap_config()
|
||||
{
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Selection/normalization logic of <see cref="ImeMsiInstallSourceProbe"/> — the registry
|
||||
/// walk itself is not unit-testable (live HKLM), so tests cover the pure pieces:
|
||||
/// <c>SelectBestMatch</c>, <c>VersionsEqual</c>, <c>IsImeMsiUrl</c>.
|
||||
/// </summary>
|
||||
public class ImeMsiInstallSourceProbeTests
|
||||
{
|
||||
private const string ImeProductCode = "{6A7DFC50-0395-4E1E-BF84-ED1404E72051}";
|
||||
private const string ImeUrl = "https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi";
|
||||
|
||||
private static ImeMsiInstallSourceProbe.MsiEntry Entry(
|
||||
string productCode = ImeProductCode,
|
||||
string version = "1.104.102.0",
|
||||
string url = ImeUrl)
|
||||
=> new ImeMsiInstallSourceProbe.MsiEntry(productCode, version, url);
|
||||
|
||||
// -- SelectBestMatch -----------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_exact_product_version_wins()
|
||||
{
|
||||
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry>
|
||||
{
|
||||
Entry(productCode: "{OTHER}", version: "9.9.9.9", url: "https://x/Other.msi"),
|
||||
Entry(),
|
||||
},
|
||||
"1.104.102.0");
|
||||
|
||||
Assert.True(result.HasData);
|
||||
Assert.True(result.MatchedByVersion);
|
||||
Assert.Equal(ImeProductCode, result.ProductCode);
|
||||
Assert.Equal("1.104.102.0", result.ProductVersion);
|
||||
Assert.Equal(ImeUrl, result.DownloadUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_normalized_version_matches_trailing_zero()
|
||||
{
|
||||
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry> { Entry(version: "1.104.102") },
|
||||
"1.104.102.0");
|
||||
|
||||
Assert.True(result.HasData);
|
||||
Assert.True(result.MatchedByVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_version_mismatch_falls_back_to_ime_msi_filename()
|
||||
{
|
||||
// IME self-updated between MSI enforcement and log line — versions drift, but the
|
||||
// enforced IntuneWindowsAgent.msi entry is still the install source.
|
||||
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry>
|
||||
{
|
||||
Entry(productCode: "{OTHER}", version: "5.0.0.0", url: "https://x/SomeLobApp.msi"),
|
||||
Entry(version: "1.103.100.0"),
|
||||
},
|
||||
"1.104.102.0");
|
||||
|
||||
Assert.True(result.HasData);
|
||||
Assert.False(result.MatchedByVersion);
|
||||
Assert.Equal("1.103.100.0", result.ProductVersion);
|
||||
Assert.Equal(ImeUrl, result.DownloadUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_version_match_beats_filename_match_on_other_entry()
|
||||
{
|
||||
// A stale IntuneWindowsAgent.msi row must not shadow the entry whose
|
||||
// ProductVersion equals the running build.
|
||||
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry>
|
||||
{
|
||||
Entry(productCode: "{STALE}", version: "1.103.100.0"),
|
||||
Entry(productCode: "{CURRENT}", version: "1.104.102.0", url: "https://y/IntuneWindowsAgent.msi"),
|
||||
},
|
||||
"1.104.102.0");
|
||||
|
||||
Assert.True(result.MatchedByVersion);
|
||||
Assert.Equal("{CURRENT}", result.ProductCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_no_plausible_entry_returns_empty()
|
||||
{
|
||||
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry>
|
||||
{
|
||||
Entry(productCode: "{OTHER}", version: "5.0.0.0", url: "https://x/SomeLobApp.msi"),
|
||||
},
|
||||
"1.104.102.0");
|
||||
|
||||
Assert.False(result.HasData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_empty_or_null_candidates_return_empty()
|
||||
{
|
||||
Assert.False(ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry>(), "1.104.102.0").HasData);
|
||||
Assert.False(ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
null, "1.104.102.0").HasData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_null_registry_values_do_not_throw()
|
||||
{
|
||||
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry>
|
||||
{
|
||||
new ImeMsiInstallSourceProbe.MsiEntry("{PARTIAL}", null, null),
|
||||
Entry(),
|
||||
},
|
||||
"1.104.102.0");
|
||||
|
||||
Assert.True(result.HasData);
|
||||
Assert.Equal(ImeProductCode, result.ProductCode);
|
||||
}
|
||||
|
||||
// -- VersionsEqual ---------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("1.104.102.0", "1.104.102.0", true)]
|
||||
[InlineData("1.104.102", "1.104.102.0", true)]
|
||||
[InlineData("1.104.102.0", "1.104.102", true)]
|
||||
[InlineData(" 1.104.102.0 ", "1.104.102.0", true)]
|
||||
[InlineData("1.104.102.0", "1.104.103.0", false)]
|
||||
[InlineData("1.104.102.0", "1.104.102.1", false)]
|
||||
[InlineData("1.104", "1.104.102.0", false)]
|
||||
[InlineData("abc", "abc", true)] // non-numeric: whole-string equality
|
||||
[InlineData("abc", "abd", false)] // non-numeric components never fuzzy-match
|
||||
[InlineData(null, "1.0", false)]
|
||||
[InlineData("1.0", null, false)]
|
||||
[InlineData("", "", false)]
|
||||
public void VersionsEqual_cases(string? a, string? b, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ImeMsiInstallSourceProbe.VersionsEqual(a, b));
|
||||
}
|
||||
|
||||
// -- IsImeMsiUrl -------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(ImeUrl, true)]
|
||||
[InlineData("https://x/intunewindowsagent.MSI", true)] // case-insensitive
|
||||
[InlineData("https://x/IntuneWindowsAgent.msi?sv=abc&sig=def", true)] // query ignored
|
||||
[InlineData("IntuneWindowsAgent.msi", true)] // bare filename
|
||||
[InlineData("https://x/NotTheAgent.msi", false)]
|
||||
[InlineData("https://x/IntuneWindowsAgent.msi.bak", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsImeMsiUrl_cases(string? url, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ImeMsiInstallSourceProbe.IsImeMsiUrl(url));
|
||||
}
|
||||
|
||||
// -- ScopedOverride ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ScopedOverride_restores_previous_override_on_dispose()
|
||||
{
|
||||
// Assembly default (TestAssemblyInit) forces Empty; a nested scope must restore it.
|
||||
Func<string, AgentLogger, ImeMsiInstallSource> before =
|
||||
ImeMsiInstallSourceProbe.TestOverride;
|
||||
using (new ImeMsiInstallSourceProbe.ScopedOverride((_, _) =>
|
||||
ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry> { Entry() }, "1.104.102.0")))
|
||||
{
|
||||
Assert.True(ImeMsiInstallSourceProbe.Read("1.104.102.0").HasData);
|
||||
}
|
||||
Assert.Same(before, ImeMsiInstallSourceProbe.TestOverride);
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -131,4 +131,117 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
trySchtasks: null!));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pins the restricted-token launch ordering introduced 2026-08-17: MDM LOB MSI installs
|
||||
/// (EnterpriseDesktopAppManagement CSP → msiexec chain) carry a SYSTEM token with
|
||||
/// SeTimeZonePrivilege / SeSystemEnvironmentPrivilege REMOVED, and WMI Win32_Process.Create
|
||||
/// duplicates the caller token onto the runtime — so on a stripped token the Scheduled Task
|
||||
/// (fresh full SYSTEM token) must launch first and WMI is only the degraded fallback. These
|
||||
/// tests fail loudly if a refactor silently restores WMI-first for the stripped-token case
|
||||
/// or drops the process verification after schtasks /Run.
|
||||
/// </summary>
|
||||
public sealed class InstallModeRestrictedTokenLaunchTests
|
||||
{
|
||||
[Fact]
|
||||
public void Schtasks_success_with_verified_pid_returns_schtasks_and_never_touches_wmi()
|
||||
{
|
||||
var wmiInvocations = 0;
|
||||
|
||||
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => 0,
|
||||
verifyRuntimePid: () => 4321,
|
||||
tryWmi: () => { wmiInvocations++; return new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 9999); });
|
||||
|
||||
Assert.Equal(AutopilotMonitor.Agent.V2.Program.RuntimeLaunchMethod.Schtasks, result.Method);
|
||||
Assert.Equal(4321, result.Pid);
|
||||
Assert.Equal(0, result.SchtasksExitCode);
|
||||
Assert.Equal(0, wmiInvocations);
|
||||
Assert.Contains("full SYSTEM token", result.Diagnostic);
|
||||
Assert.Contains("PID=4321", result.Diagnostic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Schtasks_queued_but_no_process_appears_falls_back_to_wmi()
|
||||
{
|
||||
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => 0,
|
||||
verifyRuntimePid: () => 0,
|
||||
tryWmi: () => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 777));
|
||||
|
||||
Assert.Equal(AutopilotMonitor.Agent.V2.Program.RuntimeLaunchMethod.Wmi, result.Method);
|
||||
Assert.Equal(777, result.Pid);
|
||||
Assert.Contains("no runtime process appeared", result.Diagnostic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Schtasks_failure_skips_verification_and_falls_back_to_wmi()
|
||||
{
|
||||
var verifyInvocations = 0;
|
||||
|
||||
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => 1,
|
||||
verifyRuntimePid: () => { verifyInvocations++; return 0; },
|
||||
tryWmi: () => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 555));
|
||||
|
||||
Assert.Equal(AutopilotMonitor.Agent.V2.Program.RuntimeLaunchMethod.Wmi, result.Method);
|
||||
Assert.Equal(0, verifyInvocations);
|
||||
Assert.Equal(1, result.SchtasksExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wmi_fallback_diagnostic_warns_that_the_runtime_stays_degraded()
|
||||
{
|
||||
// Support line: a restricted-token WMI launch means timezone auto-set / firmware
|
||||
// reads will fail for this runtime instance — the log must say so explicitly.
|
||||
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => 1,
|
||||
verifyRuntimePid: () => 0,
|
||||
tryWmi: () => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 555));
|
||||
|
||||
Assert.Contains("RESTRICTED TOKEN", result.Diagnostic);
|
||||
Assert.Contains("timezone", result.Diagnostic, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Both_paths_fail_returns_deferred_with_both_codes_captured()
|
||||
{
|
||||
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => 1,
|
||||
verifyRuntimePid: () => 0,
|
||||
tryWmi: () => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(2u, 0));
|
||||
|
||||
Assert.Equal(AutopilotMonitor.Agent.V2.Program.RuntimeLaunchMethod.Deferred, result.Method);
|
||||
Assert.Equal(0, result.Pid);
|
||||
Assert.Equal(2u, result.WmiReturnValue);
|
||||
Assert.Equal(1, result.SchtasksExitCode);
|
||||
Assert.Contains("BootTrigger", result.Diagnostic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wmi_returnvalue_zero_but_pid_zero_does_not_count_as_success()
|
||||
{
|
||||
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => 1,
|
||||
verifyRuntimePid: () => 0,
|
||||
tryWmi: () => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 0));
|
||||
|
||||
Assert.Equal(AutopilotMonitor.Agent.V2.Program.RuntimeLaunchMethod.Deferred, result.Method);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_delegates_are_rejected()
|
||||
{
|
||||
Func<int> zero = () => 0;
|
||||
Func<AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt> wmi =
|
||||
() => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 1);
|
||||
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(null!, zero, wmi));
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(zero, null!, wmi));
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(zero, zero, null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using AutopilotMonitor.Agent.V2;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Environment-stable pins for the token probe. Whether SeTimeZonePrivilege itself is
|
||||
/// held depends on the test runner's token, so it is NOT asserted here — only the two
|
||||
/// contracts that never vary: a privilege present in every Windows token reports held,
|
||||
/// and an unknown privilege name fails soft to "held" (the probe must never flip the
|
||||
/// launch ordering because of its own defect).
|
||||
/// </summary>
|
||||
public sealed class TokenPrivilegeProbeTests
|
||||
{
|
||||
[Fact]
|
||||
public void SeChangeNotifyPrivilege_is_held_in_every_token()
|
||||
{
|
||||
Assert.True(TokenPrivilegeProbe.IsPrivilegeHeld("SeChangeNotifyPrivilege"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_privilege_name_fails_soft_to_held()
|
||||
{
|
||||
Assert.True(TokenPrivilegeProbe.IsPrivilegeHeld("SeDefinitelyNotARealPrivilege"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
@@ -487,6 +488,70 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
Assert.Equal(2, f.InfoEvents(SharedEventTypes.ImeAgentVersion).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeAgentVersion_enriches_with_csp_install_source_when_probe_matches()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
using var _probe = new ImeMsiInstallSourceProbe.ScopedOverride((v, _) =>
|
||||
ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new[]
|
||||
{
|
||||
new ImeMsiInstallSourceProbe.MsiEntry(
|
||||
"{6A7DFC50-0395-4E1E-BF84-ED1404E72051}",
|
||||
"1.104.102.0",
|
||||
"https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi"),
|
||||
},
|
||||
v));
|
||||
|
||||
adapter.TriggerImeAgentVersionFromTest("1.104.102.0");
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ImeAgentVersion));
|
||||
Assert.Equal("1.104.102.0", info.Payload!["agentVersion"]);
|
||||
Assert.Equal(
|
||||
"https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi",
|
||||
info.Payload["msiDownloadUrl"]);
|
||||
Assert.Equal("{6A7DFC50-0395-4E1E-BF84-ED1404E72051}", info.Payload["msiProductCode"]);
|
||||
Assert.Equal("1.104.102.0", info.Payload["msiProductVersion"]);
|
||||
Assert.Equal("productVersion", info.Payload["msiMatchedBy"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeAgentVersion_without_csp_match_omits_msi_keys()
|
||||
{
|
||||
// Assembly default (TestAssemblyInit) forces the probe to Empty.
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
adapter.TriggerImeAgentVersionFromTest("1.104.102.0");
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ImeAgentVersion));
|
||||
Assert.False(info.Payload!.ContainsKey("msiDownloadUrl"));
|
||||
Assert.False(info.Payload.ContainsKey("msiProductCode"));
|
||||
Assert.False(info.Payload.ContainsKey("msiProductVersion"));
|
||||
Assert.False(info.Payload.ContainsKey("msiMatchedBy"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeAgentVersion_probe_result_is_cached_per_version()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
int probeCalls = 0;
|
||||
using var _probe = new ImeMsiInstallSourceProbe.ScopedOverride((_, _) =>
|
||||
{
|
||||
probeCalls++;
|
||||
return ImeMsiInstallSource.Empty;
|
||||
});
|
||||
|
||||
adapter.TriggerImeAgentVersionFromTest("1.104.102.0");
|
||||
adapter.TriggerImeAgentVersionFromTest("1.104.102.0");
|
||||
adapter.TriggerImeAgentVersionFromTest("1.105.0.0");
|
||||
|
||||
Assert.Equal(2, probeCalls); // one read per distinct version, not per event
|
||||
Assert.Equal(3, f.InfoEvents(SharedEventTypes.ImeAgentVersion).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoTelemetry_emits_do_telemetry_info_event_with_do_fields()
|
||||
{
|
||||
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Session 2026-08-17 — resolves where the running IME build was downloaded from.
|
||||
/// The IME is installed through the EnterpriseDesktopAppManagement CSP, which documents
|
||||
/// every MSI it enforces under
|
||||
/// <c>HKLM\SOFTWARE\Microsoft\EnterpriseDesktopAppManagement\<SID>\MSI\{ProductCode}</c>
|
||||
/// (values: <c>CurrentDownloadUrl</c>, <c>ProductVersion</c>, <c>ProductCode</c>, …).
|
||||
/// Given the IME version parsed from the IME log, this probe finds the matching MSI entry
|
||||
/// and returns its <c>CurrentDownloadUrl</c> so the <c>ime_agent_version</c> event can say
|
||||
/// which CDN/AFD endpoint delivered exactly that build (e.g.
|
||||
/// <c>imeswdb-afd-secondary.manage.microsoft.com</c>).
|
||||
/// <para>
|
||||
/// Matching: first by normalized <c>ProductVersion</c> equality (trailing <c>.0</c>
|
||||
/// components ignored), then — because the version line in the log and the enforced MSI can
|
||||
/// drift apart around an IME self-update — by download-URL filename
|
||||
/// <c>IntuneWindowsAgent.msi</c>. The match kind is surfaced so consumers know whether the
|
||||
/// URL is version-authoritative.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Fail-soft like <see cref="EspTrackingInfoProbe"/>: any failure is Debug-logged and yields
|
||||
/// <see cref="ImeMsiInstallSource.Empty"/>; a missing CSP key (no MDM-enforced MSIs yet) is
|
||||
/// not an error.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class ImeMsiInstallSourceProbe
|
||||
{
|
||||
internal const string CspRootKeyPath = @"SOFTWARE\Microsoft\EnterpriseDesktopAppManagement";
|
||||
|
||||
private const string ImeMsiFileName = "IntuneWindowsAgent.msi";
|
||||
|
||||
/// <summary>
|
||||
/// Test seam — when non-null, <see cref="Read"/> delegates to this func instead of
|
||||
/// touching the live registry. Use <see cref="ScopedOverride"/> to guarantee cleanup.
|
||||
/// </summary>
|
||||
internal static Func<string, AgentLogger, ImeMsiInstallSource> TestOverride;
|
||||
|
||||
/// <summary>
|
||||
/// Finds the CSP MSI entry for <paramref name="imeVersion"/> (the version string parsed
|
||||
/// from the IME log). Returns <see cref="ImeMsiInstallSource.Empty"/> when the CSP key is
|
||||
/// missing, unreadable, or holds no plausible entry.
|
||||
/// </summary>
|
||||
/// <param name="imeVersion">IME version from the log, e.g. <c>1.104.102.0</c>.</param>
|
||||
/// <param name="logger">Optional logger — Debug-level trace only; no warn/error.</param>
|
||||
public static ImeMsiInstallSource Read(string imeVersion, AgentLogger logger = null)
|
||||
{
|
||||
var probe = TestOverride;
|
||||
if (probe != null) return probe(imeVersion, logger);
|
||||
|
||||
try
|
||||
{
|
||||
var candidates = new List<MsiEntry>();
|
||||
using (var rootKey = Registry.LocalMachine.OpenSubKey(CspRootKeyPath))
|
||||
{
|
||||
if (rootKey == null)
|
||||
{
|
||||
logger?.Debug("ImeMsiInstallSourceProbe: EnterpriseDesktopAppManagement registry key not found");
|
||||
return ImeMsiInstallSource.Empty;
|
||||
}
|
||||
|
||||
// Layout: <SID>\MSI\{ProductCode}. The IME sits under the device-scope
|
||||
// zeros-SID, but enumerate all SIDs — layout is the same for user scope.
|
||||
foreach (var sid in rootKey.GetSubKeyNames())
|
||||
{
|
||||
using (var msiKey = rootKey.OpenSubKey(sid + @"\MSI"))
|
||||
{
|
||||
if (msiKey == null) continue;
|
||||
foreach (var productCode in msiKey.GetSubKeyNames())
|
||||
{
|
||||
using (var productKey = msiKey.OpenSubKey(productCode))
|
||||
{
|
||||
if (productKey == null) continue;
|
||||
candidates.Add(new MsiEntry(
|
||||
productCode: productCode,
|
||||
productVersion: productKey.GetValue("ProductVersion") as string,
|
||||
downloadUrl: productKey.GetValue("CurrentDownloadUrl") as string));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SelectBestMatch(candidates, imeVersion, logger);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Debug($"ImeMsiInstallSourceProbe: read threw: {ex.Message}");
|
||||
return ImeMsiInstallSource.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure selection logic (internal for unit tests): exact/normalized ProductVersion match
|
||||
/// wins; otherwise the first entry whose download URL points at
|
||||
/// <c>IntuneWindowsAgent.msi</c>.
|
||||
/// </summary>
|
||||
internal static ImeMsiInstallSource SelectBestMatch(
|
||||
IReadOnlyList<MsiEntry> candidates, string imeVersion, AgentLogger logger = null)
|
||||
{
|
||||
if (candidates == null || candidates.Count == 0) return ImeMsiInstallSource.Empty;
|
||||
|
||||
foreach (var entry in candidates)
|
||||
{
|
||||
if (VersionsEqual(entry.ProductVersion, imeVersion))
|
||||
{
|
||||
logger?.Debug($"ImeMsiInstallSourceProbe: matched by ProductVersion {entry.ProductVersion} ({entry.ProductCode})");
|
||||
return new ImeMsiInstallSource(entry, matchedByVersion: true);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entry in candidates)
|
||||
{
|
||||
if (IsImeMsiUrl(entry.DownloadUrl))
|
||||
{
|
||||
logger?.Debug($"ImeMsiInstallSourceProbe: matched by URL filename, registry version {entry.ProductVersion ?? "?"} vs log {imeVersion ?? "?"} ({entry.ProductCode})");
|
||||
return new ImeMsiInstallSource(entry, matchedByVersion: false);
|
||||
}
|
||||
}
|
||||
|
||||
logger?.Debug($"ImeMsiInstallSourceProbe: no CSP MSI entry matches IME version {imeVersion ?? "?"} ({candidates.Count} candidates)");
|
||||
return ImeMsiInstallSource.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Component-wise numeric compare with trailing-zero tolerance, so
|
||||
/// <c>1.104.102</c> == <c>1.104.102.0</c>. Non-numeric components fall back to an
|
||||
/// ordinal-ignore-case string compare of the whole version.
|
||||
/// </summary>
|
||||
internal static bool VersionsEqual(string a, string b)
|
||||
{
|
||||
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false;
|
||||
if (string.Equals(a.Trim(), b.Trim(), StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
var pa = a.Trim().Split('.');
|
||||
var pb = b.Trim().Split('.');
|
||||
var len = Math.Max(pa.Length, pb.Length);
|
||||
for (var i = 0; i < len; i++)
|
||||
{
|
||||
var sa = i < pa.Length ? pa[i] : "0";
|
||||
var sb = i < pb.Length ? pb[i] : "0";
|
||||
if (!int.TryParse(sa, out var na) || !int.TryParse(sb, out var nb)) return false;
|
||||
if (na != nb) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>URL filename equals <c>IntuneWindowsAgent.msi</c> (query string ignored).</summary>
|
||||
internal static bool IsImeMsiUrl(string url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) return false;
|
||||
var path = url;
|
||||
var query = path.IndexOf('?');
|
||||
if (query >= 0) path = path.Substring(0, query);
|
||||
var slash = path.LastIndexOf('/');
|
||||
var fileName = slash >= 0 ? path.Substring(slash + 1) : path;
|
||||
return string.Equals(fileName, ImeMsiFileName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Raw candidate row from one <c>MSI\{ProductCode}</c> subkey.</summary>
|
||||
internal readonly struct MsiEntry
|
||||
{
|
||||
public MsiEntry(string productCode, string productVersion, string downloadUrl)
|
||||
{
|
||||
ProductCode = productCode;
|
||||
ProductVersion = productVersion;
|
||||
DownloadUrl = downloadUrl;
|
||||
}
|
||||
|
||||
public string ProductCode { get; }
|
||||
public string ProductVersion { get; }
|
||||
public string DownloadUrl { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposable scope that sets <see cref="TestOverride"/> for the lifetime of the scope
|
||||
/// and restores the previous value on Dispose. Nestable. Internal test-only helper.
|
||||
/// </summary>
|
||||
internal sealed class ScopedOverride : IDisposable
|
||||
{
|
||||
private readonly Func<string, AgentLogger, ImeMsiInstallSource> _previous;
|
||||
private int _disposed;
|
||||
|
||||
public ScopedOverride(Func<string, AgentLogger, ImeMsiInstallSource> probe)
|
||||
{
|
||||
_previous = TestOverride;
|
||||
TestOverride = probe;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (System.Threading.Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
TestOverride = _previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolved install source of the running IME build. <see cref="HasData"/> is <c>false</c>
|
||||
/// when no CSP MSI entry matched. <see cref="MatchedByVersion"/> distinguishes a
|
||||
/// ProductVersion match (URL is authoritative for the reported build) from the
|
||||
/// filename fallback (URL is the currently enforced IME MSI, version may have drifted).
|
||||
/// </summary>
|
||||
internal readonly struct ImeMsiInstallSource
|
||||
{
|
||||
public static readonly ImeMsiInstallSource Empty = default;
|
||||
|
||||
internal ImeMsiInstallSource(ImeMsiInstallSourceProbe.MsiEntry entry, bool matchedByVersion)
|
||||
{
|
||||
HasData = true;
|
||||
ProductCode = entry.ProductCode;
|
||||
ProductVersion = entry.ProductVersion;
|
||||
DownloadUrl = entry.DownloadUrl;
|
||||
MatchedByVersion = matchedByVersion;
|
||||
}
|
||||
|
||||
public bool HasData { get; }
|
||||
|
||||
/// <summary>Braced MSI ProductCode subkey name, e.g. <c>{6a7dfc50-0395-4e1e-bf84-ed1404e72051}</c>.</summary>
|
||||
public string ProductCode { get; }
|
||||
|
||||
/// <summary><c>ProductVersion</c> registry value (may be null on partial writes).</summary>
|
||||
public string ProductVersion { get; }
|
||||
|
||||
/// <summary><c>CurrentDownloadUrl</c> registry value (may be null on partial writes).</summary>
|
||||
public string DownloadUrl { get; }
|
||||
|
||||
/// <summary><c>true</c> = matched via ProductVersion; <c>false</c> = URL-filename fallback.</summary>
|
||||
public bool MatchedByVersion { get; }
|
||||
}
|
||||
}
|
||||
+100
-12
@@ -346,11 +346,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
// Chicken-and-egg guard: the agent-log snapshot is zipped BEFORE packaging finishes, so
|
||||
// packaging problems (blocked paths, missing files, failed copies) are invisible in the
|
||||
// uploaded archive's own log. This manifest records every packaging decision and travels
|
||||
// INSIDE the ZIP (field case: sessions a11102f4 / 3ae7528b, missing evtx undiagnosable).
|
||||
private StringBuilder _manifest;
|
||||
|
||||
private void ManifestLine(string text)
|
||||
{
|
||||
_manifest?.AppendLine($"[{DateTime.UtcNow:HH:mm:ss.fff}Z] {text}");
|
||||
}
|
||||
|
||||
// Builds the diagnostics ZIP body in-memory. Extracted from CreateAndUploadAsync so
|
||||
// tests can assert archive contents without going through the upload path.
|
||||
internal virtual byte[] BuildArchiveBytes(bool? enrollmentSucceeded)
|
||||
{
|
||||
var tracker = new BudgetTracker(Budget);
|
||||
_manifest = new StringBuilder();
|
||||
using (var ms = new MemoryStream())
|
||||
{
|
||||
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
|
||||
@@ -394,12 +406,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (!DiagnosticsPathGuards.IsDiagnosticsPathAllowed(entry.Path, _configuration.UnrestrictedMode, userProfilePath))
|
||||
{
|
||||
_logger.Warning($"Diagnostics path blocked by guard: {entry.Path}");
|
||||
ManifestLine($"BLOCKED (path guard): {entry.Path}");
|
||||
continue;
|
||||
}
|
||||
var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path);
|
||||
if (expandedPath == null)
|
||||
{
|
||||
_logger.Warning($"Diagnostics path skipped (no user session for token): {entry.Path}");
|
||||
ManifestLine($"SKIPPED (no user session for token): {entry.Path}");
|
||||
continue;
|
||||
}
|
||||
var folder = Path.GetDirectoryName(expandedPath);
|
||||
@@ -407,9 +421,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (string.IsNullOrEmpty(folder)) continue;
|
||||
if (string.IsNullOrEmpty(pattern) || !pattern.Contains(".")) pattern = "*";
|
||||
var zipFolder = $"AdditionalLogs/{Path.GetFileName(folder)}";
|
||||
ManifestLine($"CONFIGURED PATH: '{entry.Path}' -> folder='{folder}' pattern='{pattern}'");
|
||||
AddLogFiles(archive, folder, zipFolder, pattern, tracker, entry.IncludeSubfolders);
|
||||
}
|
||||
|
||||
// Packaging manifest — always written, even when empty of problems, so its
|
||||
// absence itself is meaningful (archive predates the feature).
|
||||
try
|
||||
{
|
||||
var manifestEntry = archive.CreateEntry("package-manifest.txt", CompressionLevel.Optimal);
|
||||
using (var writer = new StreamWriter(manifestEntry.Open()))
|
||||
writer.Write(_manifest.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to write package manifest: {ex.Message}");
|
||||
}
|
||||
|
||||
// Always last: emit truncation report only if any file was skipped.
|
||||
if (tracker.HasSkips)
|
||||
WriteTruncatedMarker(archive, tracker);
|
||||
@@ -483,6 +511,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (!Directory.Exists(sourceFolder))
|
||||
{
|
||||
_logger.Debug($"Log folder not found, skipping: {sourceFolder}");
|
||||
ManifestLine($"FOLDER MISSING: {sourceFolder} (pattern '{searchPattern}')");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -494,9 +523,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to enumerate log files in {sourceFolder}: {ex.Message}");
|
||||
ManifestLine($"ENUMERATION FAILED: {sourceFolder} (pattern '{searchPattern}'): {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.Count == 0)
|
||||
ManifestLine($"NO MATCH: {sourceFolder} pattern '{searchPattern}'");
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
@@ -514,6 +547,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Skipping reparse-point file: {file}");
|
||||
tracker.RecordSkip(file, "reparse", 0);
|
||||
ManifestLine($"SKIPPED (reparse point): {file}");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -534,14 +568,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (file.EndsWith(".evtx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
tempExport = TryExportEventLogChannel(file);
|
||||
if (tempExport == null)
|
||||
if (tempExport != null)
|
||||
{
|
||||
tracker.RecordSkip(file, "evtx-export", length);
|
||||
continue;
|
||||
streamSource = tempExport;
|
||||
try { length = new FileInfo(tempExport).Length; }
|
||||
catch { /* keep original length estimate */ }
|
||||
}
|
||||
else
|
||||
{
|
||||
// Export failed (channel not resolvable / wevtutil error) — fall through
|
||||
// to the raw copy. Inactive or archived evtx files are not locked, so
|
||||
// the plain FileStream often still succeeds; if the channel IS active
|
||||
// and locked, the existing per-file catch logs the failure.
|
||||
_logger.Warning($"Event log export unavailable for {file} — attempting raw copy.");
|
||||
ManifestLine($"EVTX EXPORT FAILED (falling back to raw copy): {file}");
|
||||
}
|
||||
streamSource = tempExport;
|
||||
try { length = new FileInfo(tempExport).Length; }
|
||||
catch { /* keep original length estimate */ }
|
||||
}
|
||||
|
||||
try
|
||||
@@ -550,18 +591,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Skipping oversized file ({length} bytes > {tracker.Budget.MaxSingleFileBytes} cap): {file}");
|
||||
tracker.RecordSkip(file, "size", length);
|
||||
ManifestLine($"SKIPPED (single-file cap, {length} bytes): {file}");
|
||||
continue;
|
||||
}
|
||||
if (tracker.WouldExceedCount())
|
||||
{
|
||||
_logger.Warning($"Skipping file (file-count cap {tracker.Budget.MaxFileCount} reached): {file}");
|
||||
tracker.RecordSkip(file, "count", length);
|
||||
ManifestLine($"SKIPPED (file-count cap): {file}");
|
||||
continue;
|
||||
}
|
||||
if (tracker.WouldExceedTotal(length))
|
||||
{
|
||||
_logger.Warning($"Skipping file (total-bytes cap {tracker.Budget.MaxTotalUncompressedBytes} reached): {file}");
|
||||
tracker.RecordSkip(file, "total", length);
|
||||
ManifestLine($"SKIPPED (total-bytes cap): {file}");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -580,6 +624,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
|
||||
tracker.RecordIncluded(length);
|
||||
_logger.Debug($"Added to diagnostics package: {entryName} ({length / 1024} KB)");
|
||||
ManifestLine($"ADDED: {entryName} ({length} bytes)");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -592,23 +637,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to add log file to diagnostics package: {file} - {ex.Message}");
|
||||
ManifestLine($"FAILED (copy): {file}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the event-log CHANNEL that writes to the given .evtx file by matching the
|
||||
/// registered channels' LogFilePath — never by guessing a naming convention: most
|
||||
/// channels follow the "%4 encodes '/'" file naming, but not all do (field case
|
||||
/// 2026-08-17: Microsoft-Autopilot-BootstrapperAgent/BootstrapperAgentServiceLogProvider
|
||||
/// writes plain "BootstrapperAgentServiceLogProvider.evtx"). Falls back to the %4
|
||||
/// convention only when no registered channel claims the file.
|
||||
/// </summary>
|
||||
internal static string ResolveChannelForEvtxFile(string evtxPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var target = Path.GetFullPath(evtxPath);
|
||||
using (var session = new System.Diagnostics.Eventing.Reader.EventLogSession())
|
||||
{
|
||||
foreach (var logName in session.GetLogNames())
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = new System.Diagnostics.Eventing.Reader.EventLogConfiguration(logName, session);
|
||||
var logFile = config.LogFilePath;
|
||||
if (string.IsNullOrEmpty(logFile)) continue;
|
||||
var expanded = Path.GetFullPath(Environment.ExpandEnvironmentVariables(logFile));
|
||||
if (string.Equals(expanded, target, StringComparison.OrdinalIgnoreCase))
|
||||
return logName;
|
||||
}
|
||||
catch { /* individual channel unreadable — keep scanning */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* enumeration unavailable — fall back to convention */ }
|
||||
|
||||
return Path.GetFileNameWithoutExtension(evtxPath).Replace("%4", "/");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports the event-log channel behind a winevt .evtx file to a temp copy via
|
||||
/// <c>wevtutil epl</c>. Channel name is derived from the file name (%4 decodes to '/',
|
||||
/// e.g. "Microsoft-Autopilot-BootstrapperAgent%4BootstrapperAgentServiceLogProvider.evtx"
|
||||
/// → "Microsoft-Autopilot-BootstrapperAgent/BootstrapperAgentServiceLogProvider").
|
||||
/// Returns the temp file path (caller deletes) or null when the export failed.
|
||||
/// <c>wevtutil epl</c> (active channels hold their file exclusively locked, so a raw
|
||||
/// copy fails). Channel resolution happens against the registered channels — see
|
||||
/// <see cref="ResolveChannelForEvtxFile"/>. Returns the temp file path (caller deletes)
|
||||
/// or null when the export failed.
|
||||
/// </summary>
|
||||
private string TryExportEventLogChannel(string evtxPath)
|
||||
{
|
||||
string tempPath = null;
|
||||
try
|
||||
{
|
||||
var channel = Path.GetFileNameWithoutExtension(evtxPath).Replace("%4", "/");
|
||||
var channel = ResolveChannelForEvtxFile(evtxPath);
|
||||
tempPath = Path.Combine(Path.GetTempPath(), $"am-evtx-{Guid.NewGuid():N}.evtx");
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
@@ -625,6 +706,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
try { process.Kill(); } catch { }
|
||||
_logger.Warning($"wevtutil export timed out for channel '{channel}'.");
|
||||
ManifestLine($"EVTX EXPORT TIMEOUT: channel '{channel}' ({evtxPath})");
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
return null;
|
||||
}
|
||||
@@ -632,11 +714,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
var stderr = process.StandardError.ReadToEnd();
|
||||
_logger.Warning($"wevtutil export failed for channel '{channel}' (exit {process.ExitCode}): {stderr}");
|
||||
ManifestLine($"EVTX EXPORT ERROR: channel '{channel}' exit {process.ExitCode}: {stderr}");
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return File.Exists(tempPath) ? tempPath : null;
|
||||
if (File.Exists(tempPath))
|
||||
{
|
||||
ManifestLine($"EVTX EXPORTED: channel '{channel}' -> {evtxPath}");
|
||||
return tempPath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
@@ -97,6 +98,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
// bootstrap marker line. Lets MCP report "which device runs which bootstrap version".
|
||||
private bool _bootstrapDetectedPosted;
|
||||
|
||||
// Cached EnterpriseDesktopAppManagement-CSP lookup for the IME install source — the
|
||||
// version event fires more than once per session (V1 parity, no dedup) and the registry
|
||||
// answer is stable per version, so the probe runs once per distinct version string.
|
||||
private string? _msiSourceCachedForVersion;
|
||||
private ImeMsiInstallSource _msiSourceCache;
|
||||
|
||||
// One-shot: historic script replay detected (a previous enrollment's IME log content
|
||||
// surviving on disk) — its script events are suppressed, and a single
|
||||
// historic_ime_replay_detected Info event explains the gap in the timeline.
|
||||
@@ -864,6 +871,19 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
var patternId = _tracker.LastMatchedPatternId;
|
||||
if (!string.IsNullOrEmpty(patternId))
|
||||
data["patternId"] = patternId!;
|
||||
|
||||
// The EnterpriseDesktopAppManagement CSP records where it downloaded the IME MSI
|
||||
// from — attach that install source so the event answers "which endpoint delivered
|
||||
// exactly this build" (helps attribute bad builds to a CDN/AFD rollout ring).
|
||||
var source = ResolveMsiInstallSource(version);
|
||||
if (source.HasData)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(source.DownloadUrl)) data["msiDownloadUrl"] = source.DownloadUrl;
|
||||
if (!string.IsNullOrEmpty(source.ProductCode)) data["msiProductCode"] = source.ProductCode;
|
||||
if (!string.IsNullOrEmpty(source.ProductVersion)) data["msiProductVersion"] = source.ProductVersion;
|
||||
data["msiMatchedBy"] = source.MatchedByVersion ? "productVersion" : "fileName";
|
||||
}
|
||||
|
||||
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
|
||||
|
||||
_post.Emit(
|
||||
@@ -874,7 +894,17 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
occurredAtUtc: now);
|
||||
|
||||
// PR3-D4: fire-once and useful for compatibility-debugging — INFO level.
|
||||
_logger?.Info($"ImeAdapter: IME agent version detected: {version}");
|
||||
_logger?.Info(source.HasData && !string.IsNullOrEmpty(source.DownloadUrl)
|
||||
? $"ImeAdapter: IME agent version detected: {version} (CSP install source: {source.DownloadUrl})"
|
||||
: $"ImeAdapter: IME agent version detected: {version}");
|
||||
}
|
||||
|
||||
private ImeMsiInstallSource ResolveMsiInstallSource(string version)
|
||||
{
|
||||
if (_msiSourceCachedForVersion == version) return _msiSourceCache;
|
||||
_msiSourceCache = ImeMsiInstallSourceProbe.Read(version, _logger);
|
||||
_msiSourceCachedForVersion = version;
|
||||
return _msiSourceCache;
|
||||
}
|
||||
|
||||
private void EmitDoTelemetry(AppPackageState app, DateTime? sourceTimestampUtc)
|
||||
|
||||
@@ -172,12 +172,13 @@ namespace AutopilotMonitor.Agent.V2
|
||||
|
||||
logger.Info($"Scheduled Task '{taskName}' created/updated successfully.");
|
||||
|
||||
// Two-tier runtime-launch fallback chain:
|
||||
// Two-tier runtime-launch fallback chain (order is token-aware, see below):
|
||||
//
|
||||
// 1. WMI Win32_Process.Create (first choice). Spawns the process from
|
||||
// the WBEM service context, outside the install-mode process tree
|
||||
// and outside Task Scheduler's queue (which defers /Run on battery
|
||||
// / OOBE — observed WinPE 2026-05-04, project_v2_install_runtime_handoff.md).
|
||||
// 1. WMI Win32_Process.Create (first choice on a full token). Spawns the
|
||||
// process from the WBEM service context, outside the install-mode
|
||||
// process tree and outside Task Scheduler's queue (which defers /Run
|
||||
// on battery / OOBE — observed WinPE 2026-05-04,
|
||||
// project_v2_install_runtime_handoff.md).
|
||||
// 2. schtasks /Run on the registered BootTrigger task (fallback).
|
||||
// Used when WMI is blocked, typically Defender ASR rule
|
||||
// d1e49aac-8f56-4280-b9ba-993a6d77406c ('Block process creations
|
||||
@@ -190,6 +191,18 @@ namespace AutopilotMonitor.Agent.V2
|
||||
// the runtime via the Task Scheduler service — different
|
||||
// ATT&CK signature than either WMI or a manual schtasks /Run.
|
||||
//
|
||||
// Token-aware ordering (2026-08-17): WMI duplicates the CALLER's token onto
|
||||
// the new process. MDM-originated install chains (Bootstrap MSI via
|
||||
// EnterpriseDesktopAppManagement CSP → msiexec) carry a SYSTEM token with
|
||||
// privileges REMOVED (SeTimeZonePrivilege, SeSystemEnvironmentPrivilege) —
|
||||
// a WMI-launched runtime then runs degraded for its whole life (tzutil
|
||||
// exit 5, firmware reads privilege_denied). The Task Scheduler service
|
||||
// starts the task with a fresh full SYSTEM token, so on a stripped token
|
||||
// the order flips: schtasks /Run first (verified via process poll, since
|
||||
// /Run only reports "queued"), WMI as degraded-but-alive fallback. The
|
||||
// battery/OOBE queue-defer that motivated WMI-first is mitigated by the
|
||||
// hardened task XML (PR2: battery limits off, StartWhenAvailable, PT0S).
|
||||
//
|
||||
// Critical contract: --install returns 0 if deployment + task creation
|
||||
// succeeded, even if both immediate launch paths failed. Returning 1
|
||||
// here would make the bootstrap script throw, the next IME run would
|
||||
@@ -197,11 +210,27 @@ namespace AutopilotMonitor.Agent.V2
|
||||
// sit without a runtime until manual intervention. The bootstrap
|
||||
// script's Get-Process probe is the canonical "no runtime came up"
|
||||
// signal — see Install-AutopilotMonitor.ps1 ~line 280.
|
||||
var wmi = TryStartRuntimeViaWmi(targetExePath, logger);
|
||||
var launch = DecideRuntimeLaunchOutcome(
|
||||
wmiReturnValue: wmi.ReturnValue,
|
||||
wmiPid: wmi.Pid,
|
||||
trySchtasks: () => TryStartRuntimeViaSchtasks(taskName, logger));
|
||||
RuntimeLaunchResult launch;
|
||||
if (!TokenPrivilegeProbe.IsPrivilegeHeld(TokenPrivilegeProbe.TimeZonePrivilege))
|
||||
{
|
||||
logger.Warning(
|
||||
"Install-mode process token does not hold SeTimeZonePrivilege — privilege-stripped " +
|
||||
"caller chain (typical: MDM LOB MSI install). WMI Win32_Process.Create would duplicate " +
|
||||
"this token onto the runtime, so the Scheduled Task launch (fresh full SYSTEM token from " +
|
||||
"the Task Scheduler service) is preferred for this install.");
|
||||
launch = DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => TryStartRuntimeViaSchtasks(taskName, logger),
|
||||
verifyRuntimePid: () => WaitForDetachedRuntimePid(targetExePath, logger),
|
||||
tryWmi: () => TryStartRuntimeViaWmi(targetExePath, logger));
|
||||
}
|
||||
else
|
||||
{
|
||||
var wmi = TryStartRuntimeViaWmi(targetExePath, logger);
|
||||
launch = DecideRuntimeLaunchOutcome(
|
||||
wmiReturnValue: wmi.ReturnValue,
|
||||
wmiPid: wmi.Pid,
|
||||
trySchtasks: () => TryStartRuntimeViaSchtasks(taskName, logger));
|
||||
}
|
||||
|
||||
switch (launch.Method)
|
||||
{
|
||||
@@ -211,7 +240,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
break;
|
||||
case RuntimeLaunchMethod.Schtasks:
|
||||
logger.Info(
|
||||
$"Runtime process queued via schtasks /Run fallback on task '{taskName}'. exe={targetExePath}. {launch.Diagnostic}");
|
||||
$"Runtime start via schtasks /Run on task '{taskName}'. exe={targetExePath}. {launch.Diagnostic}");
|
||||
break;
|
||||
case RuntimeLaunchMethod.Deferred:
|
||||
logger.Warning(
|
||||
@@ -629,11 +658,113 @@ namespace AutopilotMonitor.Agent.V2
|
||||
diagnostic: $"Both immediate-launch paths failed; deferring runtime start to BootTrigger Scheduled Task on next reboot. {wmiHint} schtasks /Run exit={schtasksExit}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restricted-token variant of the launch orchestration — used when the install-mode
|
||||
/// process token does not hold <c>SeTimeZonePrivilege</c> (privilege-stripped MDM/MSI
|
||||
/// caller chain, observed 2026-08-17). WMI would duplicate the stripped token onto the
|
||||
/// runtime, so the order flips: Scheduled Task first (fresh full SYSTEM token from the
|
||||
/// Task Scheduler service), WMI only as degraded-but-alive fallback.
|
||||
/// <para>
|
||||
/// Contract:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Invoke <paramref name="trySchtasks"/>; on exit 0 invoke <paramref name="verifyRuntimePid"/> (schtasks /Run only reports "queued", never "launched"). A verified pid returns <see cref="RuntimeLaunchMethod.Schtasks"/> without touching WMI.</description></item>
|
||||
/// <item><description>schtasks failed or no process appeared → invoke <paramref name="tryWmi"/>; a valid pid returns <see cref="RuntimeLaunchMethod.Wmi"/> with a diagnostic warning that the runtime runs on the restricted token.</description></item>
|
||||
/// <item><description>Both paths fail → <see cref="RuntimeLaunchMethod.Deferred"/>; the BootTrigger start on next reboot gets a full token anyway.</description></item>
|
||||
/// <item><description><paramref name="verifyRuntimePid"/> is only invoked after a successful schtasks queue — it costs up to the full poll window.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static RuntimeLaunchResult DecideRestrictedTokenLaunchOutcome(
|
||||
Func<int> trySchtasks,
|
||||
Func<int> verifyRuntimePid,
|
||||
Func<WmiLaunchAttempt> tryWmi)
|
||||
{
|
||||
if (trySchtasks == null) throw new ArgumentNullException(nameof(trySchtasks));
|
||||
if (verifyRuntimePid == null) throw new ArgumentNullException(nameof(verifyRuntimePid));
|
||||
if (tryWmi == null) throw new ArgumentNullException(nameof(tryWmi));
|
||||
|
||||
var schtasksExit = trySchtasks();
|
||||
if (schtasksExit == 0)
|
||||
{
|
||||
var pid = verifyRuntimePid();
|
||||
if (pid > 0)
|
||||
{
|
||||
return new RuntimeLaunchResult(
|
||||
method: RuntimeLaunchMethod.Schtasks,
|
||||
pid: pid,
|
||||
wmiReturnValue: 0u,
|
||||
schtasksExitCode: 0,
|
||||
diagnostic: $"Runtime launched via Scheduled Task (restricted install token — task provides a full SYSTEM token). Verified PID={pid}.");
|
||||
}
|
||||
}
|
||||
|
||||
var schtasksHint = schtasksExit == 0
|
||||
? "schtasks /Run was queued but no runtime process appeared within the verification window."
|
||||
: $"schtasks /Run exit={schtasksExit}.";
|
||||
|
||||
var wmi = tryWmi();
|
||||
if (wmi.ReturnValue == 0u && wmi.Pid > 0)
|
||||
{
|
||||
return new RuntimeLaunchResult(
|
||||
method: RuntimeLaunchMethod.Wmi,
|
||||
pid: wmi.Pid,
|
||||
wmiReturnValue: 0u,
|
||||
schtasksExitCode: schtasksExit,
|
||||
diagnostic: "Runtime launched via WMI fallback ON THE RESTRICTED TOKEN — privileged features " +
|
||||
"(timezone auto-set, firmware reads) stay degraded until the next BootTrigger start. " +
|
||||
$"{schtasksHint} PID={wmi.Pid}.");
|
||||
}
|
||||
|
||||
return new RuntimeLaunchResult(
|
||||
method: RuntimeLaunchMethod.Deferred,
|
||||
pid: 0,
|
||||
wmiReturnValue: wmi.ReturnValue,
|
||||
schtasksExitCode: schtasksExit,
|
||||
diagnostic: "Restricted-token launch: both Scheduled Task and WMI paths failed; deferring to " +
|
||||
$"BootTrigger on next reboot (full token there). {schtasksHint} WMI returnValue={wmi.ReturnValue}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Polls for the detached runtime process after a <c>schtasks /Run</c> queue — same
|
||||
/// 10s / 500ms contract as the bootstrap script's Get-Process probe. The install-mode
|
||||
/// process shares the runtime's exe name, so the own pid is excluded; a pre-existing
|
||||
/// foreign runtime cannot be running here because install mode bails earlier on the
|
||||
/// Deployed marker.
|
||||
/// </summary>
|
||||
private static int WaitForDetachedRuntimePid(string exePath, AgentLogger logger)
|
||||
{
|
||||
var processName = Path.GetFileNameWithoutExtension(exePath);
|
||||
var ownPid = Process.GetCurrentProcess().Id;
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
var candidates = Process.GetProcessesByName(processName);
|
||||
try
|
||||
{
|
||||
var runtime = candidates.FirstOrDefault(p => p.Id != ownPid);
|
||||
if (runtime != null) return runtime.Id;
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var p in candidates) p.Dispose();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Debug($"Runtime verification poll failed transiently: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
System.Threading.Thread.Sleep(500);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a single WMI <c>Win32_Process.Create</c> attempt. <see cref="Pid"/>
|
||||
/// is <c>0</c> on any non-zero <see cref="ReturnValue"/>.
|
||||
/// </summary>
|
||||
private readonly struct WmiLaunchAttempt
|
||||
internal readonly struct WmiLaunchAttempt
|
||||
{
|
||||
public WmiLaunchAttempt(uint returnValue, int pid)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks whether the current process token HOLDS a privilege — present in the token at
|
||||
/// all, enabled or disabled. This is different from the enable-on-demand pattern in
|
||||
/// <c>UefiSecureBootCertReader</c>: a privilege that was REMOVED from the token cannot be
|
||||
/// re-enabled with <c>AdjustTokenPrivileges</c> by anyone, including SYSTEM.
|
||||
/// <para>
|
||||
/// Why install mode needs this: MDM-originated install chains (EnterpriseDesktopAppManagement
|
||||
/// CSP → msiexec → bootstrap runner) carry a SYSTEM token with several privileges removed —
|
||||
/// observed 2026-08-17 with the Bootstrap MSI: SeTimeZonePrivilege and
|
||||
/// SeSystemEnvironmentPrivilege were gone, so tzutil exited 5 (ACCESS_DENIED) and firmware
|
||||
/// reads reported privilege_denied. WMI <c>Win32_Process.Create</c> duplicates the CALLER's
|
||||
/// token onto the new process, so the restriction propagates to the runtime; the Task
|
||||
/// Scheduler service, by contrast, starts the task with a fresh full SYSTEM token. The
|
||||
/// launch orchestration therefore needs to know up front whether its own token is stripped.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Fail-soft: every API failure reports "held" so the default (WMI-first) launch path is
|
||||
/// kept — a probe defect must never change behavior for the healthy platform-script chain.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class TokenPrivilegeProbe
|
||||
{
|
||||
public const string TimeZonePrivilege = "SeTimeZonePrivilege";
|
||||
|
||||
public static bool IsPrivilegeHeld(string privilegeName)
|
||||
{
|
||||
try
|
||||
{
|
||||
NativeMethods.LUID target;
|
||||
if (!NativeMethods.LookupPrivilegeValueW(null, privilegeName, out target))
|
||||
return true;
|
||||
|
||||
SafeTokenHandle token;
|
||||
if (!NativeMethods.OpenProcessToken(NativeMethods.GetCurrentProcess(),
|
||||
NativeMethods.TOKEN_QUERY, out token))
|
||||
{
|
||||
if (token != null) token.Dispose();
|
||||
return true;
|
||||
}
|
||||
|
||||
using (token)
|
||||
{
|
||||
uint required;
|
||||
NativeMethods.GetTokenInformation(token, NativeMethods.TokenPrivileges,
|
||||
IntPtr.Zero, 0, out required);
|
||||
if (required == 0 || required > 64 * 1024)
|
||||
return true;
|
||||
|
||||
var buffer = Marshal.AllocHGlobal((int)required);
|
||||
try
|
||||
{
|
||||
if (!NativeMethods.GetTokenInformation(token, NativeMethods.TokenPrivileges,
|
||||
buffer, required, out required))
|
||||
return true;
|
||||
|
||||
// TOKEN_PRIVILEGES layout: DWORD PrivilegeCount, then PrivilegeCount ×
|
||||
// LUID_AND_ATTRIBUTES { LUID (uint Low, int High), DWORD Attributes } = 12 bytes.
|
||||
var count = Marshal.ReadInt32(buffer);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var entry = 4 + i * 12;
|
||||
var low = unchecked((uint)Marshal.ReadInt32(buffer, entry));
|
||||
var high = Marshal.ReadInt32(buffer, entry + 4);
|
||||
if (low == target.LowPart && high == target.HighPart)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SafeTokenHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||
{
|
||||
public SafeTokenHandle() : base(true) { }
|
||||
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
return NativeMethods.CloseHandle(handle);
|
||||
}
|
||||
}
|
||||
|
||||
private static class NativeMethods
|
||||
{
|
||||
public const uint TOKEN_QUERY = 0x0008;
|
||||
public const int TokenPrivileges = 3; // TOKEN_INFORMATION_CLASS.TokenPrivileges
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct LUID
|
||||
{
|
||||
public uint LowPart;
|
||||
public int HighPart;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
public static extern IntPtr GetCurrentProcess();
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool OpenProcessToken(IntPtr processHandle, uint desiredAccess, out SafeTokenHandle tokenHandle);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool LookupPrivilegeValueW(string lpSystemName, string lpName, out LUID lpLuid);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool GetTokenInformation(SafeTokenHandle tokenHandle, int tokenInformationClass,
|
||||
IntPtr tokenInformation, uint tokenInformationLength, out uint returnLength);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool CloseHandle(IntPtr handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,9 @@ try {
|
||||
}
|
||||
|
||||
Write-Log "Bootstrap script downloaded to $dest - executing."
|
||||
& $dest *>> $log
|
||||
# PS 5.1 file redirection (*>>) writes UTF-16LE, which garbles a log otherwise
|
||||
# written via Add-Content (ANSI). Route through Add-Content for one encoding.
|
||||
& $dest *>&1 | ForEach-Object { $_.ToString() } | Add-Content -Path $log
|
||||
Write-Log "Bootstrap script finished (exit code $LASTEXITCODE)."
|
||||
exit 0
|
||||
} catch {
|
||||
|
||||
+17
@@ -174,6 +174,23 @@ public class EndpointPolicyCatalogCompletenessTests
|
||||
Assert.Equal(EndpointPolicy.TenantAdminOrGA, postRules.Policy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IME log pattern edits write GLOBAL patterns (all tenants) and the handler has no GA
|
||||
/// re-gate, so the PUT must stay GlobalAdminOnly — a downgrade to TenantAdminOrGA would let
|
||||
/// any customer tenant admin mutate platform-wide patterns via ?global=true.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ImeLogPatternWrites_AreGlobalAdminOnly()
|
||||
{
|
||||
var putPattern = EndpointAccessPolicyCatalog.FindPolicy("PUT", "/api/rules/ime-log-patterns/IME-001");
|
||||
var reseed = EndpointAccessPolicyCatalog.FindPolicy("POST", "/api/rules/ime-log-patterns/reseed");
|
||||
|
||||
Assert.NotNull(putPattern);
|
||||
Assert.NotNull(reseed);
|
||||
Assert.Equal(EndpointPolicy.GlobalAdminOnly, putPattern.Policy);
|
||||
Assert.Equal(EndpointPolicy.GlobalAdminOnly, reseed.Policy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregistered routes must return null (fail-closed).
|
||||
/// </summary>
|
||||
|
||||
@@ -311,7 +311,6 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("POST", "rules/analyze/{ruleId}/create-from-template", EndpointPolicy.TenantAdminOrGA),
|
||||
new("PUT", "rules/analyze/{ruleId}", EndpointPolicy.TenantAdminOrGA),
|
||||
new("DELETE", "rules/analyze/{ruleId}", EndpointPolicy.TenantAdminOrGA),
|
||||
new("PUT", "rules/ime-log-patterns/{patternId}", EndpointPolicy.TenantAdminOrGA),
|
||||
new("POST", "sessions/{sessionId}/mark-failed", EndpointPolicy.TenantAdminOrGA),
|
||||
new("POST", "sessions/{sessionId}/mark-succeeded", EndpointPolicy.TenantAdminOrGA),
|
||||
// Operator tier so troubleshooting staff can queue request_diagnostics ("Collect Logs" in
|
||||
@@ -541,6 +540,9 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("GET", "vulnerability/ignored-software", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("POST", "vulnerability/ignored-software", EndpointPolicy.GlobalAdminOnly),
|
||||
new("DELETE", "vulnerability/ignored-software", EndpointPolicy.GlobalAdminOnly),
|
||||
// Both routes mutate GLOBAL patterns (all tenants); there is no tenant-scoped variant yet,
|
||||
// so PUT must not sit on the TenantAdminOrGA tier — the handler has no GA re-gate.
|
||||
new("PUT", "rules/ime-log-patterns/{patternId}", EndpointPolicy.GlobalAdminOnly),
|
||||
new("POST", "rules/ime-log-patterns/reseed", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "global/mcp-users", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("POST", "global/mcp-users", EndpointPolicy.GlobalAdminOnly),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { sessionUrl } from "@/lib/routes";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { useState, useEffect, useRef, useMemo, useDeferredValue } from "react";
|
||||
import { Session } from "../types";
|
||||
@@ -709,6 +710,7 @@ export function SessionTable({
|
||||
key={col.key}
|
||||
columnKey={col.key}
|
||||
session={session}
|
||||
href={linkFor(session)}
|
||||
adminMode={adminMode}
|
||||
globalAdminMode={globalAdminMode}
|
||||
blockedDevicesSet={blockedDevicesSet}
|
||||
@@ -782,9 +784,35 @@ export function SessionTable({
|
||||
);
|
||||
}
|
||||
|
||||
/** Invisible cell-filling anchor so the browser's native context menu (open in new
|
||||
* tab, copy link) and middle/ctrl+click work on session rows. Visually nothing — the
|
||||
* row keeps its exact current look; navigation UX is unchanged because plain clicks
|
||||
* still do an SPA push via Link. Cells with their own interactive content or title
|
||||
* tooltips (status badges, tenant copy button, actions) must NOT render this overlay
|
||||
* as it would sit above them and swallow their hover/click. aria-hidden + tabIndex -1
|
||||
* keep it out of tab order and screen readers — the row's click target is unchanged. */
|
||||
function RowLinkOverlay({ href, session }: { href: Route; session: Session }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
prefetch={false}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className="absolute inset-0"
|
||||
onClick={(e) => {
|
||||
// Link handles the navigation itself; stop the row's onClick from double-pushing.
|
||||
e.stopPropagation();
|
||||
trackEvent("session_opened", { sessionId: session.sessionId, status: session.status ?? "" });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionCell({
|
||||
columnKey,
|
||||
session,
|
||||
href,
|
||||
globalAdminMode,
|
||||
blockedDevicesSet,
|
||||
user,
|
||||
@@ -795,6 +823,7 @@ function SessionCell({
|
||||
}: {
|
||||
columnKey: string;
|
||||
session: Session;
|
||||
href: Route;
|
||||
adminMode: boolean;
|
||||
globalAdminMode: boolean;
|
||||
blockedDevicesSet: Set<string>;
|
||||
@@ -808,7 +837,8 @@ function SessionCell({
|
||||
switch (columnKey) {
|
||||
case "device":
|
||||
return (
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="relative px-6 py-4 whitespace-nowrap">
|
||||
<RowLinkOverlay href={href} session={session} />
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{session.deviceName || session.serialNumber}
|
||||
</div>
|
||||
@@ -849,7 +879,8 @@ function SessionCell({
|
||||
|
||||
case "model":
|
||||
return (
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="relative px-6 py-4 whitespace-nowrap">
|
||||
<RowLinkOverlay href={href} session={session} />
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{session.manufacturer || "Unknown manufacturer"}
|
||||
</div>
|
||||
@@ -907,14 +938,16 @@ function SessionCell({
|
||||
|
||||
case "eventCount":
|
||||
return (
|
||||
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<RowLinkOverlay href={href} session={session} />
|
||||
{session.eventCount}
|
||||
</td>
|
||||
);
|
||||
|
||||
case "duration":
|
||||
return (
|
||||
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<RowLinkOverlay href={href} session={session} />
|
||||
{Math.round(session.durationSeconds / 60)} min
|
||||
</td>
|
||||
);
|
||||
@@ -923,7 +956,8 @@ function SessionCell({
|
||||
// Two-line date/time keeps the column narrow so more columns fit on one page.
|
||||
const started = new Date(session.startedAt);
|
||||
return (
|
||||
<td className="px-3 py-4 whitespace-nowrap">
|
||||
<td className="relative px-3 py-4 whitespace-nowrap">
|
||||
<RowLinkOverlay href={href} session={session} />
|
||||
<div className="text-sm font-medium text-gray-900">{started.toLocaleDateString()}</div>
|
||||
<div className="text-sm text-gray-500">{started.toLocaleTimeString()}</div>
|
||||
</td>
|
||||
@@ -985,35 +1019,40 @@ function SessionCell({
|
||||
|
||||
case "osName":
|
||||
return (
|
||||
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<RowLinkOverlay href={href} session={session} />
|
||||
{session.osName || <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
);
|
||||
|
||||
case "osBuild":
|
||||
return (
|
||||
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500 font-mono">
|
||||
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500 font-mono">
|
||||
<RowLinkOverlay href={href} session={session} />
|
||||
{session.osBuild || <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
);
|
||||
|
||||
case "osDisplayVersion":
|
||||
return (
|
||||
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<RowLinkOverlay href={href} session={session} />
|
||||
{session.osDisplayVersion || <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
);
|
||||
|
||||
case "osEdition":
|
||||
return (
|
||||
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<RowLinkOverlay href={href} session={session} />
|
||||
{session.osEdition || <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
);
|
||||
|
||||
case "osLanguage":
|
||||
return (
|
||||
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<RowLinkOverlay href={href} session={session} />
|
||||
{session.osLanguage || <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user