Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
294 changes: 292 additions & 2 deletions Seq.Client.WindowsLogins.Tests/EventTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

/// <summary>
/// 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.
/// </summary>
[Fact]
public void EvaluatesValidRdpEventWithZeroLogonGuid()
{
IList<object> test = new List<object>
{
"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));
}

/// <summary>
/// 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.
/// </summary>
[Fact]
public void EvaluatesValidRdpUnlockEvent()
{
IList<object> test = new List<object>
{
"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));
}

/// <summary>
/// Ensure a type 7 (Unlock) logon with IpAddress="-" is filtered.
/// This is a local console unlock and is not of interest.
/// </summary>
[Fact]
public void EvaluatesInvalidLocalConsoleUnlockEvent()
{
IList<object> test = new List<object>
{
"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));
}

/// <summary>
/// Ensure invalid properties won't be passed
/// </summary>
Expand All @@ -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));
}

/// <summary>
/// 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.
/// </summary>
[Fact]
public void EvaluatesLinkedNonElevatedLogonFiltered()
{
// Simulates the non-elevated (filtered) token: ElevatedToken=%%1843, TargetLinkedLogonId != 0
IList<object> test = new List<object>
{
"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));
}

/// <summary>
/// Ensure the elevated linked logon token is accepted for admin RDP users.
/// The elevated token (ElevatedToken=%%1842) of the linked pair must be kept.
/// </summary>
[Fact]
public void EvaluatesLinkedElevatedLogonAccepted()
{
// Simulates the elevated token: ElevatedToken=%%1842, TargetLinkedLogonId != 0
IList<object> test = new List<object>
{
"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));
}

/// <summary>
/// Ensure a non-admin RDP logon (ElevatedToken=%%1843, no linked session) is accepted.
/// Non-admin users have no linked logon session, so TargetLinkedLogonId == 0.
/// </summary>
[Fact]
public void EvaluatesNonAdminRdpLogonAccepted()
{
IList<object> test = new List<object>
{
"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));
}

/// <summary>
/// Ensure a valid 4634 logoff event (interactive type 7, RDP reconnect session) is accepted
/// </summary>
[Fact]
public void EvaluatesValidLogoffEventType7()
{
IList<object> test = new List<object>
{
"S-1-5-21-1234", "Barry", "BARRY", "Barry", (uint) 7
};

Assert.False(EventLogListener.IsLogoffNotValid(test, false));
}

/// <summary>
/// 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.
/// </summary>
[Fact]
public void EvaluatesDwmLogoffEventFiltered()
{
IList<object> test = new List<object>
{
"S-1-5-90-0-2", "DWM-2", "Window Manager", "Barry", (uint) 2
};

Assert.True(EventLogListener.IsLogoffNotValid(test, false));
}

/// <summary>
/// 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.
/// </summary>
[Fact]
public void EvaluatesUmfdLogoffEventFiltered()
{
IList<object> test = new List<object>
{
"S-1-5-96-0-2", "UMFD-2", "Font Driver Host", "Barry", (uint) 2
};

Assert.True(EventLogListener.IsLogoffNotValid(test, false));
}

/// <summary>
/// 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).
/// </summary>
[Fact]
public void EvaluatesDwmUserInitiatedLogoffFiltered()
{
IList<object> test = new List<object>
{
"S-1-5-90-0-2", "DWM-2", "Window Manager", "Barry"
};

Assert.True(EventLogListener.IsLogoffNotValid(test, true));
}

/// <summary>
/// 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<object> test = new List<object>
{
"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));
}

/// <summary>
/// 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.
/// </summary>
[Fact]
public void EvaluatesValidNtlmRdpFailureEvent()
{
IList<object> test = new List<object>
{
"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));
}

/// <summary>
/// Ensure invalid failure event properties (non-interactive logon type) won't be passed (event 4625)
/// </summary>
[Fact]
public void EvaluatesInvalidFailureEvent()
{
IList<object> test = new List<object>
{
"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));
}

/// <summary>
/// Ensure a valid 4634 logoff event (interactive type 10) is accepted
/// </summary>
[Fact]
public void EvaluatesValidLogoffEvent()
{
// 4634 property layout: TargetUserSid[0], TargetUserName[1], TargetDomainName[2], TargetLogonId[3], LogonType[4]
IList<object> test = new List<object>
{
"00000000-0000-0000-0000-000000000001", "Barry", "BARRY", "Barry", (uint) 10
};

Assert.False(EventLogListener.IsLogoffNotValid(test, false));
}

/// <summary>
/// Ensure an invalid 4634 logoff event (non-interactive logon type) is rejected
/// </summary>
[Fact]
public void EvaluatesInvalidLogoffEvent()
{
IList<object> test = new List<object>
{
"00000000-0000-0000-0000-000000000001", "Barry", "BARRY", "Barry", (uint) 3
};

Assert.True(EventLogListener.IsLogoffNotValid(test, false));
}

/// <summary>
/// Ensure a 4647 user-initiated logoff event is always accepted (no LogonType field)
/// </summary>
[Fact]
public void EvaluatesValidUserInitiatedLogoffEvent()
{
// 4647 property layout: TargetUserSid[0], TargetUserName[1], TargetDomainName[2], TargetLogonId[3]
IList<object> test = new List<object>
{
"00000000-0000-0000-0000-000000000001", "Barry", "BARRY", "Barry"
};

Assert.False(EventLogListener.IsLogoffNotValid(test, true));
}

/// <summary>
/// Allow a single event to expire after 2 seconds
/// </summary>
Expand Down
4 changes: 4 additions & 0 deletions Seq.Client.WindowsLogins/App.config
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
<add key="HeartbeatInterval" value="600" />
<!-- Set IsDebug to true for additional heartbeat logging -->
<add key="IsDebug" value="false" />
<!-- Set IncludeLogonFailures to true to capture failed logon events (Event ID 4625) -->
<add key="IncludeLogonFailures" value="false" />
<!-- Set IncludeLogoffEvents to true to capture logoff events (Event IDs 4634 and 4647) -->
<add key="IncludeLogoffEvents" value="false" />
<add key="ProjectKey" value="PROJECT" />
<add key="Priority" value="Medium" />
<add key="Responders" value="JSmith" />
Expand Down
4 changes: 4 additions & 0 deletions Seq.Client.WindowsLogins/Config.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -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; }
Expand Down
Loading