diff --git a/.github/workflows/PullRequest.yml b/.github/workflows/PullRequest.yml index bc2f18fc..7bda43a3 100644 --- a/.github/workflows/PullRequest.yml +++ b/.github/workflows/PullRequest.yml @@ -42,6 +42,7 @@ jobs: shell: pwsh env: EVENTLOG_CONTAINER: "1" + EVENTLOG_ALLOW_CHANNEL_MUTATION: "1" run: | $projects = @(Get-ChildItem tests/Integration -Filter *.csproj -Recurse) if ($projects.Count -eq 0) { throw 'No integration test projects found under tests/Integration.' } diff --git a/compose.yml b/compose.yml index 05882ef7..5be0580c 100644 --- a/compose.yml +++ b/compose.yml @@ -13,6 +13,7 @@ x-base: &base environment: NUGET_PACKAGES: C:/nuget-packages EVENTLOG_CONTAINER: "1" + EVENTLOG_ALLOW_CHANNEL_MUTATION: "1" volumes: - type: bind source: . diff --git a/src/EventLogExpert.Eventing/Interop/EvtVariant.cs b/src/EventLogExpert.Eventing/Interop/EvtVariant.cs index 5402f747..a4d4d9f0 100644 --- a/src/EventLogExpert.Eventing/Interop/EvtVariant.cs +++ b/src/EventLogExpert.Eventing/Interop/EvtVariant.cs @@ -50,4 +50,13 @@ internal readonly record struct EvtVariant [FieldOffset(0)] internal readonly nint XmlValArr; [FieldOffset(8)] internal readonly uint Count; [FieldOffset(12)] internal readonly uint Type; + + // Zero the whole 16-byte union (including the padding between BooleanVal and Count) before setting the + // Boolean, so no indeterminate bytes reach the native EvtSetChannelConfigProperty call. + internal EvtVariant(bool booleanValue) + { + this = default; + BooleanVal = booleanValue ? 1 : 0; + Type = (uint)EvtVariantType.Boolean; + } } diff --git a/src/EventLogExpert.Eventing/Interop/NativeMethods.Evt.cs b/src/EventLogExpert.Eventing/Interop/NativeMethods.Evt.cs index 3681fddc..71ffc307 100644 --- a/src/EventLogExpert.Eventing/Interop/NativeMethods.Evt.cs +++ b/src/EventLogExpert.Eventing/Interop/NativeMethods.Evt.cs @@ -409,6 +409,20 @@ internal static partial bool EvtRender( out int bufferUsed, out int propertyCount); + /// Persists the channel configuration changes made with EvtSetChannelConfigProperty (requires elevation) + [LibraryImport(EventLogApi, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool EvtSaveChannelConfig(EvtHandle channelConfig, int flags); + + /// Sets the specified channel configuration property on the in-memory config handle + [LibraryImport(EventLogApi, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool EvtSetChannelConfigProperty( + EvtHandle channelConfig, + EvtChannelConfigPropertyId propertyId, + int flags, + in EvtVariant propertyValue); + /// /// Creates a subscription that will receive current and future events from a channel or log file that match the /// specified query criteria diff --git a/src/EventLogExpert.Eventing/Writers/ChannelConfigWriter.cs b/src/EventLogExpert.Eventing/Writers/ChannelConfigWriter.cs new file mode 100644 index 00000000..26342b6f --- /dev/null +++ b/src/EventLogExpert.Eventing/Writers/ChannelConfigWriter.cs @@ -0,0 +1,158 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; +using EventLogExpert.Eventing.Readers; +using EventLogExpert.Logging.Abstractions; +using System.Runtime.InteropServices; + +namespace EventLogExpert.Eventing.Writers; + +public sealed class ChannelConfigWriter(ITraceLogger? logger = null) : IChannelConfigWriter +{ + private readonly ITraceLogger? _logger = logger; + + public ChannelEnableResult EnableChannel(string channelName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(channelName); + + using var channelConfig = NativeMethods.EvtOpenChannelConfig( + EventLogSession.GlobalSession.Handle, + channelName, + 0); + + if (channelConfig.IsInvalid) + { + return FromNativeError(Marshal.GetLastWin32Error(), channelName, nameof(NativeMethods.EvtOpenChannelConfig)); + } + + // Read-first, fail-closed: a failed or non-Boolean read must never be treated as "disabled" (that would drive a + // machine write on an unreadable state). Only a successful Boolean read that is already true short-circuits. + if (!TryReadEnabled(channelConfig, channelName, out bool alreadyEnabled, out ChannelEnableResult readFailure)) + { + return readFailure; + } + + if (alreadyEnabled) + { + return new ChannelEnableResult(ChannelEnableOutcome.AlreadyEnabled, Win32ErrorCodes.ERROR_SUCCESS); + } + + var enabledValue = new EvtVariant(true); + + if (!NativeMethods.EvtSetChannelConfigProperty( + channelConfig, + EvtChannelConfigPropertyId.EvtChannelConfigEnabled, + 0, + in enabledValue)) + { + return FromNativeError( + Marshal.GetLastWin32Error(), + channelName, + nameof(NativeMethods.EvtSetChannelConfigProperty)); + } + + return !NativeMethods.EvtSaveChannelConfig(channelConfig, 0) ? + FromNativeError(Marshal.GetLastWin32Error(), + channelName, + nameof(NativeMethods.EvtSaveChannelConfig)) : + // EvtSaveChannelConfig returning true is the persistence guarantee, so the change is committed here. + new ChannelEnableResult(ChannelEnableOutcome.Enabled, Win32ErrorCodes.ERROR_SUCCESS); + } + + private ChannelEnableResult FromNativeError(int win32Error, string channelName, string stage) + { + _logger?.Debug($"{nameof(EnableChannel)}: {stage} failed for {channelName}. Error: {win32Error}"); + + var outcome = win32Error switch + { + Win32ErrorCodes.ERROR_ACCESS_DENIED => ChannelEnableOutcome.AccessDenied, + Win32ErrorCodes.ERROR_EVT_CHANNEL_NOT_FOUND => ChannelEnableOutcome.NotFound, + _ => ChannelEnableOutcome.Failed + }; + + return new ChannelEnableResult(outcome, win32Error); + } + + private unsafe bool TryReadEnabled( + EvtHandle channelConfig, + string channelName, + out bool enabled, + out ChannelEnableResult failure) + { + enabled = false; + failure = null!; + IntPtr buffer = IntPtr.Zero; + + try + { + bool success = NativeMethods.EvtGetChannelConfigProperty( + channelConfig, + EvtChannelConfigPropertyId.EvtChannelConfigEnabled, + 0, + 0, + IntPtr.Zero, + out int bufferSize); + + int sizeError = Marshal.GetLastWin32Error(); + + if (!success && sizeError != Win32ErrorCodes.ERROR_INSUFFICIENT_BUFFER) + { + failure = FromNativeError(sizeError, channelName, nameof(NativeMethods.EvtGetChannelConfigProperty)); + + return false; + } + + buffer = Marshal.AllocHGlobal(bufferSize); + + if (!NativeMethods.EvtGetChannelConfigProperty( + channelConfig, + EvtChannelConfigPropertyId.EvtChannelConfigEnabled, + 0, + bufferSize, + buffer, + out _)) + { + failure = FromNativeError( + Marshal.GetLastWin32Error(), + channelName, + nameof(NativeMethods.EvtGetChannelConfigProperty)); + + return false; + } + + var variant = *(EvtVariant*)buffer; + + if (variant.Type != (uint)EvtVariantType.Boolean) + { + // The read succeeded but the value is not Boolean, so the last Win32 error is not meaningful; surface a + // non-zero sentinel (ERROR_INVALID_DATA) rather than 0 so downstream diagnostics stay actionable. + _logger?.Debug( + $"{nameof(EnableChannel)}: {channelName} enabled property was not Boolean (type {variant.Type})."); + + failure = new ChannelEnableResult(ChannelEnableOutcome.Failed, Win32ErrorCodes.ERROR_INVALID_DATA); + + return false; + } + + enabled = variant.BooleanVal != 0; + return true; + } + catch (Exception ex) when (ex is not OutOfMemoryException + and not StackOverflowException + and not AccessViolationException) + { + _logger?.Debug($"{nameof(EnableChannel)}: unexpected failure reading enabled state for {channelName}.\n{ex}"); + failure = new ChannelEnableResult(ChannelEnableOutcome.Failed, Win32ErrorCodes.ERROR_INVALID_DATA); + + return false; + } + finally + { + if (buffer != IntPtr.Zero) + { + Marshal.FreeHGlobal(buffer); + } + } + } +} diff --git a/src/EventLogExpert.Eventing/Writers/ChannelEnableOutcome.cs b/src/EventLogExpert.Eventing/Writers/ChannelEnableOutcome.cs new file mode 100644 index 00000000..17fad918 --- /dev/null +++ b/src/EventLogExpert.Eventing/Writers/ChannelEnableOutcome.cs @@ -0,0 +1,25 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Eventing.Writers; + +public enum ChannelEnableOutcome +{ + /// The channel was disabled and has now been enabled (the save committed). + Enabled, + + /// The channel was already enabled, so no write was performed. + AlreadyEnabled, + + /// The caller is not elevated; the enable was not attempted (set by the runtime service). + NotElevated, + + /// The channel configuration could not be opened or saved because access was denied. + AccessDenied, + + /// No channel with the requested name is registered on the computer. + NotFound, + + /// The enable failed for any other reason; see the Win32 error code. + Failed +} diff --git a/src/EventLogExpert.Eventing/Writers/ChannelEnableResult.cs b/src/EventLogExpert.Eventing/Writers/ChannelEnableResult.cs new file mode 100644 index 00000000..e11dba9f --- /dev/null +++ b/src/EventLogExpert.Eventing/Writers/ChannelEnableResult.cs @@ -0,0 +1,6 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Eventing.Writers; + +public sealed record ChannelEnableResult(ChannelEnableOutcome Outcome, int Win32Error); diff --git a/src/EventLogExpert.Eventing/Writers/IChannelConfigWriter.cs b/src/EventLogExpert.Eventing/Writers/IChannelConfigWriter.cs new file mode 100644 index 00000000..52210853 --- /dev/null +++ b/src/EventLogExpert.Eventing/Writers/IChannelConfigWriter.cs @@ -0,0 +1,13 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Eventing.Writers; + +public interface IChannelConfigWriter +{ + /// + /// Enables a disabled event log channel by persisting EvtChannelConfigEnabled = true. Requires an elevated + /// process; without elevation a native call fails and the result is . + /// + ChannelEnableResult EnableChannel(string channelName); +} diff --git a/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs b/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs index f56aae53..c9cc52ff 100644 --- a/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs +++ b/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using EventLogExpert.DatabaseTools.DependencyInjection; using EventLogExpert.Eventing.Readers; +using EventLogExpert.Eventing.Writers; using EventLogExpert.Logging.Abstractions; using EventLogExpert.Logging.Configuration; using EventLogExpert.Logging.Routing; @@ -276,9 +277,12 @@ public IServiceCollection AddEventLogRuntime() services.AddSingleton(); services.AddSingleton(static sp => new EventLogChannelConfigReader(CategoryLogger(sp, LogCategories.EventLog))); + services.AddSingleton(static sp => + new ChannelConfigWriter(CategoryLogger(sp, LogCategories.EventLog))); services.AddSingleton(); services.AddSingleton(static sp => sp.GetRequiredService()); services.AddSingleton(static sp => sp.GetRequiredService()); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/EventLogExpert.Runtime/Scenarios/ChannelEnableService.cs b/src/EventLogExpert.Runtime/Scenarios/ChannelEnableService.cs new file mode 100644 index 00000000..3e439293 --- /dev/null +++ b/src/EventLogExpert.Runtime/Scenarios/ChannelEnableService.cs @@ -0,0 +1,44 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Writers; +using EventLogExpert.Runtime.Common.Identity; + +namespace EventLogExpert.Runtime.Scenarios; + +internal sealed class ChannelEnableService( + IChannelConfigWriter channelConfigWriter, + IChannelReadinessService readinessService, + IWindowsIdentityProvider identityProvider) : IChannelEnableService +{ + private readonly Lazy _canEnable = new(identityProvider.IsUserInAdministratorRole); + + public bool CanEnable => _canEnable.Value; + + public async Task EnableAsync(string channel, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(channel); + + // Do not attempt a write we know will be denied; a non-elevated caller never reaches the native layer. + if (!CanEnable) + { + return new ChannelEnableResult(ChannelEnableOutcome.NotElevated, 0); + } + + // Cancellation is honored only up to the moment the native write begins: the token guards both the + // synchronous entry and the Task.Run scheduling boundary, so a cancel that arrives before the writer runs + // prevents the write. Once EnableChannel starts it runs to completion (Task.Run stops consulting the token + // once the delegate is executing), so a committed machine change is never reported as cancelled. + cancellationToken.ThrowIfCancellationRequested(); + + var result = await Task.Run(() => channelConfigWriter.EnableChannel(channel), cancellationToken) + .ConfigureAwait(false); + + if (result.Outcome is ChannelEnableOutcome.Enabled or ChannelEnableOutcome.AlreadyEnabled) + { + readinessService.Invalidate(); + } + + return result; + } +} diff --git a/src/EventLogExpert.Runtime/Scenarios/IChannelEnableService.cs b/src/EventLogExpert.Runtime/Scenarios/IChannelEnableService.cs new file mode 100644 index 00000000..4f3ed28b --- /dev/null +++ b/src/EventLogExpert.Runtime/Scenarios/IChannelEnableService.cs @@ -0,0 +1,17 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Writers; + +namespace EventLogExpert.Runtime.Scenarios; + +public interface IChannelEnableService +{ + /// + /// Whether an enable can be ATTEMPTED (the process is elevated). A true value is not a guarantee that the write + /// will succeed - a channel-specific ACL can still deny an elevated caller. + /// + bool CanEnable { get; } + + Task EnableAsync(string channel, CancellationToken cancellationToken = default); +} diff --git a/src/EventLogExpert.UI/Common/EnableChannelConfirmation.cs b/src/EventLogExpert.UI/Common/EnableChannelConfirmation.cs new file mode 100644 index 00000000..05d0c390 --- /dev/null +++ b/src/EventLogExpert.UI/Common/EnableChannelConfirmation.cs @@ -0,0 +1,26 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Alerts; + +namespace EventLogExpert.UI.Common; + +internal static class EnableChannelConfirmation +{ + public static Task ConfirmAsync(IAlertDialogService dialog, string channelName, bool isAnalyticOrDebug) + { + var message = + $"Enable the \"{channelName}\" log?\n\n" + + "This changes Windows event logging for the whole computer and stays in effect until it is disabled again. " + + "Only events generated after enabling are collected - events that occurred while the log was disabled cannot be recovered."; + + if (isAnalyticOrDebug) + { + message += + "\n\nThis is an analytic or debug log: enabling it can clear the records it already holds, and Windows may " + + "keep collecting to it while refusing to open it for live viewing until it is disabled again."; + } + + return dialog.ShowAlert("Enable log", message, "Enable", "Cancel"); + } +} diff --git a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor index b06597a3..85a9f6b4 100644 --- a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor +++ b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor @@ -27,52 +27,52 @@

