Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions Scripts/Features/UpdateWatchdog.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
33 changes: 31 additions & 2 deletions Scripts/Watchdog/WatchdogPayload.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -200,15 +225,19 @@ 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
}

$desiredState = Get-WinnowWatchdogDesiredState
$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
Expand Down
19 changes: 19 additions & 0 deletions Tests/Unit/Test-UpdateWatchdog.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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' } }
Expand Down
2 changes: 2 additions & 0 deletions docs/Telemetry-And-Privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.