diff --git a/internal b/internal index 7cb78f47..31b077a3 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 7cb78f47ead7a9f36a61c5d43acb2d0fdbc9d1e6 +Subproject commit 31b077a3fec152c2234aa4a773f38805ec5a145d diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/DiagnosticsPathGuardsTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/DiagnosticsPathGuardsTests.cs new file mode 100644 index 00000000..f09b6e90 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/DiagnosticsPathGuardsTests.cs @@ -0,0 +1,193 @@ +using System; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather +{ + /// + /// A configured diagnostics entry is resolved to the folder and pattern that get + /// enumerated, and the guard judges exactly that folder. The cases below pin the + /// property that matters: no spelling of an allowed entry ever selects its parent. + /// + public sealed class DiagnosticsPathGuardsTests + { + private const string TestProfile = @"C:\Users\TestUser"; + + private static readonly Func IsDirectory = _ => true; + private static readonly Func IsNotDirectory = _ => false; + + private static DiagnosticsPathGuards.CollectionTarget Resolve( + string path, + bool includeSubfolders = false, + bool unrestrictedMode = false, + string? userProfilePath = null, + Func? directoryExists = null) + { + Assert.True(DiagnosticsPathGuards.TryResolveCollectionTarget( + path, includeSubfolders, unrestrictedMode, userProfilePath!, directoryExists ?? IsDirectory, out var target)); + return target; + } + + private static void AssertRefused( + string path, + bool unrestrictedMode = false, + string? userProfilePath = null, + Func? directoryExists = null) + { + Assert.False(DiagnosticsPathGuards.TryResolveCollectionTarget( + path, true, unrestrictedMode, userProfilePath!, directoryExists ?? IsDirectory, out var target)); + Assert.Null(target); + } + + // ------------------------------------------------------------ folder entries + + [Theory] + [InlineData(@"C:\Windows\Logs")] + [InlineData(@"C:\Windows\Logs\")] + [InlineData(@"C:\Windows\SetupDiag")] + [InlineData(@"C:\ProgramData\AutopilotMonitor")] + [InlineData(@"C:\ProgramData\Microsoft\DiagnosticLogCSP")] + public void Folder_entry_collects_from_itself_never_from_its_parent(string configured) + { + var target = Resolve(configured, includeSubfolders: true); + + Assert.Equal(configured.TrimEnd('\\'), target.Folder, ignoreCase: true); + Assert.Equal("*", target.Pattern); + Assert.True(target.Recurse); + } + + [Fact] + public void Trailing_separator_means_folder_even_when_the_folder_does_not_exist_yet() + { + var target = Resolve(@"C:\Windows\Logs\NotThereYet\", directoryExists: IsNotDirectory); + + Assert.Equal(@"C:\Windows\Logs\NotThereYet", target.Folder, ignoreCase: true); + Assert.Equal("*", target.Pattern); + } + + [Fact] + public void Folder_name_with_a_dot_is_still_a_folder() + { + var target = Resolve(@"C:\Windows\Logs\vendor.v2"); + + Assert.Equal(@"C:\Windows\Logs\vendor.v2", target.Folder, ignoreCase: true); + Assert.Equal("*", target.Pattern); + } + + // ------------------------------------------------------------ wildcard entries + + [Fact] + public void Wildcard_entry_collects_the_pattern_from_its_validated_parent() + { + var target = Resolve(@"C:\Windows\Panther\*.log", includeSubfolders: true); + + Assert.Equal(@"C:\Windows\Panther", target.Folder, ignoreCase: true); + Assert.Equal("*.log", target.Pattern); + Assert.True(target.Recurse); + } + + [Fact] + public void Wildcard_beside_an_allowlisted_file_is_refused() + { + // Only ReportingEvents.log is on the allowlist, not the folder it lives in. + AssertRefused(@"C:\Windows\SoftwareDistribution\*.log"); + } + + // ------------------------------------------------------------ file entries + + [Fact] + public void File_entry_never_recurses_because_its_folder_was_not_validated() + { + var target = Resolve( + @"C:\Windows\SoftwareDistribution\ReportingEvents.log", + includeSubfolders: true, + directoryExists: IsNotDirectory); + + Assert.Equal(@"C:\Windows\SoftwareDistribution", target.Folder, ignoreCase: true); + Assert.Equal("ReportingEvents.log", target.Pattern); + Assert.False(target.Recurse); + } + + [Fact] + public void Missing_path_without_a_dot_is_one_file_name_not_a_wildcard_on_the_parent() + { + var target = Resolve(@"C:\Windows\Logs\nodot", includeSubfolders: true, directoryExists: IsNotDirectory); + + Assert.Equal(@"C:\Windows\Logs", target.Folder, ignoreCase: true); + Assert.Equal("nodot", target.Pattern); + Assert.False(target.Recurse); + } + + // ------------------------------------------------------------ refusals + + [Theory] + [InlineData(@"C:\Windows")] + [InlineData(@"C:\ProgramData")] + [InlineData(@"C:\ProgramData\Microsoft")] + [InlineData(@"C:\Windows\Logs\..\System32")] + [InlineData(@"C:\Windows\LogsEvil")] + [InlineData("")] + public void Paths_outside_the_allowlist_are_refused(string configured) + { + AssertRefused(configured); + } + + [Theory] + [InlineData(@"C:\Users")] + [InlineData(@"C:\Users\TestUser\Documents")] + [InlineData(@"C:\Windows\System32\config")] + [InlineData(@"C:\Windows\System32\config\*")] + public void Hard_blocks_hold_in_unrestricted_mode(string configured) + { + AssertRefused(configured, unrestrictedMode: true); + } + + // ------------------------------------------------------------ user-profile token + + [Fact] + public void Expanded_token_path_outside_AppData_is_refused_even_in_unrestricted_mode() + { + // The service expands %LOGGED_ON_USER_PROFILE% before the guard sees the path; an + // unexpanded token is not under C:\Users lexically and used to slip past the block. + AssertRefused(TestProfile + @"\Documents", unrestrictedMode: true, userProfilePath: TestProfile); + AssertRefused(TestProfile + @"\Documents\*.txt", unrestrictedMode: true, userProfilePath: TestProfile); + } + + [Fact] + public void Expanded_token_path_on_the_profile_allowlist_is_admitted_in_restricted_mode() + { + var target = Resolve( + TestProfile + @"\AppData\Local\RealmJoin\Logs\*.log", + userProfilePath: TestProfile); + + Assert.Equal(TestProfile + @"\AppData\Local\RealmJoin\Logs", target.Folder, ignoreCase: true); + Assert.Equal("*.log", target.Pattern); + } + + [Fact] + public void Profile_path_without_the_token_stays_blocked() + { + AssertRefused(TestProfile + @"\AppData\Local\RealmJoin\Logs\*.log", userProfilePath: null); + } + + // ------------------------------------------------------------ recursion guard + + [Theory] + [InlineData(@"C:\Users")] + [InlineData(@"C:\Users\TestUser")] + [InlineData(@"C:\Windows\System32\config")] + [InlineData(@"C:\Windows\System32\config\RegBack")] + public void Recursion_never_enters_a_hard_blocked_subtree_in_unrestricted_mode(string directory) + { + Assert.False(DiagnosticsPathGuards.IsDirectoryEnterable(directory, unrestrictedMode: true, userProfilePath: null)); + } + + [Fact] + public void Recursion_enters_ordinary_subfolders() + { + Assert.True(DiagnosticsPathGuards.IsDirectoryEnterable(@"C:\Windows\System32\drivers", unrestrictedMode: true, userProfilePath: null)); + Assert.True(DiagnosticsPathGuards.IsDirectoryEnterable(@"C:\Windows\Logs\CBS", unrestrictedMode: false, userProfilePath: null)); + Assert.False(DiagnosticsPathGuards.IsDirectoryEnterable(@"C:\Windows\System32\drivers", unrestrictedMode: false, userProfilePath: null)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/DiagnosticsEventLogBlockTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/DiagnosticsEventLogBlockTests.cs new file mode 100644 index 00000000..8875e7ef --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/DiagnosticsEventLogBlockTests.cs @@ -0,0 +1,204 @@ +#nullable enable +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +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.Telemetry.Gather; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime +{ + /// + /// The hard-blocked event-log channels (Security, PowerShell, Sysmon) are unreadable through + /// the diagnostics package as well — by path, by wildcard, by resolved channel, and in + /// unrestricted mode. Before this the block held only in the event-log collector. + /// + public sealed class DiagnosticsEventLogBlockTests + { + private const string WinevtLogs = @"C:\Windows\System32\winevt\Logs"; + + // A channel no machine has: the export fails and the packager falls back to the raw copy. + private const string HarmlessChannel = "AutopilotMonitor-Tests-NoSuchChannel/Operational"; + + [Theory] + [InlineData("Security.evtx")] + [InlineData("security.EVTX")] + [InlineData("Microsoft-Windows-PowerShell%4Operational.evtx")] + [InlineData("Microsoft-Windows-PowerShell%4Admin.evtx")] + [InlineData("Windows PowerShell.evtx")] + [InlineData("Microsoft-Windows-Sysmon%4Operational.evtx")] + [InlineData("Archive-Security-2026-09-21-10-15-30-123.evtx")] + [InlineData("Archive-Microsoft-Windows-PowerShell%4Operational-2026-09-21-10-15-30-123.evtx")] + public void Hard_blocked_channel_files_are_recognised_by_name(string fileName) + { + Assert.True(DiagnosticsPathGuards.IsHardBlockedEventLogFile(Path.Combine(WinevtLogs, fileName))); + } + + [Theory] + [InlineData("System.evtx")] + [InlineData("Application.evtx")] + [InlineData("SecurityAudit.evtx")] + [InlineData("Security.log")] + [InlineData("Microsoft-Windows-Security-Mitigations%4KernelMode.evtx")] + // Same boundary as the collector: only '/' continues a blocked channel name. + [InlineData("Microsoft-Windows-PowerShell-DesiredStateConfiguration-FileDownloadManager%4Operational.evtx")] + [InlineData("Archive-System-2026-09-21-10-15-30-123.evtx")] + public void Other_files_are_not_mistaken_for_a_blocked_channel(string fileName) + { + Assert.False(DiagnosticsPathGuards.IsHardBlockedEventLogFile(Path.Combine(WinevtLogs, fileName))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Path_guard_refuses_a_blocked_channel_file_in_every_mode(bool unrestrictedMode) + { + Assert.False(DiagnosticsPathGuards.IsDiagnosticsPathAllowed( + WinevtLogs + @"\Security.evtx", unrestrictedMode)); + Assert.False(DiagnosticsPathGuards.IsDiagnosticsPathAllowed( + WinevtLogs + @"\Microsoft-Windows-PowerShell%4Operational.evtx", unrestrictedMode)); + } + + [Fact] + public void Path_guard_still_admits_other_channels_and_wildcards() + { + Assert.True(DiagnosticsPathGuards.IsDiagnosticsPathAllowed(WinevtLogs + @"\System.evtx")); + // Undecidable on the string — the packager filters per file (tests below). + Assert.True(DiagnosticsPathGuards.IsDiagnosticsPathAllowed(WinevtLogs + @"\*.evtx")); + } + + [Theory] + [InlineData("Security", true)] + [InlineData("Microsoft-Windows-PowerShell/Operational", true)] + [InlineData(" Windows PowerShell ", true)] + [InlineData("Microsoft-Windows-Sysmon/Operational", true)] + [InlineData("System", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void Channel_block_is_one_matcher_for_collector_and_package(string? channel, bool blocked) + { + Assert.Equal(blocked, GatherRuleGuards.IsHardBlockedEventLogChannel(channel!)); + } + + // ------------------------------------------------------------ packager ---- + + // %TEMP% lives under C:\Users, which the path guard blocks in every mode, so the + // configured folder sits beside the test assembly and the run is unrestricted — which + // is also the strongest form of the claim: not even unrestricted mode collects these. + private sealed class EvtxFolder : System.IDisposable + { + public string Path { get; } = System.IO.Path.Combine( + System.AppContext.BaseDirectory, "diag-evtx-" + System.Guid.NewGuid().ToString("N")); + + public EvtxFolder(params string[] fileNames) + { + Directory.CreateDirectory(Path); + foreach (var name in fileNames) + File.WriteAllBytes(System.IO.Path.Combine(Path, name), new byte[] { 1, 2, 3 }); + } + + public void Dispose() + { + try { Directory.Delete(Path, recursive: true); } catch { } + } + } + + private static DiagnosticsPackageService BuildService( + TempDirectory scratch, string configuredPath, System.Func channelResolver) + { + var logger = new AgentLogger(Directory.CreateDirectory(Path.Combine(scratch.Path, "logs")).FullName); + var apiClient = new BackendApiClient( + httpClient: new System.Net.Http.HttpClient(), + baseUrl: "http://localhost", + manufacturer: string.Empty, + model: string.Empty, + serialNumber: string.Empty, + useBootstrapTokenAuth: false, + bootstrapToken: null, + agentVersion: "0.0.0", + logger: logger); + + var cfg = new AgentConfiguration + { + SessionId = "S1", + TenantId = "T1", + ApiBaseUrl = "http://localhost", + UnrestrictedMode = true, + }; + cfg.DiagnosticsLogPaths.Add(new DiagnosticsLogPath { Path = configuredPath }); + + string Sub(string name) => Directory.CreateDirectory(Path.Combine(scratch.Path, name)).FullName; + return new DiagnosticsPackageService( + cfg, + logger, + apiClient, + agentLogFolderOverride: Sub("agentlogs"), + imeLogFolderOverride: Sub("ime"), + agentStateFolderOverride: Sub("state"), + agentSpoolFolderOverride: Sub("spool"), + agentDataFolderOverride: Sub("data"), + sectionFolderOverrides: null, + devicePreparationProbe: () => false) + { + EventLogChannelResolver = channelResolver, + }; + } + + private static List ZipEntryNames(byte[] bytes) + { + using var ms = new MemoryStream(bytes); + using var archive = new ZipArchive(ms, ZipArchiveMode.Read); + return archive.Entries.Select(e => e.FullName).ToList(); + } + + private static string ReadManifest(byte[] bytes) + { + using var ms = new MemoryStream(bytes); + using var archive = new ZipArchive(ms, ZipArchiveMode.Read); + using var reader = new StreamReader(archive.GetEntry("package-manifest.txt")!.Open()); + return reader.ReadToEnd(); + } + + [Fact] + public void Wildcard_entry_skips_blocked_channel_files_and_keeps_the_rest() + { + using var scratch = new TempDirectory(); + using var folder = new EvtxFolder( + "Security.evtx", "Microsoft-Windows-PowerShell%4Operational.evtx", "Setup.evtx"); + + var service = BuildService(scratch, Path.Combine(folder.Path, "*.evtx"), _ => HarmlessChannel); + var bytes = service.BuildArchiveBytes(enrollmentSucceeded: false); + + var entries = ZipEntryNames(bytes); + Assert.Contains(entries, e => e.EndsWith("/Setup.evtx")); + Assert.DoesNotContain(entries, e => e.EndsWith("Security.evtx")); + Assert.DoesNotContain(entries, e => e.Contains("PowerShell")); + + var manifest = ReadManifest(bytes); + Assert.Contains("BLOCKED (event-log channel guard", manifest); + Assert.Contains("Security.evtx", manifest); + Assert.Equal(2, service.LastPackaging!.ProblemsByKind["path-guard"]); + } + + [Fact] + public void File_with_a_harmless_name_is_blocked_when_it_resolves_to_a_blocked_channel() + { + // A channel is free to write to any file name; and the resolver's naming fallback + // maps a stray file onto a live channel. The export must never read that channel. + using var scratch = new TempDirectory(); + using var folder = new EvtxFolder("Harmless.evtx"); + + var service = BuildService(scratch, Path.Combine(folder.Path, "Harmless.evtx"), _ => "Security"); + var bytes = service.BuildArchiveBytes(enrollmentSucceeded: false); + + Assert.DoesNotContain(ZipEntryNames(bytes), e => e.EndsWith("Harmless.evtx")); + Assert.Contains("BLOCKED (event-log channel guard, channel 'Security')", ReadManifest(bytes)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/DiagnosticsPackageServiceTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/DiagnosticsPackageServiceTests.cs index 482eeba4..043da53b 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/DiagnosticsPackageServiceTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/DiagnosticsPackageServiceTests.cs @@ -206,6 +206,91 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime Assert.Contains($"BLOCKED (path guard): {configuredPath}", manifest); } + // %TEMP% lives under C:\Users, which the guard blocks in every mode — the positive + // configured-path cases run beside the test assembly, in unrestricted mode. + private sealed class ConfiguredPathTree : System.IDisposable + { + public string Root { get; } = Path.Combine( + System.AppContext.BaseDirectory, "diag-paths-" + System.Guid.NewGuid().ToString("N")); + public string Logs => Path.Combine(Root, "Logs"); + + public ConfiguredPathTree() + { + Directory.CreateDirectory(Path.Combine(Logs, "sub")); + File.WriteAllText(Path.Combine(Root, "parent-only.txt"), "not configured"); + File.WriteAllText(Path.Combine(Logs, "app.log"), "configured"); + File.WriteAllText(Path.Combine(Logs, "sub", "app.log"), "below"); + } + + public void Dispose() + { + try { Directory.Delete(Root, recursive: true); } catch { /* best effort */ } + } + } + + [Theory] + [InlineData("")] + [InlineData("\\")] + public void BuildArchiveBytes_folder_entry_collects_the_folder_not_its_parent(string trailing) + { + using var rig = new Rig(); + using var tree = new ConfiguredPathTree(); + + var bytes = rig.Build(cfg => + { + cfg.UnrestrictedMode = true; + cfg.DiagnosticsLogPaths.Add(new AutopilotMonitor.Shared.Models.DiagnosticsLogPath + { + Path = tree.Logs + trailing, + IncludeSubfolders = true, + }); + }) + .BuildArchiveBytes(enrollmentSucceeded: true); + + var entries = ZipEntryNames(bytes); + Assert.Contains("AdditionalLogs/Logs/app.log", entries); + Assert.Contains("AdditionalLogs/Logs/sub/app.log", entries); + Assert.DoesNotContain(entries, e => e.EndsWith("parent-only.txt")); + } + + [Fact] + public void BuildArchiveBytes_file_entry_ignores_include_subfolders() + { + using var rig = new Rig(); + using var tree = new ConfiguredPathTree(); + + var bytes = rig.Build(cfg => + { + cfg.UnrestrictedMode = true; + cfg.DiagnosticsLogPaths.Add(new AutopilotMonitor.Shared.Models.DiagnosticsLogPath + { + Path = Path.Combine(tree.Logs, "app.log"), + IncludeSubfolders = true, + }); + }) + .BuildArchiveBytes(enrollmentSucceeded: true); + + var entries = ZipEntryNames(bytes); + Assert.Contains("AdditionalLogs/Logs/app.log", entries); + Assert.DoesNotContain("AdditionalLogs/Logs/sub/app.log", entries); + } + + [Fact] + public void EnumerateFilesNoReparseDirs_asks_the_guard_before_entering_a_subfolder() + { + using var tree = new ConfiguredPathTree(); + var refused = new List(); + + var files = DiagnosticsPackageService.EnumerateFilesNoReparseDirs( + tree.Root, "*", recurse: true, + mayEnterDirectory: dir => !dir.EndsWith("sub"), + refusedDirectories: refused); + + Assert.Contains(Path.Combine(tree.Logs, "app.log"), files); + Assert.DoesNotContain(Path.Combine(tree.Logs, "sub", "app.log"), files); + Assert.Equal(new[] { Path.Combine(tree.Logs, "sub") }, refused); + } + [Fact] public void BuildArchiveBytes_excludes_top_level_session_id_and_bootstrap_config() { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs index 379ac5b6..12dff547 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs @@ -99,7 +99,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime /// /// Packaging problems that are not cap skips, per kind: path-guard (configured path - /// refused), no-user-session (user-profile token without a signed-in user), + /// or hard-blocked event log refused),no-user-session (user-profile token without a signed-in user), /// folder-rejected, enumerate, open, copy. /// public IReadOnlyDictionary ProblemsByKind { get; set; } = Empty; @@ -145,6 +145,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime // exact point production is exposed. Null in production. internal Action BeforeSourceFileOpen { get; set; } + // Test seam: which channel writes to an .evtx file. Production asks the registered + // channels; a test cannot register one. + internal Func EventLogChannelResolver { get; set; } = ResolveChannelForEvtxFile; + // Tracks per-build inclusion totals + skip reasons. Threaded through every // AddLogFiles call so caps are global across all sections, not per section. private sealed class BudgetTracker @@ -462,17 +466,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime // 3. Configured additional log paths (global + tenant, validated by guards) foreach (var entry in _configuration.DiagnosticsLogPaths ?? new System.Collections.Generic.List()) { - // Resolve %LOGGED_ON_USER_PROFILE% token and get profile path for guard exception + // The profile path only lifts the C:\Users block for entries that use the token. var userProfilePath = UserProfileResolver.ContainsUserProfileToken(entry.Path) ? UserProfileResolver.GetLoggedOnUserProfilePath() : null; - if (!DiagnosticsPathGuards.IsDiagnosticsPathAllowed(entry.Path, _configuration.UnrestrictedMode, userProfilePath)) - { - _logger.Warning($"Diagnostics path blocked by guard: {entry.Path}"); - ManifestLine($"BLOCKED (path guard): {entry.Path}"); - tracker.RecordProblem("path-guard"); - continue; - } + // Expand BEFORE the guard: the guard must judge the path that is read, + // and an unresolved token is not a path it can judge. var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path); if (expandedPath == null) { @@ -481,13 +480,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime tracker.RecordProblem("no-user-session"); continue; } - var folder = Path.GetDirectoryName(expandedPath); - var pattern = Path.GetFileName(expandedPath); - 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); + + // The guard validates the folder that gets enumerated — never a path + // derived from the validated one afterwards. + if (!DiagnosticsPathGuards.TryResolveCollectionTarget( + expandedPath, entry.IncludeSubfolders, _configuration.UnrestrictedMode, userProfilePath, out var target)) + { + _logger.Warning($"Diagnostics path blocked by guard: {entry.Path}"); + ManifestLine($"BLOCKED (path guard): {entry.Path}"); + tracker.RecordProblem("path-guard"); + continue; + } + + var unrestrictedMode = _configuration.UnrestrictedMode; + var zipFolder = $"AdditionalLogs/{Path.GetFileName(target.Folder)}"; + ManifestLine($"CONFIGURED PATH: '{entry.Path}' -> folder='{target.Folder}' pattern='{target.Pattern}' recursive={target.Recurse}"); + AddLogFiles(archive, target.Folder, zipFolder, target.Pattern, tracker, target.Recurse, + dir => DiagnosticsPathGuards.IsDirectoryEnterable(dir, unrestrictedMode, userProfilePath)); } // Packaging manifest — always written, even when empty of problems, so its @@ -617,14 +626,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime // Recursive enumeration that skips reparse-point directories. Materializes the // result into a list so the caller can iterate without holding a directory handle. // Errors during enumeration are swallowed (logged via _logger by the caller). - private static List EnumerateFilesNoReparseDirs(string folder, string pattern, bool recurse) + // mayEnterDirectory (configured paths only) is asked for every subdirectory before the + // walk descends: a hard-blocked subtree can sit below a folder that passed the guard. + internal static List EnumerateFilesNoReparseDirs(string folder, string pattern, bool recurse, + Func mayEnterDirectory, List refusedDirectories) { var result = new List(); - CollectFilesNoReparseDirs(folder, pattern, recurse, result); + CollectFilesNoReparseDirs(folder, pattern, recurse, mayEnterDirectory, refusedDirectories, result); return result; } - private static void CollectFilesNoReparseDirs(string folder, string pattern, bool recurse, List result) + private static void CollectFilesNoReparseDirs(string folder, string pattern, bool recurse, + Func mayEnterDirectory, List refusedDirectories, List result) { string[] files; try { files = Directory.GetFiles(folder, pattern, SearchOption.TopDirectoryOnly); } @@ -643,12 +656,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime try { attrs = File.GetAttributes(sub); } catch { continue; } if (IsReparsePoint(attrs)) continue; - CollectFilesNoReparseDirs(sub, pattern, recurse: true, result); + if (mayEnterDirectory != null && !mayEnterDirectory(sub)) + { + refusedDirectories.Add(sub); + continue; + } + CollectFilesNoReparseDirs(sub, pattern, recurse: true, mayEnterDirectory, refusedDirectories, result); } } private void AddLogFiles(ZipArchive archive, string sourceFolder, string zipFolder, string searchPattern, - BudgetTracker tracker, bool includeSubfolders = false) + BudgetTracker tracker, bool includeSubfolders = false, Func mayEnterDirectory = null) { // The folder stays pinned (open handle) for this whole section. The path guards and // the enumeration below only ever judge path strings; every byte that reaches the @@ -673,9 +691,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime } List files; + var refusedDirectories = new List(); try { - files = EnumerateFilesNoReparseDirs(pinned.LexicalPath, searchPattern, includeSubfolders); + files = EnumerateFilesNoReparseDirs(pinned.LexicalPath, searchPattern, includeSubfolders, + mayEnterDirectory, refusedDirectories); } catch (Exception ex) { @@ -685,6 +705,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime return; } + // Expected outcome, not a packaging problem: the subtree is off limits by design. + foreach (var refused in refusedDirectories) + { + _logger.Info($"Diagnostics subfolder skipped by guard: {refused}"); + ManifestLine($"SKIPPED DIR (path guard): {refused}"); + } + if (files.Count == 0) ManifestLine($"NO MATCH: {sourceFolder} pattern '{searchPattern}'"); @@ -704,7 +731,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime // the tenant-configured BootstrapperAgent channel never made it into the ZIP. if (file.EndsWith(".evtx", StringComparison.OrdinalIgnoreCase)) { - tempExport = TryExportEventLogChannel(file); + // Hard-blocked channels are refused for every source and in every mode, + // by file name AND by the channel the export would read: the path guard + // cannot judge what a wildcard or folder entry resolves to, and the %4 + // fallback of the resolver turns a "Security.evtx" in any allowed folder + // into the live Security channel. + var channel = EventLogChannelResolver(file); + if (DiagnosticsPathGuards.IsHardBlockedEventLogFile(file) || + GatherRuleGuards.IsHardBlockedEventLogChannel(channel)) + { + _logger.Warning($"Event log blocked by channel guard (channel '{channel}'): {file}"); + ManifestLine($"BLOCKED (event-log channel guard, channel '{channel}'): {file}"); + tracker.RecordProblem("path-guard"); + return; + } + + tempExport = TryExportEventLogChannel(file, channel); if (tempExport == null) { // Export failed (channel not resolvable / wevtutil error) — fall through @@ -850,16 +892,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime /// /// Exports the event-log channel behind a winevt .evtx file to a temp copy via /// wevtutil epl (active channels hold their file exclusively locked, so a raw - /// copy fails). Channel resolution happens against the registered channels — see - /// . Returns the temp file path (caller deletes) - /// or null when the export failed. + /// copy fails). The caller resolved against the registered + /// channels — see — and checked it against the + /// hard-blocked channels. Returns the temp file path (caller deletes) or null when the + /// export failed. /// - private string TryExportEventLogChannel(string evtxPath) + private string TryExportEventLogChannel(string evtxPath, string channel) { string tempPath = null; try { - var channel = ResolveChannelForEvtxFile(evtxPath); tempPath = Path.Combine(Path.GetTempPath(), $"am-evtx-{Guid.NewGuid():N}.evtx"); var psi = new System.Diagnostics.ProcessStartInfo { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/DiagnosticsPathGuards.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/DiagnosticsPathGuards.cs index bf4c5878..62bd89b1 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/DiagnosticsPathGuards.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/DiagnosticsPathGuards.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.RegularExpressions; namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather { @@ -124,51 +125,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather if (IsSymlinkOrReparsePoint(normalizedDir)) return false; - // C:\Users block always applies (even in unrestricted mode) - // Exception: paths under \AppData\Local or AppData\Roaming - // are allowed when the %LOGGED_ON_USER_PROFILE% token was used. - if (normalizedDir.StartsWith(BlockedUsersPrefix, StringComparison.OrdinalIgnoreCase) && - (normalizedDir.Length == BlockedUsersPrefix.Length || - normalizedDir[BlockedUsersPrefix.Length] == Path.DirectorySeparatorChar)) - { - if (!GatherRuleGuards.IsUserProfileSubpathAllowed(normalizedDir, userProfilePath)) - return false; - } - - // Additional hard-blocked paths (even in unrestricted mode) - foreach (var blocked in AdditionalHardBlockedPrefixes) - { - var normalizedBlocked = Path.GetFullPath(blocked); - if (normalizedDir.StartsWith(normalizedBlocked, StringComparison.OrdinalIgnoreCase) && - (normalizedDir.Length == normalizedBlocked.Length || - normalizedDir[normalizedBlocked.Length] == Path.DirectorySeparatorChar)) - { - return false; - } - } - - // In unrestricted mode, everything except hard-blocked paths is allowed - if (unrestrictedMode) - return true; - - foreach (var prefix in AllowedDiagnosticsPathPrefixes) - { - var normalizedPrefix = Path.GetFullPath(prefix); - if (normalizedDir.StartsWith(normalizedPrefix, StringComparison.OrdinalIgnoreCase)) - { - // Segment-bounded: next char must be '\' or end-of-string - if (normalizedDir.Length == normalizedPrefix.Length || - normalizedDir[normalizedPrefix.Length] == Path.DirectorySeparatorChar) - { - return true; - } - } - } - - // Vendor folders under the signed-in user's profile — same allowlist the file - // collectors use, since it answers the same question: which folders below a - // profile carry enrollment-relevant logs rather than the user's own data. - return GatherRuleGuards.IsUserProfilePathOnAllowlist(normalizedDir, userProfilePath); + return IsNormalizedPathAllowed(normalizedDir, unrestrictedMode, userProfilePath); } catch { @@ -177,6 +134,213 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather } } + /// + /// What the package actually reads for one configured entry: every file matching + /// in , optionally below it. + /// + public sealed class CollectionTarget + { + public CollectionTarget(string folder, string pattern, bool recurse) + { + Folder = folder; + Pattern = pattern; + Recurse = recurse; + } + + /// Full path without a trailing separator (drive roots keep theirs). + public string Folder { get; } + public string Pattern { get; } + public bool Recurse { get; } + } + + /// + /// Resolves a configured entry to the folder and pattern that get enumerated, and + /// validates exactly that. Returns false when the guard refuses the entry. + /// + /// must already have the %LOGGED_ON_USER_PROFILE% token + /// resolved: validating the raw token would judge a different path than the one read. + /// + /// A wildcard in the last segment selects files in its parent folder. A trailing + /// separator or an existing directory selects every file in that folder. Anything else + /// names one file — validated as that file and never recursed, because its parent + /// folder has not passed the guard. The filesystem decides folder-vs-file, never the + /// spelling of the last segment. + /// + public static bool TryResolveCollectionTarget( + string expandedPath, + bool includeSubfolders, + bool unrestrictedMode, + string userProfilePath, + out CollectionTarget target) + => TryResolveCollectionTarget( + expandedPath, includeSubfolders, unrestrictedMode, userProfilePath, Directory.Exists, out target); + + internal static bool TryResolveCollectionTarget( + string expandedPath, + bool includeSubfolders, + bool unrestrictedMode, + string userProfilePath, + Func directoryExists, + out CollectionTarget target) + { + target = null; + if (string.IsNullOrWhiteSpace(expandedPath)) + return false; + + try + { + var expanded = Environment.ExpandEnvironmentVariables(expandedPath.Trim()); + var lastSegment = Path.GetFileName(expanded); + + if (lastSegment.Contains('*') || lastSegment.Contains('?')) + { + var parent = Path.GetDirectoryName(expanded); + if (string.IsNullOrEmpty(parent)) + return false; + var folder = TrimTrailingSeparators(Path.GetFullPath(parent)); + if (!IsDiagnosticsPathAllowed(folder, unrestrictedMode, userProfilePath)) + return false; + target = new CollectionTarget(folder, lastSegment, includeSubfolders); + return true; + } + + var full = TrimTrailingSeparators(Path.GetFullPath(expanded)); + if (!IsDiagnosticsPathAllowed(full, unrestrictedMode, userProfilePath)) + return false; + + if (lastSegment.Length == 0 || directoryExists(full)) + { + target = new CollectionTarget(full, "*", includeSubfolders); + return true; + } + + var fileFolder = Path.GetDirectoryName(full); + if (string.IsNullOrEmpty(fileFolder)) + return false; + target = new CollectionTarget(fileFolder, Path.GetFileName(full), recurse: false); + return true; + } + catch + { + target = null; + return false; + } + } + + /// + /// Guard for every subdirectory a recursive collection is about to enter. The start + /// folder passed , but a + /// hard-blocked subtree can sit below an allowed folder (unrestricted mode, drive + /// root). Lexical only: reparse points are skipped by the enumeration and refused + /// again on the handle that is read. + /// + public static bool IsDirectoryEnterable(string directory, bool unrestrictedMode, string userProfilePath) + { + if (string.IsNullOrWhiteSpace(directory)) + return false; + + try + { + return IsNormalizedPathAllowed(Path.GetFullPath(directory), unrestrictedMode, userProfilePath); + } + catch + { + return false; + } + } + + private static string TrimTrailingSeparators(string fullPath) + { + var root = Path.GetPathRoot(fullPath); + var trimmed = fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return root != null && trimmed.Length < root.Length ? root : trimmed; + } + + // Hard blocks, then (restricted mode) the allowlists — on an already normalized path. + private static bool IsNormalizedPathAllowed(string normalizedDir, bool unrestrictedMode, string userProfilePath) + { + // C:\Users block always applies (even in unrestricted mode) + // Exception: paths under \AppData\Local or AppData\Roaming + // are allowed when the %LOGGED_ON_USER_PROFILE% token was used. + if (normalizedDir.StartsWith(BlockedUsersPrefix, StringComparison.OrdinalIgnoreCase) && + (normalizedDir.Length == BlockedUsersPrefix.Length || + normalizedDir[BlockedUsersPrefix.Length] == Path.DirectorySeparatorChar)) + { + if (!GatherRuleGuards.IsUserProfileSubpathAllowed(normalizedDir, userProfilePath)) + return false; + } + + // Additional hard-blocked paths (even in unrestricted mode) + foreach (var blocked in AdditionalHardBlockedPrefixes) + { + var normalizedBlocked = Path.GetFullPath(blocked); + if (normalizedDir.StartsWith(normalizedBlocked, StringComparison.OrdinalIgnoreCase) && + (normalizedDir.Length == normalizedBlocked.Length || + normalizedDir[normalizedBlocked.Length] == Path.DirectorySeparatorChar)) + { + return false; + } + } + + // Hard-blocked event logs (even in unrestricted mode). Only a path that names the + // file is caught here; a wildcard or folder entry is judged per file by + // DiagnosticsPackageService, which is the enforcement point. + if (IsHardBlockedEventLogFile(normalizedDir)) + return false; + + // In unrestricted mode, everything except hard-blocked paths is allowed + if (unrestrictedMode) + return true; + + foreach (var prefix in AllowedDiagnosticsPathPrefixes) + { + var normalizedPrefix = Path.GetFullPath(prefix); + if (normalizedDir.StartsWith(normalizedPrefix, StringComparison.OrdinalIgnoreCase)) + { + // Segment-bounded: next char must be '\' or end-of-string + if (normalizedDir.Length == normalizedPrefix.Length || + normalizedDir[normalizedPrefix.Length] == Path.DirectorySeparatorChar) + { + return true; + } + } + } + + // Vendor folders under the signed-in user's profile — same allowlist the file + // collectors use, since it answers the same question: which folders below a + // profile carry enrollment-relevant logs rather than the user's own data. + return GatherRuleGuards.IsUserProfilePathOnAllowlist(normalizedDir, userProfilePath); + } + + // Windows archives a full channel as "Archive--yyyy-MM-dd-HH-mm-ss-fff.evtx". + private static readonly Regex ArchivedEventLogRegex = new Regex( + @"^Archive-(.+)-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{3}$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + + /// + /// True when the .evtx file name belongs to one of + /// — live file + /// ("Microsoft-Windows-PowerShell%4Operational.evtx", where %4 encodes '/') or + /// archived copy. Judges the NAME only; a channel may write to a file that does not + /// follow the convention, so the packager also checks the channel it resolved. + /// + internal static bool IsHardBlockedEventLogFile(string path) + { + if (string.IsNullOrEmpty(path)) + return false; + + var name = Path.GetFileName(path); + if (!name.EndsWith(".evtx", StringComparison.OrdinalIgnoreCase)) + return false; + + var stem = name.Substring(0, name.Length - ".evtx".Length); + var archived = ArchivedEventLogRegex.Match(stem); + if (archived.Success) + stem = archived.Groups[1].Value; + + return GatherRuleGuards.IsHardBlockedEventLogChannel(stem.Replace("%4", "/")); + } + /// /// Checks whether any component of the path (directories or file) is a symlink, /// junction, or other reparse point. Returns true if a reparse point is detected. diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs index 5f7618f8..472c89c3 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs @@ -451,11 +451,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather var trimmed = channel.Trim(); // Hard blocks apply even in unrestricted mode - foreach (var blocked in HardBlockedEventLogChannels) - { - if (MatchesChannel(trimmed, blocked)) - return false; - } + if (IsHardBlockedEventLogChannel(trimmed)) + return false; if (unrestrictedMode) return true; @@ -463,6 +460,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather return AllowedEventLogChannels.Any(allowed => MatchesChannel(trimmed, allowed)); } + /// + /// True when the channel is one of . Every + /// path that can read an event log asks here — the event-log collector and the + /// diagnostics package alike — so the block cannot hold in one and leak through another. + /// + internal static bool IsHardBlockedEventLogChannel(string channel) + { + if (string.IsNullOrWhiteSpace(channel)) + return false; + + var trimmed = channel.Trim(); + return HardBlockedEventLogChannels.Any(blocked => MatchesChannel(trimmed, blocked)); + } + private static bool MatchesChannel(string channel, string prefix) => channel.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && (channel.Length == prefix.Length || channel[prefix.Length] == '/'); diff --git a/src/Web/autopilot-monitor-web/app/admin/components/DiagnosticsLogPathsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/DiagnosticsLogPathsSection.tsx index 1c71e34f..8055a467 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/DiagnosticsLogPathsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/DiagnosticsLogPathsSection.tsx @@ -79,7 +79,7 @@ export function DiagnosticsLogPathsSection({
setNewDiagPath(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addPath(); } }} diff --git a/src/Web/autopilot-monitor-web/app/settings/components/DiagnosticsSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/DiagnosticsSection.tsx index 7d274e1f..102e94a9 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/DiagnosticsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/DiagnosticsSection.tsx @@ -304,7 +304,7 @@ export default function DiagnosticsSection({
setNewDiagPath(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addTenantPath(); } }} diff --git a/src/Web/autopilot-monitor-web/utils/__tests__/guardValidation.test.ts b/src/Web/autopilot-monitor-web/utils/__tests__/guardValidation.test.ts index 76b9286e..e75b4012 100644 --- a/src/Web/autopilot-monitor-web/utils/__tests__/guardValidation.test.ts +++ b/src/Web/autopilot-monitor-web/utils/__tests__/guardValidation.test.ts @@ -410,6 +410,32 @@ describe("validateDiagnosticsPath", () => { const r = validateDiagnosticsPath("C:\\Windows\\System32\\winevt\\Logs\\System.evtx", false); expect(r.allowed).toBe(true); }); + + it.each([ + "C:\\Windows\\System32\\winevt\\Logs\\Security.evtx", + "C:\\Windows\\System32\\winevt\\Logs\\security.EVTX", + "C:\\Windows\\System32\\winevt\\Logs\\Microsoft-Windows-PowerShell%4Operational.evtx", + "C:\\Windows\\System32\\winevt\\Logs\\Windows PowerShell.evtx", + "C:\\Windows\\System32\\winevt\\Logs\\Microsoft-Windows-Sysmon%4Operational.evtx", + "C:\\Windows\\System32\\winevt\\Logs\\Archive-Security-2026-09-21-10-15-30-123.evtx", + "C:\\Install\\Log\\Security.evtx", + ])("blocks the hard-blocked event log %s in every mode", (path) => { + expect(validateDiagnosticsPath(path, false).allowed).toBe(false); + const unrestricted = validateDiagnosticsPath(path, true); + expect(unrestricted.allowed).toBe(false); + expect(unrestricted.reason).toContain("never readable"); + }); + + it("does not mistake a neighbouring channel for a blocked one", () => { + const dsc = + "C:\\Windows\\System32\\winevt\\Logs\\Microsoft-Windows-PowerShell-DesiredStateConfiguration-FileDownloadManager%4Operational.evtx"; + expect(validateDiagnosticsPath(dsc, false).allowed).toBe(true); + expect(validateDiagnosticsPath("C:\\Windows\\Logs\\SecurityAudit.evtx", false).allowed).toBe(true); + }); + + it("leaves an .evtx wildcard allowed — the agent skips blocked files one by one", () => { + expect(validateDiagnosticsPath("C:\\Windows\\System32\\winevt\\Logs\\*.evtx", false).allowed).toBe(true); + }); }); // --------------------------------------------------------------------------- diff --git a/src/Web/autopilot-monitor-web/utils/guardValidation.ts b/src/Web/autopilot-monitor-web/utils/guardValidation.ts index aec81090..59738f04 100644 --- a/src/Web/autopilot-monitor-web/utils/guardValidation.ts +++ b/src/Web/autopilot-monitor-web/utils/guardValidation.ts @@ -457,6 +457,19 @@ export function validateDiagnosticsPath( return { allowed: false, reason: `${hardBlockedDiag} is always blocked (protected system path)`, unrestricted: false }; } + // Hard-blocked event logs (even in unrestricted mode). Only a path that names the + // file is caught here; for a wildcard the agent skips these files one by one. + const blockedChannel = findBlockedEventLogFile( + normalizedDir.substring(normalizedDir.lastIndexOf("\\") + 1) + ); + if (blockedChannel) { + return { + allowed: false, + reason: `"${blockedChannel}" is never readable — it carries the audit trail or script-block logging`, + unrestricted: false, + }; + } + if (unrestrictedMode) { return { allowed: true, reason: "All paths allowed in unrestricted mode (except blocked system paths)", unrestricted: true }; } @@ -497,6 +510,22 @@ function matchesChannel(channel: string, prefix: string): boolean { return c === p || c.startsWith(p + "/"); } +// Windows archives a full channel as "Archive--yyyy-MM-dd-HH-mm-ss-fff.evtx". +const ARCHIVED_EVENT_LOG = /^Archive-(.+)-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{3}$/i; + +/** + * The blocked channel an .evtx file name belongs to (live or archived file; "%4" in the + * name encodes "/"), or undefined. Mirrors DiagnosticsPathGuards.IsHardBlockedEventLogFile. + */ +function findBlockedEventLogFile(fileName: string): string | undefined { + if (!fileName.toLowerCase().endsWith(".evtx")) return undefined; + let stem = fileName.slice(0, -".evtx".length); + const archived = ARCHIVED_EVENT_LOG.exec(stem); + if (archived) stem = archived[1]; + const channel = stem.replace(/%4/g, "/").trim(); + return BLOCKED_EVENT_LOG_CHANNELS.find((b) => matchesChannel(channel, b)); +} + export function validateEventLogTarget( target: string, unrestrictedMode: boolean