Quick Launch

- + OnClick="OpenApplicationAndSystemAsync" + aria-label="Open Application + System (live)"> Application + System (live) - + OnClick="OpenApplicationAsync" + aria-label="Open Application (live)"> Application (live) - + OnClick="OpenSystemAsync" + aria-label="Open System (live)"> System (live) @if (!SecurityRequiresElevation) { - + OnClick="OpenSecurityAsync" + aria-label="Open Security (live)"> Security (live) } else { - + aria-label="Open Security (live)"> Security (live) } - + Log file... - + Folder...
@@ -89,14 +89,16 @@ } else { - + - channel.Channel, StringComparer.OrdinalIgnoreCase); - _livePresence = LivePresence.FromReadiness(readiness); + await RefreshReadinessAsync(); RebuildCategories(); if (_categories.Count > 0 && !_categories.Any(category => category.Category.Equals(_activeCategory))) @@ -161,6 +163,15 @@ scenarios is null .SelectMany(static scenario => scenario.Channels.Concat(scenario.OptionalChannels)) .Distinct(StringComparer.OrdinalIgnoreCase); + private static string DescribeEnableFailure(string channel, ChannelEnableResult result) => result.Outcome switch + { + ChannelEnableOutcome.AccessDenied => + $"Enabling \"{channel}\" was denied. Administrator rights are required, and the log's security settings must allow the change.", + ChannelEnableOutcome.NotFound => $"The \"{channel}\" log is not registered on this computer.", + ChannelEnableOutcome.NotElevated => $"Run EventLogExpert as administrator to enable \"{channel}\".", + _ => $"The \"{channel}\" log could not be enabled (error {result.Win32Error})." + }; + private static string? DescribeFolderLaunch(ScenarioDefinition scenario, ScenarioFolderLaunchResult result) { var scanNote = result.Unreadable > 0 ? $" {FolderFilesWord(result.Unreadable)} could not be inspected." : string.Empty; @@ -240,6 +251,40 @@ private bool AccessAllowsLaunch(string channel) => private void ClearFilter() => FilterCommands.ClearAllFilters(); + private Task EnableChannelAsync(string channel) => + RunGuardedAsync(async () => + { + bool isAnalyticOrDebug = _readinessByChannel.TryGetValue(channel, out var current) + && current.Access == ChannelAccess.NotEvaluated; + + if (!await EnableChannelConfirmation.ConfirmAsync(AlertDialogService, channel, isAnalyticOrDebug)) + { + return; + } + + var result = await ChannelEnable.EnableAsync(channel); + + if (result.Outcome is not (ChannelEnableOutcome.Enabled or ChannelEnableOutcome.AlreadyEnabled)) + { + await AlertDialogService.ShowErrorAlert("Enable log", DescribeEnableFailure(channel, result)); + + return; + } + + // The committed change is authoritative; re-run the full readiness fetch (never a single-channel probe, + // which would make LivePresence treat one channel as the complete set) so the pill reflects real state. + await RefreshReadinessAsync(); + + if (!_readinessByChannel.TryGetValue(channel, out var refreshed) + || refreshed.Enablement != ChannelEnablement.Enabled) + { + await AlertDialogService.ShowAlert( + "Enable log", + $"\"{channel}\" was enabled, but its status could not be confirmed. Refresh to re-check.", + "OK"); + } + }); + private IEnumerable FavoriteScenarios() => _splashScenarios is null ? [] @@ -413,6 +458,13 @@ private void ReconcileActiveTab() _pendingTabFocus = true; } + private async Task RefreshReadinessAsync() + { + var readiness = await ChannelReadinessService.GetReadinessAsync(CatalogChannels(_splashScenarios)); + _readinessByChannel = readiness.ToDictionary(channel => channel.Channel, StringComparer.OrdinalIgnoreCase); + _livePresence = LivePresence.FromReadiness(readiness); + } + private async Task RunGuardedAsync(Func action) { if (_isBusy) { return; } diff --git a/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor b/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor index ccef048a..18d92562 100644 --- a/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor +++ b/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor @@ -26,11 +26,13 @@
@if (Selected is not null) { - > GetChannelReadiness { get; set; } = static _ => []; @@ -32,6 +34,8 @@ public sealed partial class ScenarioBrowserPanel : IAsyncDisposable [Parameter][EditorRequired] public Func IsScenarioDisabled { get; set; } = static _ => false; + [Parameter] public EventCallback OnEnableChannel { get; set; } + [Parameter] public EventCallback OnLaunch { get; set; } [Parameter] public EventCallback OnLaunchFromFolder { get; set; } diff --git a/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor b/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor index f746d373..519d19e6 100644 --- a/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor +++ b/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor @@ -30,6 +30,25 @@ @readiness.Channel @StatusTags(readiness) + @if (CanOfferEnable(readiness)) + { + @if (CanEnableChannels) + { + + } + else + { + + + This instance is not elevated. Close it, start EventLogExpert as administrator, then return to enable this channel. + + } + } } @if (DisplayOptionalReadiness.Count > 0) @@ -75,24 +94,24 @@

}
- - + + OnClick="LaunchAsync" + aria-describedby="@(IsDisabled ? _offlineId : null)" + aria-disabled="@(IsDisabled ? "true" : "false")"> Launch - + OnClick="LaunchFromFolderAsync" + aria-label="@($"Open {Scenario.Name} from a folder of exported logs")"> Open from folder
diff --git a/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor.cs b/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor.cs index f0cb4af1..05115897 100644 --- a/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor.cs +++ b/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor.cs @@ -1,6 +1,7 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. +using EventLogExpert.Eventing.Common.Channels; using EventLogExpert.Filtering.Basic; using EventLogExpert.Filtering.Persistence; using EventLogExpert.Runtime.Scenarios; @@ -15,6 +16,8 @@ public sealed partial class ScenarioDetail private readonly string _nameId = ComponentId.NewUnique().Value; private readonly string _offlineId = ComponentId.NewUnique().Value; + [Parameter] public bool CanEnableChannels { get; set; } + [Parameter] public IReadOnlyList ChannelReadiness { get; set; } = []; [Parameter] public bool IsBusy { get; set; } @@ -25,6 +28,8 @@ public sealed partial class ScenarioDetail [Parameter] public bool IsLivePresent { get; set; } = true; + [Parameter] public EventCallback OnEnableChannel { get; set; } + [Parameter] public EventCallback OnLaunch { get; set; } [Parameter] public EventCallback OnLaunchFromFolder { get; set; } @@ -76,6 +81,11 @@ private IReadOnlyList FilterLines _ => "Enablement unknown" }; + private static bool IsSystemChannel(string channel) => + string.Equals(channel, LogChannelNames.ApplicationLog, StringComparison.OrdinalIgnoreCase) || + string.Equals(channel, LogChannelNames.SystemLog, StringComparison.OrdinalIgnoreCase) || + string.Equals(channel, LogChannelNames.SecurityLog, StringComparison.OrdinalIgnoreCase); + private static string PresenceLabel(ChannelPresence presence) => presence switch { ChannelPresence.Present => "Present", @@ -83,6 +93,13 @@ private IReadOnlyList FilterLines _ => "Presence unknown" }; + // A disabled required channel can be enabled in place only when it is actually present and is not one of the classic + // system logs (Application/System/Security), which Windows does not allow toggling. + private bool CanOfferEnable(ChannelReadiness readiness) => + readiness.Presence == ChannelPresence.Present && + readiness.Enablement == ChannelEnablement.Disabled && + !IsSystemChannel(readiness.Channel); + private async Task LaunchAsync() { if (IsDisabled) { return; } diff --git a/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor.css b/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor.css index 715a800c..f1ad7d0e 100644 --- a/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor.css +++ b/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor.css @@ -98,6 +98,35 @@ color: var(--text-secondary); } +.scenario-detail__enable { + align-self: center; + padding: 0.1rem 0.5rem; + border: 1px solid var(--border-divider); + border-radius: 999px; + background: transparent; + color: var(--text-secondary); + font-size: 0.78rem; + cursor: pointer; +} + +.scenario-detail__enable:hover:not(:disabled), +.scenario-detail__enable:focus-visible { + background: color-mix(in srgb, var(--text-secondary) 12%, transparent); +} + +.scenario-detail__enable:disabled { + opacity: 0.6; + cursor: default; +} + +.scenario-detail__enable-hint { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.78rem; + color: color-mix(in srgb, var(--text-secondary) 85%, transparent); +} + .scenario-detail__admin-badge { display: inline-flex; align-items: center; diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Writers/ChannelConfigWriterIntegrationTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Writers/ChannelConfigWriterIntegrationTests.cs new file mode 100644 index 00000000..869f3d2a --- /dev/null +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Writers/ChannelConfigWriterIntegrationTests.cs @@ -0,0 +1,173 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Channels; +using EventLogExpert.Eventing.Interop; +using EventLogExpert.Eventing.Readers; +using EventLogExpert.Eventing.Writers; +using System.Diagnostics; +using System.Security.Principal; +using System.Text; + +namespace EventLogExpert.Eventing.IntegrationTests.Writers; + +public sealed class ChannelConfigWriterIntegrationTests +{ + [Fact] + public void EnableChannel_WhenChannelAlreadyEnabled_ReturnsAlreadyEnabled() + { + // The classic Application log is always enabled; the read-first short-circuit returns without any write. + var writer = new ChannelConfigWriter(); + + var result = writer.EnableChannel(LogChannelNames.ApplicationLog); + + Assert.Equal(ChannelEnableOutcome.AlreadyEnabled, result.Outcome); + } + + [Fact] + public void EnableChannel_WhenChannelDisabled_EnablesThenRestoresOriginalState() + { + if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("EVENTLOG_ALLOW_CHANNEL_MUTATION"))) + { + Assert.Skip( + "Enabling a channel is an irreversible machine-wide mutation (it can clear an analytic/debug channel's " + + "records), so it needs an explicit opt-in beyond EVENTLOG_CONTAINER. The container and CI set " + + "EVENTLOG_ALLOW_CHANNEL_MUTATION automatically; set it manually only in an ephemeral/throwaway environment."); + } + + if (!IsElevated()) + { + Assert.Skip("Enabling a channel persists a machine-wide change and requires an elevated process."); + } + + using var reader = new EventLogChannelConfigReader(); + var channel = TryFindDisabledNonClassicChannel(reader); + + if (channel is null) + { + Assert.Skip("No disabled analytic/debug channel was available to exercise the enable path."); + } + + var writer = new ChannelConfigWriter(); + + try + { + var result = writer.EnableChannel(channel); + + Assert.Equal(ChannelEnableOutcome.Enabled, result.Outcome); + Assert.True(reader.ReadConfig(channel).Enabled); + } + finally + { + RestoreOriginalDisabledStateOrThrow(reader, channel); + } + } + + [Fact] + public void EnableChannel_WhenChannelNotFound_ReturnsNotFound() + { + var writer = new ChannelConfigWriter(); + + var result = writer.EnableChannel("EventLogExpert-Nonexistent-Channel/DoesNotExist"); + + Assert.Equal(ChannelEnableOutcome.NotFound, result.Outcome); + } + + private static IEnumerable EnumerateChannels() + { + var output = RunWevtutil("el"); + + if (output is null) { yield break; } + + foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + yield return line; + } + } + + private static bool IsElevated() + { + using WindowsIdentity identity = WindowsIdentity.GetCurrent(); + + return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); + } + + private static void RestoreOriginalDisabledStateOrThrow(EventLogChannelConfigReader reader, string channel) + { + // The production writer is enable-only, so the test owns the disable via wevtutil. Verify the restore landed + // and fail loudly if it did not, so a mutation test never silently leaves the machine in a changed state. + var restored = SetChannelDisabled(channel); + + if (!restored || reader.ReadConfig(channel).Enabled is not false) + { + throw new InvalidOperationException( + $"Failed to restore channel '{channel}' to its original disabled state; the machine may be left mutated."); + } + } + + private static string? RunWevtutil(string arguments) + { + try + { + using var process = new Process + { + StartInfo = new ProcessStartInfo("wevtutil.exe", arguments) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + var stdout = new StringBuilder(); + + // Drain both streams via the async event model so a full stderr buffer cannot deadlock the child, and + // enforce the timeout by killing the process rather than blocking indefinitely on a synchronous read. + process.OutputDataReceived += (_, e) => { if (e.Data is not null) { stdout.AppendLine(e.Data); } }; + process.ErrorDataReceived += (_, _) => { }; + + if (!process.Start()) { return null; } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + if (!process.WaitForExit(30_000)) + { + try { process.Kill(entireProcessTree: true); } catch { /* best effort */ } + + return null; + } + + // Flush the async output handlers before reading the accumulated output. + process.WaitForExit(); + + return process.ExitCode == 0 ? stdout.ToString() : null; + } + catch + { + return null; + } + } + + private static bool SetChannelDisabled(string channel) => + RunWevtutil($"sl \"{channel}\" /e:false") is not null; + + private static string? TryFindDisabledNonClassicChannel(EventLogChannelConfigReader reader) + { + foreach (var channel in EnumerateChannels()) + { + ChannelConfig config; + + try { config = reader.ReadConfig(channel); } + catch { continue; } + + if (config.Enabled == false && config.Type is EvtChannelType.Analytic or EvtChannelType.Debug) + { + return channel; + } + } + + return null; + } +} diff --git a/tests/Integration/README.md b/tests/Integration/README.md index a84e51ad..cf146475 100644 --- a/tests/Integration/README.md +++ b/tests/Integration/README.md @@ -17,6 +17,14 @@ throws `InvalidOperationException` when the `EVENTLOG_CONTAINER` environment var missing. This is the **second gate** — even if the runsettings filter is bypassed, the fixture ensures tests fail loudly rather than silently polluting your local Application event log. +A small number of tests make an **irreversible machine-wide mutation**. The current example is the +channel-enable round-trip in `Writers/ChannelConfigWriterIntegrationTests`, which enables a real +disabled analytic/debug channel (enabling such a channel can clear its records). These require a +**third gate**: the `EVENTLOG_ALLOW_CHANNEL_MUTATION` environment variable. `compose.yml` and the +CI workflow set it automatically because those environments are ephemeral. Setting +`EVENTLOG_CONTAINER=1` alone (including via the host opt-in below) deliberately does **not** run +these tests, so a developer host is never mutated. + ### To run integration tests Use the provided script, which handles Docker daemon mode switching automatically. diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ChannelEnableServiceTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ChannelEnableServiceTests.cs new file mode 100644 index 00000000..83f0793b --- /dev/null +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ChannelEnableServiceTests.cs @@ -0,0 +1,117 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Writers; +using EventLogExpert.Runtime.Common.Identity; +using EventLogExpert.Runtime.Scenarios; +using NSubstitute; + +namespace EventLogExpert.Runtime.Tests.Scenarios; + +public sealed class ChannelEnableServiceTests +{ + private readonly IWindowsIdentityProvider _identity = Substitute.For(); + private readonly IChannelReadinessService _readiness = Substitute.For(); + private readonly IChannelConfigWriter _writer = Substitute.For(); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public void CanEnable_ReflectsAdministratorRole() + { + _identity.IsUserInAdministratorRole().Returns(true); + + Assert.True(Service().CanEnable); + } + + [Fact] + public async Task EnableAsync_WhenAlreadyEnabled_InvalidatesReadiness() + { + _identity.IsUserInAdministratorRole().Returns(true); + _writer.EnableChannel("Microsoft-Windows-Sample/Operational") + .Returns(new ChannelEnableResult(ChannelEnableOutcome.AlreadyEnabled, 0)); + + var result = await Service().EnableAsync("Microsoft-Windows-Sample/Operational", Ct); + + Assert.Equal(ChannelEnableOutcome.AlreadyEnabled, result.Outcome); + _readiness.Received(1).Invalidate(); + } + + [Fact] + public async Task EnableAsync_WhenCancelledAfterSaveCommits_StillReturnsEnabledAndInvalidates() + { + _identity.IsUserInAdministratorRole().Returns(true); + using var cts = new CancellationTokenSource(); + + // The commit lands, then cancellation arrives during the native call; the service must not report the + // committed change as cancelled, and must still invalidate. + _writer.EnableChannel("Microsoft-Windows-Sample/Operational").Returns(_ => + { + cts.Cancel(); + + return new ChannelEnableResult(ChannelEnableOutcome.Enabled, 0); + }); + + var result = await Service().EnableAsync("Microsoft-Windows-Sample/Operational", cts.Token); + + Assert.Equal(ChannelEnableOutcome.Enabled, result.Outcome); + _readiness.Received(1).Invalidate(); + } + + [Fact] + public async Task EnableAsync_WhenCancelledBeforeNativeOp_ThrowsWithoutWriting() + { + _identity.IsUserInAdministratorRole().Returns(true); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + () => Service().EnableAsync("Microsoft-Windows-Sample/Operational", cts.Token)); + + _writer.DidNotReceive().EnableChannel(Arg.Any()); + _readiness.DidNotReceive().Invalidate(); + } + + [Fact] + public async Task EnableAsync_WhenEnabled_InvalidatesReadiness() + { + _identity.IsUserInAdministratorRole().Returns(true); + _writer.EnableChannel("Microsoft-Windows-Sample/Operational") + .Returns(new ChannelEnableResult(ChannelEnableOutcome.Enabled, 0)); + + var result = await Service().EnableAsync("Microsoft-Windows-Sample/Operational", Ct); + + Assert.Equal(ChannelEnableOutcome.Enabled, result.Outcome); + _readiness.Received(1).Invalidate(); + } + + [Fact] + public async Task EnableAsync_WhenNotElevated_ReturnsNotElevatedWithoutWritingOrInvalidating() + { + _identity.IsUserInAdministratorRole().Returns(false); + + var result = await Service().EnableAsync("Microsoft-Windows-Sample/Operational", Ct); + + Assert.Equal(ChannelEnableOutcome.NotElevated, result.Outcome); + _writer.DidNotReceive().EnableChannel(Arg.Any()); + _readiness.DidNotReceive().Invalidate(); + } + + [Theory] + [InlineData(ChannelEnableOutcome.AccessDenied)] + [InlineData(ChannelEnableOutcome.NotFound)] + [InlineData(ChannelEnableOutcome.Failed)] + public async Task EnableAsync_WhenWriteFails_DoesNotInvalidate(ChannelEnableOutcome outcome) + { + _identity.IsUserInAdministratorRole().Returns(true); + _writer.EnableChannel("Microsoft-Windows-Sample/Operational") + .Returns(new ChannelEnableResult(outcome, 5)); + + var result = await Service().EnableAsync("Microsoft-Windows-Sample/Operational", Ct); + + Assert.Equal(outcome, result.Outcome); + _readiness.DidNotReceive().Invalidate(); + } + + private ChannelEnableService Service() => new(_writer, _readiness, _identity); +} diff --git a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs index 4f32b559..c599c1ae 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs @@ -4,6 +4,7 @@ using AngleSharp.Dom; using Bunit; using EventLogExpert.Eventing.Readers; +using EventLogExpert.Eventing.Writers; using EventLogExpert.Filtering.Evaluation; using EventLogExpert.Runtime.Alerts; using EventLogExpert.Runtime.Announcement; @@ -32,6 +33,7 @@ public sealed class EmptyStateDashboardTests : BunitContext private readonly IMenuActionService _actions = Substitute.For(); private readonly IAlertDialogService _alertDialog = Substitute.For(); private readonly IAnnouncementService _announcer = Substitute.For(); + private readonly IChannelEnableService _channelEnable = Substitute.For(); private readonly IChannelReadinessService _channelReadinessService = Substitute.For(); private readonly IScenarioFavoriteCommands _favoriteCommands = Substitute.For(); private readonly IState _favorites = Substitute.For>(); @@ -70,6 +72,7 @@ public EmptyStateDashboardTests() Services.AddSingleton(_scenarioLaunch); Services.AddSingleton(_scenarioQuery); Services.AddSingleton(_channelReadinessService); + Services.AddSingleton(_channelEnable); Services.AddFluxor(options => options.ScanAssemblies(typeof(EmptyStateDashboard).Assembly)); JSInterop.Mode = JSRuntimeMode.Loose; } @@ -384,6 +387,49 @@ public void DetailStar_TogglesFavorite() _favoriteCommands.Received(1).SetFavorite("application-crashes", "Application crashes", true)); } + [Fact] + public void EnableChannel_WhenConfirmCancelled_DoesNotInvokeService() + { + _channelEnable.CanEnable.Returns(true); + _alertDialog.ShowAlert(Arg.Any(), Arg.Any(), "Enable", "Cancel").Returns(false); + _channelReadinessService.GetReadinessAsync(Arg.Any>(), Arg.Any()) + .Returns([new ChannelReadiness("Microsoft-Windows-Sample/Operational", ChannelPresence.Present, ChannelEnablement.Disabled)]); + _scenarioQuery.GetSplashScenarios() + .Returns([Scenario("test", "Test") with { Channels = ["Microsoft-Windows-Sample/Operational"] }]); + + var cut = Render(); + cut.WaitForAssertion(() => + Assert.NotEmpty(cut.FindAll(".sidebar-tabs-tabpanel.active .scenario-detail__enable"))); + cut.Find(".sidebar-tabs-tabpanel.active .scenario-detail__enable").Click(); + + _channelEnable.DidNotReceive().EnableAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public void EnableChannel_WhenConfirmedAndEnabled_InvokesServiceAndRefreshesReadiness() + { + _channelEnable.CanEnable.Returns(true); + _channelEnable.EnableAsync("Microsoft-Windows-Sample/Operational", Arg.Any()) + .Returns(new ChannelEnableResult(ChannelEnableOutcome.Enabled, 0)); + _alertDialog.ShowAlert(Arg.Any(), Arg.Any(), "Enable", "Cancel").Returns(true); + _channelReadinessService.GetReadinessAsync(Arg.Any>(), Arg.Any()) + .Returns( + [new ChannelReadiness("Microsoft-Windows-Sample/Operational", ChannelPresence.Present, ChannelEnablement.Disabled)], + [new ChannelReadiness("Microsoft-Windows-Sample/Operational", ChannelPresence.Present, ChannelEnablement.Enabled)]); + _scenarioQuery.GetSplashScenarios() + .Returns([Scenario("test", "Test") with { Channels = ["Microsoft-Windows-Sample/Operational"] }]); + + var cut = Render(); + cut.WaitForAssertion(() => + Assert.NotEmpty(cut.FindAll(".sidebar-tabs-tabpanel.active .scenario-detail__enable"))); + cut.Find(".sidebar-tabs-tabpanel.active .scenario-detail__enable").Click(); + + cut.WaitForAssertion(() => + _channelEnable.Received(1).EnableAsync("Microsoft-Windows-Sample/Operational", Arg.Any())); + cut.WaitForAssertion(() => + Assert.Empty(cut.FindAll(".sidebar-tabs-tabpanel.active .scenario-detail__enable"))); + } + [Fact] public void Favorites_LoadDispatchedOnFirstRender() { diff --git a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/ScenarioDetailTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/ScenarioDetailTests.cs index 6b29fed3..0f19fce8 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/ScenarioDetailTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/ScenarioDetailTests.cs @@ -42,6 +42,121 @@ public void BlockedNote_WhenDisabledAndLivePresent_IsShown() Assert.Contains("Open from folder", note.TextContent); } + [Fact] + public void EnableButton_WhenAdminAndDisabledRequiredChannelPresent_IsShown() + { + var cut = Render(parameters => parameters + .Add(detail => detail.Scenario, Scenario("test", "Test") with { Channels = ["Microsoft-Windows-Sample/Operational"] }) + .Add(detail => detail.CanEnableChannels, true) + .Add(detail => detail.ChannelReadiness, + [ + new ChannelReadiness("Microsoft-Windows-Sample/Operational", ChannelPresence.Present, ChannelEnablement.Disabled) + ])); + + Assert.Single(cut.FindAll(".scenario-detail__enable")); + Assert.Empty(cut.FindAll(".scenario-detail__enable-hint")); + } + + [Fact] + public void EnableButton_WhenChannelAbsent_IsAbsent() + { + var cut = Render(parameters => parameters + .Add(detail => detail.Scenario, Scenario("test", "Test") with { Channels = ["Microsoft-Windows-Sample/Operational"] }) + .Add(detail => detail.CanEnableChannels, true) + .Add(detail => detail.ChannelReadiness, + [ + new ChannelReadiness("Microsoft-Windows-Sample/Operational", ChannelPresence.Absent, ChannelEnablement.Disabled) + ])); + + Assert.Empty(cut.FindAll(".scenario-detail__enable")); + Assert.Empty(cut.FindAll(".scenario-detail__enable-hint")); + } + + [Fact] + public void EnableButton_WhenChannelEnabled_IsAbsent() + { + var cut = Render(parameters => parameters + .Add(detail => detail.Scenario, Scenario("test", "Test") with { Channels = ["Microsoft-Windows-Sample/Operational"] }) + .Add(detail => detail.CanEnableChannels, true) + .Add(detail => detail.ChannelReadiness, + [ + new ChannelReadiness("Microsoft-Windows-Sample/Operational", ChannelPresence.Present, ChannelEnablement.Enabled) + ])); + + Assert.Empty(cut.FindAll(".scenario-detail__enable")); + } + + [Fact] + public void EnableButton_WhenClicked_InvokesOnEnableChannelWithChannel() + { + string? captured = null; + + var cut = Render(parameters => parameters + .Add(detail => detail.Scenario, Scenario("test", "Test") with { Channels = ["Microsoft-Windows-Sample/Operational"] }) + .Add(detail => detail.CanEnableChannels, true) + .Add(detail => detail.ChannelReadiness, + [ + new ChannelReadiness("Microsoft-Windows-Sample/Operational", ChannelPresence.Present, ChannelEnablement.Disabled) + ]) + .Add(detail => detail.OnEnableChannel, channel => captured = channel)); + + cut.Find(".scenario-detail__enable").Click(); + + Assert.Equal("Microsoft-Windows-Sample/Operational", captured); + } + + [Fact] + public void EnableButton_WhenOnlyOptionalChannelDisabled_IsAbsent() + { + var cut = Render(parameters => parameters + .Add(detail => detail.Scenario, Scenario("test", "Test") with + { + Channels = ["Microsoft-Windows-Sample/Operational"], + OptionalChannels = ["Microsoft-Windows-Other/Operational"] + }) + .Add(detail => detail.CanEnableChannels, true) + .Add(detail => detail.ChannelReadiness, + [ + new ChannelReadiness("Microsoft-Windows-Sample/Operational", ChannelPresence.Present, ChannelEnablement.Enabled) + ]) + .Add(detail => detail.OptionalChannelReadiness, + [ + new ChannelReadiness("Microsoft-Windows-Other/Operational", ChannelPresence.Present, ChannelEnablement.Disabled) + ])); + + Assert.Empty(cut.FindAll(".scenario-detail__enable")); + } + + [Fact] + public void EnableButton_WhenSystemChannelDisabled_IsAbsent() + { + var cut = Render(parameters => parameters + .Add(detail => detail.Scenario, Scenario("test", "Test") with { Channels = ["Application"] }) + .Add(detail => detail.CanEnableChannels, true) + .Add(detail => detail.ChannelReadiness, + [ + new ChannelReadiness("Application", ChannelPresence.Present, ChannelEnablement.Disabled) + ])); + + Assert.Empty(cut.FindAll(".scenario-detail__enable")); + Assert.Empty(cut.FindAll(".scenario-detail__enable-hint")); + } + + [Fact] + public void EnableHint_WhenNotAdminAndDisabledRequiredChannelPresent_IsShown() + { + var cut = Render(parameters => parameters + .Add(detail => detail.Scenario, Scenario("test", "Test") with { Channels = ["Microsoft-Windows-Sample/Operational"] }) + .Add(detail => detail.CanEnableChannels, false) + .Add(detail => detail.ChannelReadiness, + [ + new ChannelReadiness("Microsoft-Windows-Sample/Operational", ChannelPresence.Present, ChannelEnablement.Disabled) + ])); + + Assert.Empty(cut.FindAll(".scenario-detail__enable")); + Assert.Contains("start EventLogExpert as administrator", cut.Find(".scenario-detail__enable-hint").TextContent); + } + [Fact] public void Facts_ShowsRequiredChannels() {