diff --git a/README.md b/README.md
index 4e1f078..3931ff7 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,31 @@ 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
+
+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
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..4ddf18a 100644
--- a/Seq.Client.WindowsLogins.Tests/EventTests.cs
+++ b/Seq.Client.WindowsLogins.Tests/EventTests.cs
@@ -29,12 +29,73 @@ 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));
}
+ ///
+ /// 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", "%%1843", (ulong) 0
+ };
+
+ 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", "%%1843", (ulong) 0
+ };
+
+ 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", "%%1843", (ulong) 0
+ };
+
+ Assert.True(EventLogListener.IsNotValid(test));
+ }
+
///
/// Ensure invalid properties won't be passed
///
@@ -48,12 +109,241 @@ 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 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],
+ // 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 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)
+ ///
+ [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..6798da2 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
@@ -164,12 +200,14 @@ private static void HandleEventLogEntry(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;
@@ -224,6 +262,8 @@ private static void HandleEventLogEntry(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)
@@ -240,12 +280,244 @@ 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 ||
+ //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.
+ //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] == "-" ||
- eventProperties[12].ToString() == "00000000-0000-0000-0000-000000000000";
+ ((ulong) eventProperties[22] != 0 && eventProperties[21].ToString() == "%%1843");
+ }
+
+ public static bool IsFailureNotValid(IList eventProperties)
+ {
+ //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)
+ {
+ //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, 7, and 10 are of interest (LogonType is at index 4).
+ return !isUserInitiated &&
+ (uint) eventProperties[4] != 2 && (uint) eventProperties[4] != 7 && (uint) eventProperties[4] != 10;
}
}
}
\ No newline at end of file