From 84e2eda1d49b004fffb0e7fce89af1396d447e16 Mon Sep 17 00:00:00 2001 From: BiosSystem <63607038+BiosSystem@users.noreply.github.com> Date: Thu, 17 Sep 2026 08:34:17 +0300 Subject: [PATCH] Log watchdog integrity failures and drift to the event log The watchdog only wrote to a text file in %ProgramData%\Winnow, which a local attacker with access to that box can also edit, and which nothing collects centrally. For a control that runs as SYSTEM and fails closed on tampering, the tamper signal is the thing you most want auditable. Register a Winnow event source in the Application log at install and have the payload mirror its security-relevant lines there: an integrity failure as an Error (event 2000) and a corrected drift as a Warning (event 1000). Writing is guarded on the source existing and never throws, so a missing source just falls back to the text log. Added event-source-present and event-source-missing cases to the tests. --- CHANGELOG.md | 1 + Scripts/Features/UpdateWatchdog.ps1 | 13 +++++++++++ Scripts/Watchdog/WatchdogPayload.ps1 | 33 ++++++++++++++++++++++++++-- Tests/Unit/Test-UpdateWatchdog.ps1 | 19 ++++++++++++++++ docs/Telemetry-And-Privacy.md | 2 ++ 5 files changed, 66 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65fce98..97d7340 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Ve ### Added +- The update watchdog now mirrors its security-relevant lines to the Windows event log (source `Winnow`, `Application` log, registered at install): an integrity failure as an Error (event ID `2000`) and a corrected drift as a Warning (event ID `1000`). A tamper attempt against a SYSTEM control is now auditable and SIEM-collectable, not only in a local text file the same attacker could edit. Falls back to the text log if the source cannot be registered. - `-VerifyWatchdog` prints a read-only health report for the update watchdog: whether the task is registered, whether the payload still matches the hash recorded at install, whether the payload directory is still locked down, and when it last ran. Exits `0` when healthy and `2` when degraded or not installed, so a fail-closed watchdog that quietly refused to run is now visible to a scheduled check or monitoring script. ### Changed diff --git a/Scripts/Features/UpdateWatchdog.ps1 b/Scripts/Features/UpdateWatchdog.ps1 index 673350f..cea17c2 100644 --- a/Scripts/Features/UpdateWatchdog.ps1 +++ b/Scripts/Features/UpdateWatchdog.ps1 @@ -83,6 +83,19 @@ function Invoke-InstallUpdateWatchdog { Set-ItemProperty -LiteralPath $regPath -Name 'SchemaVersion' -Value $schemaVersion -Type DWord -Force Set-ItemProperty -LiteralPath $regPath -Name 'InstalledUtc' -Value ((Get-Date).ToUniversalTime().ToString('o')) -Type String -Force + # Register a Windows event log source so the payload can record integrity + # failures and corrected drift centrally, not only in its text log. This + # needs admin, which the installer already has; if it fails the payload + # falls back to the text log alone. + try { + if (-not [System.Diagnostics.EventLog]::SourceExists('Winnow')) { + New-EventLog -LogName Application -Source 'Winnow' -ErrorAction Stop + } + } + catch { + Write-Host " [WARN] Could not register the Winnow event source; the watchdog will log to its text file only." -ForegroundColor Yellow + } + # Remove any prior task before re-registering. $existingTask = Get-ScheduledTask -TaskName $taskName -TaskPath $taskPath -ErrorAction SilentlyContinue if ($existingTask) { diff --git a/Scripts/Watchdog/WatchdogPayload.ps1 b/Scripts/Watchdog/WatchdogPayload.ps1 index 691054c..4271e7e 100644 --- a/Scripts/Watchdog/WatchdogPayload.ps1 +++ b/Scripts/Watchdog/WatchdogPayload.ps1 @@ -48,6 +48,31 @@ function Write-WinnowWatchdogLog { catch { } } +function Test-WinnowWatchdogEventSource { + param([string]$Source = 'Winnow') + try { return [System.Diagnostics.EventLog]::SourceExists($Source) } + catch { return $false } +} + +function Write-WinnowWatchdogEvent { + # Mirror the security-relevant lines to the Windows event log so a tamper + # attempt or a corrected drift is auditable centrally, not just in a text + # file a local attacker could also edit. The installer registers the source; + # if it is missing this is a no-op and the text log still records everything. + param( + [Parameter(Mandatory)] + [string]$Message, + [ValidateSet('Information', 'Warning', 'Error')] + [string]$EntryType = 'Information', + [int]$EventId = 1000, + [string]$Source = 'Winnow' + ) + + if (-not (Test-WinnowWatchdogEventSource -Source $Source)) { return } + try { Write-EventLog -LogName Application -Source $Source -EntryType $EntryType -EventId $EventId -Message $Message -ErrorAction Stop } + catch { } +} + function Set-WinnowWatchdogDirectoryAcl { # Lock the payload directory so only SYSTEM and Administrators can change what # a SYSTEM task later executes. Standard users keep read and execute, nothing @@ -200,7 +225,9 @@ function Invoke-WinnowWatchdog { catch { Write-WinnowWatchdogLog -Message "WARN could not re-harden the payload directory: $($_.Exception.Message)" -LogPath $context.LogPath } if (-not (Test-WinnowWatchdogIntegrity -PayloadPath $context.PayloadPath -RegPath $context.RegPath)) { - Write-WinnowWatchdogLog -Message 'SECURITY the payload failed its integrity check. Refusing to enforce. Re-run Winnow to reinstall the watchdog.' -LogPath $context.LogPath + $integrityMessage = 'The watchdog payload failed its integrity check. Refusing to enforce. Re-run Winnow to reinstall the watchdog.' + Write-WinnowWatchdogLog -Message "SECURITY $integrityMessage" -LogPath $context.LogPath + Write-WinnowWatchdogEvent -Message $integrityMessage -EntryType Error -EventId 2000 return } @@ -208,7 +235,9 @@ function Invoke-WinnowWatchdog { $reasserted = @(Invoke-WinnowWatchdogEnforcement -DesiredState $desiredState) if ($reasserted.Count -gt 0) { - Write-WinnowWatchdogLog -Message ("Corrected drift on: " + ($reasserted -join ', ')) -LogPath $context.LogPath + $driftMessage = "Corrected drift on: " + ($reasserted -join ', ') + Write-WinnowWatchdogLog -Message $driftMessage -LogPath $context.LogPath + Write-WinnowWatchdogEvent -Message $driftMessage -EntryType Warning -EventId 1000 } else { Write-WinnowWatchdogLog -Message 'Privacy floor intact, nothing to re-apply.' -LogPath $context.LogPath diff --git a/Tests/Unit/Test-UpdateWatchdog.ps1 b/Tests/Unit/Test-UpdateWatchdog.ps1 index de37321..8adea3f 100644 --- a/Tests/Unit/Test-UpdateWatchdog.ps1 +++ b/Tests/Unit/Test-UpdateWatchdog.ps1 @@ -165,6 +165,25 @@ Describe 'Winnow update watchdog enforcement' { } } +Describe 'Winnow update watchdog event logging' { + It 'writes to the event log when the source is registered' { + Mock -CommandName Test-WinnowWatchdogEventSource -MockWith { $true } + Mock -CommandName Write-EventLog -MockWith { } + + Write-WinnowWatchdogEvent -Message 'integrity failed' -EntryType Error -EventId 2000 + + Should -Invoke -CommandName Write-EventLog -Times 1 -Exactly + } + + It 'stays silent and does not throw when the source is missing' { + Mock -CommandName Test-WinnowWatchdogEventSource -MockWith { $false } + Mock -CommandName Write-EventLog -MockWith { } + + { Write-WinnowWatchdogEvent -Message 'drift corrected' -EntryType Warning } | Should -Not -Throw + Should -Invoke -CommandName Write-EventLog -Times 0 -Exactly + } +} + Describe 'Winnow update watchdog health' { It 'reports healthy when installed, intact, and locked down' { Mock -CommandName Get-ScheduledTask -MockWith { [PSCustomObject]@{ TaskName = 'Winnow_UpdateWatchdog' } } diff --git a/docs/Telemetry-And-Privacy.md b/docs/Telemetry-And-Privacy.md index 0e93d8d..83134fe 100644 --- a/docs/Telemetry-And-Privacy.md +++ b/docs/Telemetry-And-Privacy.md @@ -25,3 +25,5 @@ The task runs as SYSTEM, so the script it executes is a privileged target. Winno * **Self-integrity check.** At install the payload's SHA256 is recorded under `HKLM\SOFTWARE\Winnow\Watchdog` (writable only by an admin or SYSTEM). Before enforcing, the payload re-hashes itself and refuses to run if the hash does not match, so a swapped-out payload cannot make the SYSTEM task apply an attacker's settings. Missing or unreadable state fails closed. If the check fails it logs a security warning and does nothing; re-running Winnow reinstalls a clean payload. Because a fail-closed control is silent when it refuses to act, `Winnow.ps1 -VerifyWatchdog` prints a read-only health report: whether the task is registered, whether the payload still matches its recorded hash, whether the directory is still locked down, and when it last ran. It exits `0` when healthy and `2` when degraded or not installed, so it drops into a scheduled check or a monitoring script. + +The watchdog also mirrors its security-relevant lines to the Windows event log (source `Winnow`, in the `Application` log), registered by the installer: an integrity failure is written as an Error (event ID `2000`) and a corrected drift as a Warning (event ID `1000`). This makes a tamper attempt or a reset auditable centrally and collectable by a SIEM, not only readable in a local text file that the same local attacker could edit. If the source cannot be registered the payload falls back to the text log alone.