From 127857a1f86cd0bb54dc2da2df6dd7aa1ee925c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:02:14 +0000 Subject: [PATCH 1/4] Add configurable logon failures/logoff events; fix RDP logons on standalone servers Co-authored-by: HakanL <407941+HakanL@users.noreply.github.com> --- README.md | 23 +- Seq.Client.WindowsLogins.Tests/EventTests.cs | 108 ++++++++ Seq.Client.WindowsLogins/App.config | 4 + Seq.Client.WindowsLogins/Config.cs | 4 + Seq.Client.WindowsLogins/EventLogListener.cs | 270 ++++++++++++++++++- 5 files changed, 400 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4e1f078..d4acac9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This Windows service started life as a fork of the [Seq.Client.EventLog](https://github.com/c0shea/Seq.Client.EventLog) app for [Seq](https://getseq.net/), but has diverged quite a long way; nonetheless it absolutely owes DNA to the original. -This substantially modifies the client to a service that looks for successful interactive user logins and raises a nicely formatted event with the data extracted as structured properties. +This substantially modifies the client to a service that looks for interactive user logins and raises a nicely formatted event with the data extracted as structured properties. It can optionally also capture logon failures and logoff events. ## Get Started @@ -13,6 +13,27 @@ This substantially modifies the client to a service that looks for successful in 5. From the command line, run ```net start Seq.Client.WindowsLogins``` to start the service. 6. Click the refresh button in Seq as you wait anxiously for the events to start flooding in! +## Configuration Options + +The following options can be set in `Seq.Client.WindowsLogins.exe.config`: + +| Setting | Default | Description | +|---|---|---| +| `AppName` | `Seq.Client.WindowsLogins` | App name used for logging | +| `LogSeqServer` | *(required)* | URL of your Seq server | +| `LogSeqApiKey` | *(empty)* | Seq API key (leave blank if not used) | +| `LogFolder` | *(app dir)* | Folder for local file logs | +| `HeartbeatInterval` | `600` | Heartbeat log interval in seconds (0 disables) | +| `IsDebug` | `false` | Include extra detail in heartbeat log entries | +| `IncludeLogonFailures` | `false` | Capture failed logon events (Event ID 4625) | +| `IncludeLogoffEvents` | `false` | Capture logoff events (Event IDs 4634 and 4647) | + +### Notes on logon filtering + +Interactive logons are identified by **LogonType 2** (console) and **LogonType 10** (Remote Desktop / RDP). Non-interactive logons (services, batch jobs, network shares, etc.) are excluded. This filtering applies equally to success, failure, and logoff events. + +On **standalone servers** (not domain-joined), Windows uses NTLM rather than Kerberos, so the `LogonGuid` field in logon events is always all-zeros. The service correctly handles this and does **not** filter out events based on a zero `LogonGuid`. + ## Enriched Events Events are ingested into Seq with useful properties that allow for easy searching. diff --git a/Seq.Client.WindowsLogins.Tests/EventTests.cs b/Seq.Client.WindowsLogins.Tests/EventTests.cs index 0fe382f..21a477f 100644 --- a/Seq.Client.WindowsLogins.Tests/EventTests.cs +++ b/Seq.Client.WindowsLogins.Tests/EventTests.cs @@ -35,6 +35,26 @@ public void EvaluatesValidEvent() Assert.False(EventLogListener.IsNotValid(test)); } + /// + /// Ensure an RDP (type 10) logon with a zero LogonGuid is treated as valid. + /// On standalone servers using NTLM, LogonGuid is always all-zeros; the old check incorrectly filtered these out. + /// + [Fact] + public void EvaluatesValidRdpEventWithZeroLogonGuid() + { + IList test = new List + { + "00000000-0000-0000-0000-000000000001", "Barry", "BARRY", "Barry", + "00000000-0000-0000-0000-000000000001", "Barry", "BARRY", "Barry", (uint) 10, "Barry", "BarryAuth", + "BARRYPC", + Guid.Parse("00000000-0000-0000-0000-000000000000"), "Barries", "Barry", 1024, 1, " BARRY.EXE", + "192.168.1.100", 3389, + "Impersonation" + }; + + Assert.False(EventLogListener.IsNotValid(test)); + } + /// /// Ensure invalid properties won't be passed /// @@ -54,6 +74,94 @@ public void EvaluatesInvalidEvent() Assert.True(EventLogListener.IsNotValid(test)); } + /// + /// Ensure valid failure event properties will be passed (event 4625) + /// + [Fact] + public void EvaluatesValidFailureEvent() + { + // 4625 property layout: SubjectUserSid[0], SubjectUserName[1], SubjectDomainName[2], SubjectLogonId[3], + // TargetUserSid[4], TargetUserName[5], TargetDomainName[6], + // Status[7], FailureReason[8], SubStatus[9], + // LogonType[10], LogonProcessName[11], AuthenticationPackageName[12], WorkstationName[13], + // TransmittedServices[14], LmPackageName[15], KeyLength[16], + // ProcessId[17], ProcessName[18], IpAddress[19], IpPort[20] + IList test = new List + { + "00000000-0000-0000-0000-000000000001", "Barry", "BARRY", "Barry", + "00000000-0000-0000-0000-000000000001", "Barry", "BARRY", + "0xC000006D", "%%2313", "0xC000006A", + (uint) 10, "Barry", "BarryAuth", "BARRYPC", + "-", "-", 0, + 1, " BARRY.EXE", "192.168.1.100", 3389 + }; + + Assert.False(EventLogListener.IsFailureNotValid(test)); + } + + /// + /// Ensure invalid failure event properties (non-interactive logon type) won't be passed (event 4625) + /// + [Fact] + public void EvaluatesInvalidFailureEvent() + { + IList test = new List + { + "00000000-0000-0000-0000-000000000001", "Barry", "BARRY", "Barry", + "00000000-0000-0000-0000-000000000001", "Barry", "BARRY", + "0xC000006D", "%%2313", "0xC000006A", + (uint) 3, "Barry", "BarryAuth", "BARRYPC", + "-", "-", 0, + 1, " BARRY.EXE", "-", 0 + }; + + Assert.True(EventLogListener.IsFailureNotValid(test)); + } + + /// + /// Ensure a valid 4634 logoff event (interactive type 10) is accepted + /// + [Fact] + public void EvaluatesValidLogoffEvent() + { + // 4634 property layout: TargetUserSid[0], TargetUserName[1], TargetDomainName[2], TargetLogonId[3], LogonType[4] + IList test = new List + { + "00000000-0000-0000-0000-000000000001", "Barry", "BARRY", "Barry", (uint) 10 + }; + + Assert.False(EventLogListener.IsLogoffNotValid(test, false)); + } + + /// + /// Ensure an invalid 4634 logoff event (non-interactive logon type) is rejected + /// + [Fact] + public void EvaluatesInvalidLogoffEvent() + { + IList test = new List + { + "00000000-0000-0000-0000-000000000001", "Barry", "BARRY", "Barry", (uint) 3 + }; + + Assert.True(EventLogListener.IsLogoffNotValid(test, false)); + } + + /// + /// Ensure a 4647 user-initiated logoff event is always accepted (no LogonType field) + /// + [Fact] + public void EvaluatesValidUserInitiatedLogoffEvent() + { + // 4647 property layout: TargetUserSid[0], TargetUserName[1], TargetDomainName[2], TargetLogonId[3] + IList test = new List + { + "00000000-0000-0000-0000-000000000001", "Barry", "BARRY", "Barry" + }; + + Assert.False(EventLogListener.IsLogoffNotValid(test, true)); + } + /// /// Allow a single event to expire after 2 seconds /// diff --git a/Seq.Client.WindowsLogins/App.config b/Seq.Client.WindowsLogins/App.config index e3e369a..24b0acb 100644 --- a/Seq.Client.WindowsLogins/App.config +++ b/Seq.Client.WindowsLogins/App.config @@ -16,6 +16,10 @@ + + + + diff --git a/Seq.Client.WindowsLogins/Config.cs b/Seq.Client.WindowsLogins/Config.cs index 593c85d..61a8715 100644 --- a/Seq.Client.WindowsLogins/Config.cs +++ b/Seq.Client.WindowsLogins/Config.cs @@ -17,6 +17,8 @@ static Config() LogFolder = ConfigurationManager.AppSettings["LogFolder"]; HeartbeatInterval = GetInt(ConfigurationManager.AppSettings["HeartbeatInterval"]); IsDebug = GetBool(ConfigurationManager.AppSettings["IsDebug"]); + IncludeLogonFailures = GetBool(ConfigurationManager.AppSettings["IncludeLogonFailures"]); + IncludeLogoffEvents = GetBool(ConfigurationManager.AppSettings["IncludeLogoffEvents"]); ProjectKey = ConfigurationManager.AppSettings["ProjectKey"]; Responders = ConfigurationManager.AppSettings["Responders"]; Priority = ConfigurationManager.AppSettings["Priority"]; @@ -70,6 +72,8 @@ static Config() public static string LogFolder { get; } public static int HeartbeatInterval { get; } public static bool IsDebug { get; } + public static bool IncludeLogonFailures { get; } + public static bool IncludeLogoffEvents { get; } public static string ProjectKey { get; } public static string Priority { get; } public static string Responders { get; } diff --git a/Seq.Client.WindowsLogins/EventLogListener.cs b/Seq.Client.WindowsLogins/EventLogListener.cs index d9ab21d..a5950de 100644 --- a/Seq.Client.WindowsLogins/EventLogListener.cs +++ b/Seq.Client.WindowsLogins/EventLogListener.cs @@ -17,6 +17,10 @@ public class EventLogListener private static readonly DateTime ServiceStart = DateTime.Now; private static long _logonsDetected; private static long _nonInteractiveLogons; + private static long _logonFailuresDetected; + private static long _nonInteractiveFailures; + private static long _logoffsDetected; + private static long _nonInteractiveLogoffs; private static long _unhandledEvents; private static long _oldEvents; private static long _emptyEvents; @@ -47,9 +51,8 @@ public void Start(bool isInteractive) _isInteractive = isInteractive; Log.Level(LurgLevel.Debug).Add("Starting listener"); - //Query for success audits with event id 4624 - _eventLog = new EventLogQuery("Security", PathType.LogName, - "*[System[band(Keywords,9007199254740992) and (EventID=4624)]]"); + //Build query based on configured options + _eventLog = new EventLogQuery("Security", PathType.LogName, BuildEventQuery()); _watcher = new EventLogWatcher(_eventLog); _watcher.EventRecordWritten += OnEntryWritten; _watcher.Enabled = true; @@ -78,6 +81,10 @@ private static void ServiceHeartbeat(object sender, ElapsedEventArgs e) .AddProperty("ItemCount", EventList.Count) .AddProperty("LogonsDetected", _logonsDetected) .AddProperty("NonInteractiveLogons", _nonInteractiveLogons) + .AddProperty("LogonFailuresDetected", _logonFailuresDetected) + .AddProperty("NonInteractiveFailures", _nonInteractiveFailures) + .AddProperty("LogoffsDetected", _logoffsDetected) + .AddProperty("NonInteractiveLogoffs", _nonInteractiveLogoffs) .AddProperty("OldEvents", _oldEvents) .AddProperty("EmptyEvents", _emptyEvents) .AddProperty("UnhandledEvents", _unhandledEvents) @@ -85,8 +92,10 @@ private static void ServiceHeartbeat(object sender, ElapsedEventArgs e) .Add( Config.IsDebug ? "{AppName:l} Heartbeat [{MachineName:l}] - Event cache: {ItemCount}, Logons detected: {LogonsDetected}, " + - "Non-interactive logons: {NonInteractiveLogons}, Unhandled events: {UnhandledEvents}, Old events seen: {OldEvents}, " + - "Empty events: {EmptyEvents}, Next Heartbeat: {NextTime:H:mm:ss tt}" + "Non-interactive logons: {NonInteractiveLogons}, Logon failures: {LogonFailuresDetected}, " + + "Non-interactive failures: {NonInteractiveFailures}, Logoffs: {LogoffsDetected}, " + + "Non-interactive logoffs: {NonInteractiveLogoffs}, Unhandled events: {UnhandledEvents}, " + + "Old events seen: {OldEvents}, Empty events: {EmptyEvents}, Next Heartbeat: {NextTime:H:mm:ss tt}" : "{AppName:l} Heartbeat [{MachineName:l}] - Event cache: {ItemCount}, Next Heartbeat: {NextTime:H:mm:ss tt}"); if (_heartbeatTimer.AutoReset) return; @@ -139,6 +148,33 @@ private static void HandleEventLogEntry(EventRecord entry) //Ensure that we track events we've already seen EventList.Add(entry.RecordId); + try + { + switch (entry.Id) + { + case 4624: + HandleLogonSuccessEvent(entry); + break; + case 4625: + HandleLogonFailureEvent(entry); + break; + case 4634: + case 4647: + HandleLogoffEvent(entry); + break; + default: + _unhandledEvents++; + break; + } + } + catch (Exception ex) + { + Log.Exception(ex).Add("Error parsing event: {Message:l}", ex.Message); + } + } + + private static void HandleLogonSuccessEvent(EventRecord entry) + { try { //Get all the properties of interest for passing to Seq @@ -240,12 +276,230 @@ private static void HandleEventLogEntry(EventRecord entry) } } + private static void HandleLogonFailureEvent(EventRecord entry) + { + try + { + var failureEventPropertySelector = new EventLogPropertySelector(new[] + { + "Event/EventData/Data[@Name='SubjectUserSid']", + "Event/EventData/Data[@Name='SubjectUserName']", + "Event/EventData/Data[@Name='SubjectDomainName']", + "Event/EventData/Data[@Name='SubjectLogonId']", + "Event/EventData/Data[@Name='TargetUserSid']", + "Event/EventData/Data[@Name='TargetUserName']", + "Event/EventData/Data[@Name='TargetDomainName']", + "Event/EventData/Data[@Name='Status']", + "Event/EventData/Data[@Name='FailureReason']", + "Event/EventData/Data[@Name='SubStatus']", + "Event/EventData/Data[@Name='LogonType']", + "Event/EventData/Data[@Name='LogonProcessName']", + "Event/EventData/Data[@Name='AuthenticationPackageName']", + "Event/EventData/Data[@Name='WorkstationName']", + "Event/EventData/Data[@Name='TransmittedServices']", + "Event/EventData/Data[@Name='LmPackageName']", + "Event/EventData/Data[@Name='KeyLength']", + "Event/EventData/Data[@Name='ProcessId']", + "Event/EventData/Data[@Name='ProcessName']", + "Event/EventData/Data[@Name='IpAddress']", + "Event/EventData/Data[@Name='IpPort']" + }); + + var eventProperties = ((EventLogRecord) entry).GetPropertyValues(failureEventPropertySelector); + + if (eventProperties.Count != 21) + { + _unhandledEvents++; + return; + } + + if (IsFailureNotValid(eventProperties)) + { + _nonInteractiveFailures++; + return; + } + + _logonFailuresDetected++; + + var eventTimeLong = string.Empty; + var eventTimeShort = string.Empty; + if (entry.TimeCreated != null) + { + eventTimeLong = ((DateTime) entry.TimeCreated).ToString("F"); + eventTimeShort = ((DateTime) entry.TimeCreated).ToString("G"); + } + + Log.Level(Extensions.MapLogLevel(EventLogEntryType.FailureAudit)) + .SetTimestamp(entry.TimeCreated ?? DateTime.Now) + .AddProperty("EventId", (long) entry.Id) + .AddProperty("InstanceId", entry.Id) + .AddProperty("EventTime", entry.TimeCreated) + .AddProperty("EventTimeLong", eventTimeLong) + .AddProperty("EventTimeShort", eventTimeShort) + .AddProperty("Source", entry.ProviderName) + .AddProperty("Category", entry.LevelDisplayName) + .AddProperty("EventLogName", entry.LogName) + .AddProperty("EventRecordID", entry.RecordId) + .AddProperty("Details", entry.FormatDescription()) + .AddProperty("SubjectUserSid", eventProperties[0]) + .AddProperty("SubjectUserName", eventProperties[1]) + .AddProperty("SubjectDomainName", eventProperties[2]) + .AddProperty("SubjectLogonId", eventProperties[3]) + .AddProperty("TargetUserSid", eventProperties[4]) + .AddProperty("TargetUserName", eventProperties[5]) + .AddProperty("TargetDomainName", eventProperties[6]) + .AddProperty("Status", eventProperties[7]) + .AddProperty("FailureReason", eventProperties[8]) + .AddProperty("SubStatus", eventProperties[9]) + .AddProperty("LogonType", eventProperties[10]) + .AddProperty("LogonProcessName", eventProperties[11]) + .AddProperty("AuthenticationPackageName", eventProperties[12]) + .AddProperty("WorkstationName", eventProperties[13]) + .AddProperty("TransmittedServices", eventProperties[14]) + .AddProperty("LmPackageName", eventProperties[15]) + .AddProperty("KeyLength", eventProperties[16]) + .AddProperty("ProcessId", eventProperties[17]) + .AddProperty("ProcessName", eventProperties[18]) + .AddProperty("IpAddress", eventProperties[19]) + .AddProperty("IpPort", eventProperties[20]) + .AddProperty(nameof(Config.ProjectKey), Config.ProjectKey) + .AddProperty(nameof(Config.Priority), Config.Priority) + .AddProperty(nameof(Config.Responders), Config.Responders) + .AddProperty(nameof(Config.Tags), Config.Tags) + .AddProperty(nameof(Config.InitialTimeEstimate), Config.InitialTimeEstimate) + .AddProperty(nameof(Config.RemainingTimeEstimate), Config.RemainingTimeEstimate) + .AddProperty(nameof(Config.DueDate), Config.DueDate) + .Add( + "[{AppName:l}] Login failure detected on {MachineName:l} - {TargetDomainName:l}\\{TargetUserName:l} at {EventTime:F}"); + } + catch (Exception ex) + { + Log.Exception(ex).Add("Error parsing event: {Message:l}", ex.Message); + } + } + + private static void HandleLogoffEvent(EventRecord entry) + { + try + { + // Event 4647 (user-initiated logoff) has 4 properties; 4634 (logoff) has 5 + var isUserInitiated = entry.Id == 4647; + var propertyNames = isUserInitiated + ? new[] + { + "Event/EventData/Data[@Name='TargetUserSid']", + "Event/EventData/Data[@Name='TargetUserName']", + "Event/EventData/Data[@Name='TargetDomainName']", + "Event/EventData/Data[@Name='TargetLogonId']" + } + : new[] + { + "Event/EventData/Data[@Name='TargetUserSid']", + "Event/EventData/Data[@Name='TargetUserName']", + "Event/EventData/Data[@Name='TargetDomainName']", + "Event/EventData/Data[@Name='TargetLogonId']", + "Event/EventData/Data[@Name='LogonType']" + }; + + var logoffEventPropertySelector = new EventLogPropertySelector(propertyNames); + var eventProperties = ((EventLogRecord) entry).GetPropertyValues(logoffEventPropertySelector); + + var expectedCount = isUserInitiated ? 4 : 5; + if (eventProperties.Count != expectedCount) + { + _unhandledEvents++; + return; + } + + if (IsLogoffNotValid(eventProperties, isUserInitiated)) + { + _nonInteractiveLogoffs++; + return; + } + + _logoffsDetected++; + + var eventTimeLong = string.Empty; + var eventTimeShort = string.Empty; + if (entry.TimeCreated != null) + { + eventTimeLong = ((DateTime) entry.TimeCreated).ToString("F"); + eventTimeShort = ((DateTime) entry.TimeCreated).ToString("G"); + } + + Log.Level(Extensions.MapLogLevel(EventLogEntryType.SuccessAudit)) + .SetTimestamp(entry.TimeCreated ?? DateTime.Now) + .AddProperty("EventId", (long) entry.Id) + .AddProperty("InstanceId", entry.Id) + .AddProperty("EventTime", entry.TimeCreated) + .AddProperty("EventTimeLong", eventTimeLong) + .AddProperty("EventTimeShort", eventTimeShort) + .AddProperty("Source", entry.ProviderName) + .AddProperty("Category", entry.LevelDisplayName) + .AddProperty("EventLogName", entry.LogName) + .AddProperty("EventRecordID", entry.RecordId) + .AddProperty("Details", entry.FormatDescription()) + .AddProperty("TargetUserSid", eventProperties[0]) + .AddProperty("TargetUserName", eventProperties[1]) + .AddProperty("TargetDomainName", eventProperties[2]) + .AddProperty("TargetLogonId", eventProperties[3]) + .AddProperty(nameof(Config.ProjectKey), Config.ProjectKey) + .AddProperty(nameof(Config.Priority), Config.Priority) + .AddProperty(nameof(Config.Responders), Config.Responders) + .AddProperty(nameof(Config.Tags), Config.Tags) + .AddProperty(nameof(Config.InitialTimeEstimate), Config.InitialTimeEstimate) + .AddProperty(nameof(Config.RemainingTimeEstimate), Config.RemainingTimeEstimate) + .AddProperty(nameof(Config.DueDate), Config.DueDate) + .Add( + "[{AppName:l}] Logoff detected on {MachineName:l} - {TargetDomainName:l}\\{TargetUserName:l} at {EventTime:F}"); + } + catch (Exception ex) + { + Log.Exception(ex).Add("Error parsing event: {Message:l}", ex.Message); + } + } + + private static string BuildEventQuery() + { + var successEventIds = new List {"EventID=4624"}; + + if (Config.IncludeLogoffEvents) + { + successEventIds.Add("EventID=4634"); + successEventIds.Add("EventID=4647"); + } + + var successFilter = + $"band(Keywords,9007199254740992) and ({string.Join(" or ", successEventIds)})"; + + if (Config.IncludeLogonFailures) + return + $"*[System[({successFilter}) or (band(Keywords,4503599627370496) and EventID=4625)]]"; + + return $"*[System[{successFilter}]]"; + } + public static bool IsNotValid(IList eventProperties) { //Only interactive users are of interest - logonType 2 and 10. Some non-interactive services can launch processes with logontype 2 but can be filtered. - return (uint) eventProperties[8] != 2 && (uint) eventProperties[8] != 10 || - (string) eventProperties[18] == "-" || - eventProperties[12].ToString() == "00000000-0000-0000-0000-000000000000"; + //Note: LogonGuid is intentionally not checked here; on standalone servers using NTLM it is always all-zeros, which would incorrectly suppress RDP (type 10) logons. + return ((uint) eventProperties[8] != 2 && (uint) eventProperties[8] != 10) || + (string) eventProperties[18] == "-"; + } + + public static bool IsFailureNotValid(IList eventProperties) + { + //Only interactive users are of interest - logonType 2 and 10 (LogonType is at index 10 in event 4625) + return ((uint) eventProperties[10] != 2 && (uint) eventProperties[10] != 10) || + (string) eventProperties[19] == "-"; + } + + public static bool IsLogoffNotValid(IList eventProperties, bool isUserInitiated) + { + //For event 4647 (user-initiated logoff) there is no LogonType field; always include it. + //For event 4634, only interactive logon types 2 and 10 are of interest (LogonType is at index 4). + return !isUserInitiated && + (uint) eventProperties[4] != 2 && (uint) eventProperties[4] != 10; } } } \ No newline at end of file From 9600bc08ecbaf0837c09d75a0cb4561e60bc6507 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:35:22 +0000 Subject: [PATCH 2/4] Fix RDP reconnect logons dropped: add LogonType 7 (Unlock) to accepted types Co-authored-by: HakanL <407941+HakanL@users.noreply.github.com> --- README.md | 4 +- Seq.Client.WindowsLogins.Tests/EventTests.cs | 41 ++++++++++++++++++++ Seq.Client.WindowsLogins/EventLogListener.cs | 9 +++-- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d4acac9..9157f2a 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,9 @@ The following options can be set in `Seq.Client.WindowsLogins.exe.config`: ### Notes on logon filtering -Interactive logons are identified by **LogonType 2** (console) and **LogonType 10** (Remote Desktop / RDP). Non-interactive logons (services, batch jobs, network shares, etc.) are excluded. This filtering applies equally to success, failure, and logoff events. +Interactive logons are identified by **LogonType 2** (console), **LogonType 7** (Unlock), and **LogonType 10** (Remote Desktop / RDP). Non-interactive logons (services, batch jobs, network shares, etc.) are excluded. This filtering applies equally to success, failure, and logoff events. + +**LogonType 7 (Unlock)** events are included because reconnecting to an existing RDP session generates LogonType 7 events (not LogonType 10) when the session screen is locked. These events with a remote source IP address (`IpAddress != "-"`) are treated as RDP authentication events. Local console unlocks (`IpAddress = "-"`) are filtered out. On **standalone servers** (not domain-joined), Windows uses NTLM rather than Kerberos, so the `LogonGuid` field in logon events is always all-zeros. The service correctly handles this and does **not** filter out events based on a zero `LogonGuid`. diff --git a/Seq.Client.WindowsLogins.Tests/EventTests.cs b/Seq.Client.WindowsLogins.Tests/EventTests.cs index 21a477f..b9fa0f1 100644 --- a/Seq.Client.WindowsLogins.Tests/EventTests.cs +++ b/Seq.Client.WindowsLogins.Tests/EventTests.cs @@ -55,6 +55,47 @@ public void EvaluatesValidRdpEventWithZeroLogonGuid() Assert.False(EventLogListener.IsNotValid(test)); } + /// + /// Ensure a type 7 (Unlock) logon with a remote IpAddress is treated as valid. + /// Reconnecting to an existing RDP session generates LogonType=7, not LogonType=10, + /// so this must be accepted when there is a remote source address. + /// + [Fact] + public void EvaluatesValidRdpUnlockEvent() + { + IList test = new List + { + "00000000-0000-0000-0000-000000000001", "Barry", "BARRY", "Barry", + "00000000-0000-0000-0000-000000000001", "Barry", "BARRY", "Barry", (uint) 7, "Barry", "BarryAuth", + "BARRYPC", + Guid.Parse("00000000-0000-0000-0000-000000000000"), "Barries", "Barry", 1024, 1, " BARRY.EXE", + "10.80.6.1", 0, + "Impersonation" + }; + + Assert.False(EventLogListener.IsNotValid(test)); + } + + /// + /// Ensure a type 7 (Unlock) logon with IpAddress="-" is filtered. + /// This is a local console unlock and is not of interest. + /// + [Fact] + public void EvaluatesInvalidLocalConsoleUnlockEvent() + { + IList test = new List + { + "00000000-0000-0000-0000-000000000001", "Barry", "BARRY", "Barry", + "00000000-0000-0000-0000-000000000001", "Barry", "BARRY", "Barry", (uint) 7, "Barry", "BarryAuth", + "BARRYPC", + Guid.Parse("00000000-0000-0000-0000-000000000000"), "Barries", "Barry", 1024, 1, " BARRY.EXE", + "-", 0, + "Impersonation" + }; + + Assert.True(EventLogListener.IsNotValid(test)); + } + /// /// Ensure invalid properties won't be passed /// diff --git a/Seq.Client.WindowsLogins/EventLogListener.cs b/Seq.Client.WindowsLogins/EventLogListener.cs index a5950de..1d2a148 100644 --- a/Seq.Client.WindowsLogins/EventLogListener.cs +++ b/Seq.Client.WindowsLogins/EventLogListener.cs @@ -481,9 +481,12 @@ private static string BuildEventQuery() public static bool IsNotValid(IList eventProperties) { - //Only interactive users are of interest - logonType 2 and 10. Some non-interactive services can launch processes with logontype 2 but can be filtered. - //Note: LogonGuid is intentionally not checked here; on standalone servers using NTLM it is always all-zeros, which would incorrectly suppress RDP (type 10) logons. - return ((uint) eventProperties[8] != 2 && (uint) eventProperties[8] != 10) || + //Interactive logon types of interest: 2 (Interactive), 7 (Unlock - fired on RDP reconnect when screen is locked), + //10 (RemoteInteractive/RDP). Some non-interactive services can launch processes with logonType 2 but are filtered + //by the IpAddress check. Type 7 events with IpAddress="-" are local console unlocks and are also filtered. + //Note: LogonGuid is intentionally not checked here; on standalone servers using NTLM it is always all-zeros, + //which would incorrectly suppress RDP (type 7/10) logons. + return ((uint) eventProperties[8] != 2 && (uint) eventProperties[8] != 7 && (uint) eventProperties[8] != 10) || (string) eventProperties[18] == "-"; } From fae049894c01e6396c3ecb7699dd9c1abf96d770 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 20:14:14 +0000 Subject: [PATCH 3/4] Fix NTLM RDP logon failures dropped: accept any remote IP in IsFailureNotValid Co-authored-by: HakanL <407941+HakanL@users.noreply.github.com> --- README.md | 4 +++- Seq.Client.WindowsLogins.Tests/EventTests.cs | 20 ++++++++++++++++++++ Seq.Client.WindowsLogins/EventLogListener.cs | 7 ++++--- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9157f2a..3931ff7 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,12 @@ The following options can be set in `Seq.Client.WindowsLogins.exe.config`: ### Notes on logon filtering -Interactive logons are identified by **LogonType 2** (console), **LogonType 7** (Unlock), and **LogonType 10** (Remote Desktop / RDP). Non-interactive logons (services, batch jobs, network shares, etc.) are excluded. This filtering applies equally to success, failure, and logoff events. +Successful logons (Event 4624) are identified by **LogonType 2** (console), **LogonType 7** (Unlock), and **LogonType 10** (Remote Desktop / RDP). Non-interactive logons (services, batch jobs, etc.) are excluded. **LogonType 7 (Unlock)** events are included because reconnecting to an existing RDP session generates LogonType 7 events (not LogonType 10) when the session screen is locked. These events with a remote source IP address (`IpAddress != "-"`) are treated as RDP authentication events. Local console unlocks (`IpAddress = "-"`) are filtered out. +For **logon failures** (Event 4625, enabled via `IncludeLogonFailures`), any failure with a remote source IP address is captured regardless of LogonType. This is necessary because on standalone NTLM servers, RDP logon failures generate LogonType=3 (Network) rather than LogonType=10. The `IpAddress != "-"` check alone is used to exclude purely local authentication failures. + On **standalone servers** (not domain-joined), Windows uses NTLM rather than Kerberos, so the `LogonGuid` field in logon events is always all-zeros. The service correctly handles this and does **not** filter out events based on a zero `LogonGuid`. ## Enriched Events diff --git a/Seq.Client.WindowsLogins.Tests/EventTests.cs b/Seq.Client.WindowsLogins.Tests/EventTests.cs index b9fa0f1..6e0a94a 100644 --- a/Seq.Client.WindowsLogins.Tests/EventTests.cs +++ b/Seq.Client.WindowsLogins.Tests/EventTests.cs @@ -140,6 +140,26 @@ public void EvaluatesValidFailureEvent() Assert.False(EventLogListener.IsFailureNotValid(test)); } + /// + /// Ensure a type 3 (Network) failure with a remote IP is treated as valid. + /// On standalone NTLM servers, RDP logon failures generate LogonType=3 rather than LogonType=10. + /// + [Fact] + public void EvaluatesValidNtlmRdpFailureEvent() + { + IList test = new List + { + "S-1-0-0", "-", "-", "0x0", + "S-1-0-0", "HLindestaf", "HPIDEV2017", + "0xC000006D", "%%2313", "0xC000006A", + (uint) 3, "NtLmSsp ", "NTLM", "HPIDEV2017", + "-", "-", 0, + 0, "-", "10.80.6.1", 0 + }; + + Assert.False(EventLogListener.IsFailureNotValid(test)); + } + /// /// Ensure invalid failure event properties (non-interactive logon type) won't be passed (event 4625) /// diff --git a/Seq.Client.WindowsLogins/EventLogListener.cs b/Seq.Client.WindowsLogins/EventLogListener.cs index 1d2a148..147e8a3 100644 --- a/Seq.Client.WindowsLogins/EventLogListener.cs +++ b/Seq.Client.WindowsLogins/EventLogListener.cs @@ -492,9 +492,10 @@ public static bool IsNotValid(IList eventProperties) public static bool IsFailureNotValid(IList eventProperties) { - //Only interactive users are of interest - logonType 2 and 10 (LogonType is at index 10 in event 4625) - return ((uint) eventProperties[10] != 2 && (uint) eventProperties[10] != 10) || - (string) eventProperties[19] == "-"; + //Any remote authentication failure is of interest (IpAddress != "-"). + //On standalone NTLM servers, RDP logon failures generate LogonType=3 (network) rather than LogonType=10; + //the IpAddress check alone is sufficient to exclude purely local authentication failures. + return (string) eventProperties[19] == "-"; } public static bool IsLogoffNotValid(IList eventProperties, bool isUserInitiated) From 7eeabbf9ca99e098be5e3cb90fd6a6b466e38a4e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:14:33 +0000 Subject: [PATCH 4/4] Filter DWM/UMFD logoff events and duplicate admin RDP linked logon events Co-authored-by: HakanL <407941+HakanL@users.noreply.github.com> --- Seq.Client.WindowsLogins.Tests/EventTests.cs | 133 ++++++++++++++++++- Seq.Client.WindowsLogins/EventLogListener.cs | 24 +++- 2 files changed, 146 insertions(+), 11 deletions(-) diff --git a/Seq.Client.WindowsLogins.Tests/EventTests.cs b/Seq.Client.WindowsLogins.Tests/EventTests.cs index 6e0a94a..4ddf18a 100644 --- a/Seq.Client.WindowsLogins.Tests/EventTests.cs +++ b/Seq.Client.WindowsLogins.Tests/EventTests.cs @@ -29,7 +29,7 @@ public void EvaluatesValidEvent() "BARRYPC", Guid.Parse("00000000-0000-0000-0000-000000000001"), "Barries", "Barry", 1024, 1, " BARRY.EXE", "127.0.0.1", 1111, - "All The Impersonation" + "All The Impersonation", "%%1843", (ulong) 0 }; Assert.False(EventLogListener.IsNotValid(test)); @@ -49,7 +49,7 @@ public void EvaluatesValidRdpEventWithZeroLogonGuid() "BARRYPC", Guid.Parse("00000000-0000-0000-0000-000000000000"), "Barries", "Barry", 1024, 1, " BARRY.EXE", "192.168.1.100", 3389, - "Impersonation" + "Impersonation", "%%1843", (ulong) 0 }; Assert.False(EventLogListener.IsNotValid(test)); @@ -70,7 +70,7 @@ public void EvaluatesValidRdpUnlockEvent() "BARRYPC", Guid.Parse("00000000-0000-0000-0000-000000000000"), "Barries", "Barry", 1024, 1, " BARRY.EXE", "10.80.6.1", 0, - "Impersonation" + "Impersonation", "%%1843", (ulong) 0 }; Assert.False(EventLogListener.IsNotValid(test)); @@ -90,7 +90,7 @@ public void EvaluatesInvalidLocalConsoleUnlockEvent() "BARRYPC", Guid.Parse("00000000-0000-0000-0000-000000000000"), "Barries", "Barry", 1024, 1, " BARRY.EXE", "-", 0, - "Impersonation" + "Impersonation", "%%1843", (ulong) 0 }; Assert.True(EventLogListener.IsNotValid(test)); @@ -109,16 +109,137 @@ public void EvaluatesInvalidEvent() "BARRYPC", Guid.Parse("00000000-0000-0000-0000-000000000000"), "Barries", "Barry", 1024, 1, " BARRY.EXE", "-", 1111, - "All The Impersonation" + "All The Impersonation", "%%1843", (ulong) 0 }; Assert.True(EventLogListener.IsNotValid(test)); } /// - /// Ensure valid failure event properties will be passed (event 4625) + /// Ensure the non-elevated linked logon token is filtered for admin RDP users. + /// When an admin logs in via RDP, Windows creates two linked logon sessions (elevated and + /// filtered/non-elevated tokens). The non-elevated linked token must be filtered to avoid duplicates. /// [Fact] + public void EvaluatesLinkedNonElevatedLogonFiltered() + { + // Simulates the non-elevated (filtered) token: ElevatedToken=%%1843, TargetLinkedLogonId != 0 + IList test = new List + { + "00000000-0000-0000-0000-000000000001", "Barry", "BARRY", "Barry", + "S-1-5-21-1234", "Barry", "BARRY", "Barry", (uint) 10, "Barry", "BarryAuth", + "BARRYPC", + Guid.Parse("00000000-0000-0000-0000-000000000000"), "Barries", "Barry", 1024, 1, " BARRY.EXE", + "10.80.6.1", 0, + "Impersonation", "%%1843", (ulong) 57606888 + }; + + Assert.True(EventLogListener.IsNotValid(test)); + } + + /// + /// Ensure the elevated linked logon token is accepted for admin RDP users. + /// The elevated token (ElevatedToken=%%1842) of the linked pair must be kept. + /// + [Fact] + public void EvaluatesLinkedElevatedLogonAccepted() + { + // Simulates the elevated token: ElevatedToken=%%1842, TargetLinkedLogonId != 0 + IList test = new List + { + "00000000-0000-0000-0000-000000000001", "Barry", "BARRY", "Barry", + "S-1-5-21-1234", "Barry", "BARRY", "Barry", (uint) 10, "Barry", "BarryAuth", + "BARRYPC", + Guid.Parse("00000000-0000-0000-0000-000000000000"), "Barries", "Barry", 1024, 1, " BARRY.EXE", + "10.80.6.1", 0, + "Impersonation", "%%1842", (ulong) 57606917 + }; + + Assert.False(EventLogListener.IsNotValid(test)); + } + + /// + /// Ensure a non-admin RDP logon (ElevatedToken=%%1843, no linked session) is accepted. + /// Non-admin users have no linked logon session, so TargetLinkedLogonId == 0. + /// + [Fact] + public void EvaluatesNonAdminRdpLogonAccepted() + { + IList test = new List + { + "00000000-0000-0000-0000-000000000001", "Barry", "BARRY", "Barry", + "S-1-5-21-1234", "Barry", "BARRY", "Barry", (uint) 10, "Barry", "BarryAuth", + "BARRYPC", + Guid.Parse("00000000-0000-0000-0000-000000000000"), "Barries", "Barry", 1024, 1, " BARRY.EXE", + "10.80.6.1", 0, + "Impersonation", "%%1843", (ulong) 0 + }; + + Assert.False(EventLogListener.IsNotValid(test)); + } + + /// + /// Ensure a valid 4634 logoff event (interactive type 7, RDP reconnect session) is accepted + /// + [Fact] + public void EvaluatesValidLogoffEventType7() + { + IList test = new List + { + "S-1-5-21-1234", "Barry", "BARRY", "Barry", (uint) 7 + }; + + Assert.False(EventLogListener.IsLogoffNotValid(test, false)); + } + + /// + /// Ensure a 4634 logoff event for a Window Manager (DWM) virtual account is filtered. + /// DWM-x accounts (SID prefix S-1-5-90-) are created per RDP session and generate spurious logoff events. + /// + [Fact] + public void EvaluatesDwmLogoffEventFiltered() + { + IList test = new List + { + "S-1-5-90-0-2", "DWM-2", "Window Manager", "Barry", (uint) 2 + }; + + Assert.True(EventLogListener.IsLogoffNotValid(test, false)); + } + + /// + /// Ensure a 4634 logoff event for a Font Driver Host (UMFD) virtual account is filtered. + /// UMFD-x accounts (SID prefix S-1-5-96-) are created per RDP session and generate spurious logoff events. + /// + [Fact] + public void EvaluatesUmfdLogoffEventFiltered() + { + IList test = new List + { + "S-1-5-96-0-2", "UMFD-2", "Font Driver Host", "Barry", (uint) 2 + }; + + Assert.True(EventLogListener.IsLogoffNotValid(test, false)); + } + + /// + /// Ensure a 4647 user-initiated logoff event for a DWM virtual account is also filtered. + /// The SID check applies regardless of event type (4634 or 4647). + /// + [Fact] + public void EvaluatesDwmUserInitiatedLogoffFiltered() + { + IList test = new List + { + "S-1-5-90-0-2", "DWM-2", "Window Manager", "Barry" + }; + + Assert.True(EventLogListener.IsLogoffNotValid(test, true)); + } + + /// + /// Ensure valid failure event properties will be passed (event 4625) + [Fact] public void EvaluatesValidFailureEvent() { // 4625 property layout: SubjectUserSid[0], SubjectUserName[1], SubjectDomainName[2], SubjectLogonId[3], diff --git a/Seq.Client.WindowsLogins/EventLogListener.cs b/Seq.Client.WindowsLogins/EventLogListener.cs index 147e8a3..6798da2 100644 --- a/Seq.Client.WindowsLogins/EventLogListener.cs +++ b/Seq.Client.WindowsLogins/EventLogListener.cs @@ -200,12 +200,14 @@ private static void HandleLogonSuccessEvent(EventRecord entry) "Event/EventData/Data[@Name='ProcessName']", "Event/EventData/Data[@Name='IpAddress']", "Event/EventData/Data[@Name='IpPort']", - "Event/EventData/Data[@Name='ImpersonationLevel']" + "Event/EventData/Data[@Name='ImpersonationLevel']", + "Event/EventData/Data[@Name='ElevatedToken']", + "Event/EventData/Data[@Name='TargetLinkedLogonId']" }); var eventProperties = ((EventLogRecord) entry).GetPropertyValues(loginEventPropertySelector); - if (eventProperties.Count != 21) + if (eventProperties.Count != 23) { _unhandledEvents++; return; @@ -260,6 +262,8 @@ private static void HandleLogonSuccessEvent(EventRecord entry) .AddProperty("IpAddress", eventProperties[18]) .AddProperty("IpPort", eventProperties[19]) .AddProperty("ImpersonationLevel", eventProperties[20]) + .AddProperty("ElevatedToken", eventProperties[21]) + .AddProperty("TargetLinkedLogonId", eventProperties[22]) .AddProperty(nameof(Config.ProjectKey), Config.ProjectKey) .AddProperty(nameof(Config.Priority), Config.Priority) .AddProperty(nameof(Config.Responders), Config.Responders) @@ -486,8 +490,11 @@ public static bool IsNotValid(IList eventProperties) //by the IpAddress check. Type 7 events with IpAddress="-" are local console unlocks and are also filtered. //Note: LogonGuid is intentionally not checked here; on standalone servers using NTLM it is always all-zeros, //which would incorrectly suppress RDP (type 7/10) logons. + //For admin users, Windows creates two linked logon sessions (elevated and filtered/non-elevated tokens). + //The non-elevated linked token (ElevatedToken=%%1843, TargetLinkedLogonId!=0) is filtered to avoid duplicates. return ((uint) eventProperties[8] != 2 && (uint) eventProperties[8] != 7 && (uint) eventProperties[8] != 10) || - (string) eventProperties[18] == "-"; + (string) eventProperties[18] == "-" || + ((ulong) eventProperties[22] != 0 && eventProperties[21].ToString() == "%%1843"); } public static bool IsFailureNotValid(IList eventProperties) @@ -500,10 +507,17 @@ public static bool IsFailureNotValid(IList eventProperties) public static bool IsLogoffNotValid(IList eventProperties, bool isUserInitiated) { + //Filter Windows virtual session accounts (Window Manager/DWM, SID prefix S-1-5-90-, and + //Font Driver Host/UMFD, SID prefix S-1-5-96-) that are created per RDP session but are + //not actual user accounts. Their logoff events fire when the RDP session disconnects. + var sid = eventProperties[0].ToString(); + if (sid.StartsWith("S-1-5-90-") || sid.StartsWith("S-1-5-96-")) + return true; + //For event 4647 (user-initiated logoff) there is no LogonType field; always include it. - //For event 4634, only interactive logon types 2 and 10 are of interest (LogonType is at index 4). + //For event 4634, only interactive logon types 2, 7, and 10 are of interest (LogonType is at index 4). return !isUserInitiated && - (uint) eventProperties[4] != 2 && (uint) eventProperties[4] != 10; + (uint) eventProperties[4] != 2 && (uint) eventProperties[4] != 7 && (uint) eventProperties[4] != 10; } } } \ No newline at end of file