From d21950ffa98a926d610484e03d6869781e7b270e Mon Sep 17 00:00:00 2001 From: Blackwell Code <161588956+Blackwellcode@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:32:41 +0100 Subject: [PATCH 1/4] audit: add five read-only checks from the original manual audit The original audit tool (manual run of 2026-11-09) also inspected settings that winsec-audit did not cover yet. Its read-only parts are carried over as checks; its hardening, rollback and update modes write to the system and stay out of this read-only tool. - DEF-09 email and removable drive scanning - NET-08 Point and Print driver installation (PrintNightmare) - NET-09 legacy SMB1/Telnet/TFTP client features (elevated) - LOG-04 Security/PowerShell/Defender event log sizes - LOG-05 process creation auditing via a recorded event 4688 (elevated; without elevation the Security query returns "no events" instead of access denied, so the check must not run unelevated) Smoke test: every declared Invoke-Check must be reported exactly once. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 ++++++ CLAUDE.md | 2 +- README.md | 10 +++--- tests/Test-Smoke.ps1 | 16 ++++++++++ winsec-audit.ps1 | 72 +++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 102 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc1b956..5a9a2f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project are documented here. Format: [Keep a Changel ## [Unreleased] +### Added +- Five read-only checks carried over from the original manual audit (now 49): + - DEF-09 email and removable drive scanning; + - NET-08 Point and Print driver installation restricted to administrators (PrintNightmare); + - NET-09 legacy SMB1/Telnet/TFTP client features (elevated); + - LOG-04 maximum size of the Security, PowerShell and Defender event logs; + - LOG-05 process creation auditing, proven by a recorded event 4688 (elevated). + ### Security - `dism`, `sfc` and `tpmtool` are started from `%SystemRoot%\System32` (Sysnative under 32-bit PowerShell) instead of being resolved through `PATH`. - `winget.exe` is taken from the installed App Installer package and must carry a valid Microsoft signature; the per-user execution alias on `PATH` is no longer used. @@ -14,6 +22,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel ### Tests - Read-only guard: `Write-*` is limited to Host/Output/Verbose/Warning/Error/Information (`Write-EventLog` and `Write-Progress` fail); `New-Object` and `::new()` of file and stream types are rejected; the method deny list adds `Create`, `Copy`, `Move`, `Replace`, `OpenWrite`, `CreateDirectory`, `Stop`, `Terminate`, `ChangeStartMode` and `Remove`. - Guard self-test: one negative sample per new rule (32 samples), runs the guard under the same PowerShell edition, `-WorkDir` for the sample files. +- Smoke test: every `Invoke-Check` declared in the script must appear exactly once in `report.json`. ### CI - GitHub Actions pinned to commit SHAs; PSScriptAnalyzer pinned with `-RequiredVersion`. diff --git a/CLAUDE.md b/CLAUDE.md index b6d7e73..ff55ff9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # winsec-audit – Projektanker -Read-only Security-Audit für Windows 10/11, ein PowerShell-Skript (`winsec-audit.ps1`), kompatibel mit Windows PowerShell 5.1 und PowerShell 7. Herkunft: das manuelle Audit vom 2026-11-09 (`F:\tools\windows-security-audit`). +Read-only Security-Audit für Windows 10/11, ein PowerShell-Skript (`winsec-audit.ps1`), kompatibel mit Windows PowerShell 5.1 und PowerShell 7. Herkunft: das manuelle Audit vom 2026-11-09, archiviert unter `F:\archiv\windows-security-audit-20261112` (dort auch das schreibende Härtungsskript `Invoke-SecurityHardening.ps1`, das hier bewusst nicht übernommen wurde). ## Invarianten (nicht verhandelbar) diff --git a/README.md b/README.md index 2d5267d..fccff11 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![ci](https://github.com/Blackwellcode/winsec-audit/actions/workflows/ci.yml/badge.svg)](https://github.com/Blackwellcode/winsec-audit/actions/workflows/ci.yml) -Lightweight, **read-only** security audit for Windows 10/11 workstations. A single PowerShell script with no dependencies. It runs 44 checks and writes a Markdown and a JSON report. +Lightweight, **read-only** security audit for Windows 10/11 workstations. A single PowerShell script with no dependencies. It runs 49 checks and writes a Markdown and a JSON report. It never changes your system: no registry writes, no service or firewall changes, no scans, no repairs, no installs. That is enforced on every commit by a static AST guard plus a runtime test that fingerprints system state before and after the run. @@ -32,12 +32,12 @@ Downloaded the ZIP instead of cloning? Windows marks it as coming from the inter | Area | Checks | | :--- | :--- | | Patching | last successful update install, pending reboot | -| Microsoft Defender | real-time/tamper protection, signature age, scan age, cloud/PUA/network protection, ASR rules, exclusions, active threats, Smart App Control | +| Microsoft Defender | real-time/tamper protection, signature age, scan age, cloud/PUA/network protection, ASR rules, exclusions, active threats, Smart App Control, email and removable drive scanning | | Firewall | profiles and default inbound action, script interpreters reachable inbound on Public networks, network categories | -| Network | SMBv1, SMB signing and guest logons, RDP/NLA, WinRM/RemoteRegistry/sshd/Telnet/Remote Assistance, LLMNR/NetBIOS, listening ports, hosts file | +| Network | SMBv1, SMB signing and guest logons, RDP/NLA, WinRM/RemoteRegistry/sshd/Telnet/Remote Assistance, LLMNR/NetBIOS, listening ports, hosts file, Point and Print driver installation (PrintNightmare), legacy SMB1/Telnet/TFTP client features | | Accounts | UAC, built-in Administrator/Guest, local administrators, auto-logon password, enabled local accounts | | Platform | Secure Boot, TPM 2.0, BitLocker, HVCI/Credential Guard, LSA protection/WDigest, vulnerable driver blocklist | -| Logging | script block logging and 4688 command lines, execution policy, PowerShell 2.0 | +| Logging | script block logging and 4688 command lines, execution policy, PowerShell 2.0, event log sizes, process creation auditing (event 4688 recorded) | | Persistence | unsigned autostarts, orphaned/unsigned scheduled tasks, unsigned services and unquoted service paths, Winlogon/AppInit/IFEO hijacks, WMI subscriptions, local root CAs with private keys | | Software | outdated packages via `winget upgrade` | | Integrity | unsigned kernel drivers in `System32\drivers`; with `-Deep`: DISM and SFC | @@ -49,7 +49,7 @@ Each check reports `Pass`, `Warn`, `Fail`, `Info`, `Skipped` or `Error`, with ev ```mermaid flowchart LR subgraph Script["winsec-audit.ps1"] - C["44 × Invoke-Check
-RequiresAdmin, -DeepOnly"] --> F["Findings
Pass · Warn · Fail · Info · Skipped · Error"] + C["49 × Invoke-Check
-RequiresAdmin, -DeepOnly"] --> F["Findings
Pass · Warn · Fail · Info · Skipped · Error"] F --> W["Write-Report"] end N["Native allowlist
tpmtool · winget · dism · sfc"] -->|fixed arguments| C diff --git a/tests/Test-Smoke.ps1 b/tests/Test-Smoke.ps1 index 14ac6df..c33b5cc 100644 --- a/tests/Test-Smoke.ps1 +++ b/tests/Test-Smoke.ps1 @@ -144,6 +144,22 @@ try { if (-not $r.PSObject.Properties[$field]) { $problems.Add("$($r.Id): field '$field' missing") } } } + # every Invoke-Check declared in the script reports exactly once + $tokens = $null; $parseErrors = $null + $scriptAst = [System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path -LiteralPath $audit).Path, [ref]$tokens, [ref]$parseErrors) + $declared = @($scriptAst.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -eq 'Invoke-Check' }, $true) | ForEach-Object { + $els = $_.CommandElements + for ($i = 1; $i -lt $els.Count - 1; $i++) { + if ($els[$i] -is [System.Management.Automation.Language.CommandParameterAst] -and $els[$i].ParameterName -eq 'Id') { $els[$i + 1].Extent.Text.Trim("'", '"') } + } + }) + $reported = @($results | ForEach-Object { [string]$_.Id }) + foreach ($id in $declared) { + $n = @($reported | Where-Object { $_ -eq $id }).Count + if ($n -ne 1) { $problems.Add("check $id reported $n time(s), expected 1") } + } + if ($reported.Count -ne $declared.Count) { $problems.Add("$($reported.Count) result(s) for $($declared.Count) declared check(s)") } + $errors = @($results | Where-Object { $_.Status -eq 'Error' }) foreach ($e in $errors) { Write-Host " Error in $($e.Id): $($e.Evidence)" -ForegroundColor Yellow } if ($errors.Count -gt $MaxErrors) { $problems.Add("$($errors.Count) check(s) ended with Error (max $MaxErrors)") } diff --git a/winsec-audit.ps1 b/winsec-audit.ps1 index 3bb7bf4..0a91550 100644 --- a/winsec-audit.ps1 +++ b/winsec-audit.ps1 @@ -3,7 +3,7 @@ winsec-audit - read-only security audit for Windows 10/11 workstations. .DESCRIPTION - Runs 44 checks (patching, Microsoft Defender, firewall, network exposure, accounts, + Runs 49 checks (patching, Microsoft Defender, firewall, network exposure, accounts, boot integrity, logging, persistence, software updates, system integrity) and writes report.md and report.json. The script never changes system state; tests/Test-ReadOnly.ps1 enforces that on every commit. Without elevation, admin-only checks are reported as Skipped. @@ -350,6 +350,16 @@ Invoke-Check -Id 'DEF-08' -Category 'Defender' -Title 'Smart App Control state' New-Finding Info "Smart App Control: $sac. (Once off it can only be re-enabled by reinstalling Windows.)" } +Invoke-Check -Id 'DEF-09' -Category 'Defender' -Title 'Email and removable drive scanning on' -Severity 'Low' -Weight 2 -Body { + Assert-Defender + $p = $script:MpPref + $email = [bool](Get-PropValue $p 'DisableEmailScanning') + $usb = [bool](Get-PropValue $p 'DisableRemovableDriveScanning') + $ev = "DisableEmailScanning=$email, DisableRemovableDriveScanning=$usb, CloudExtendedTimeout=$([int](Get-PropValue $p 'CloudExtendedTimeout')) s." + if (-not $email -and -not $usb) { return New-Finding Pass $ev } + New-Finding Warn $ev 'Set-MpPreference -DisableEmailScanning $false -DisableRemovableDriveScanning $false (both are off by default).' +} + # --------------------------------------------------------------------------- # Firewall # --------------------------------------------------------------------------- @@ -445,6 +455,31 @@ Invoke-Check -Id 'NET-07' -Category 'Network' -Title 'Hosts file not redirecting New-Finding Pass "$($entries.Count) active entr(y/ies). $(Join-Limited $entries 5)" } +Invoke-Check -Id 'NET-08' -Category 'Network' -Title 'Point and Print: printer drivers installed by administrators only' -Severity 'High' -Weight 3 -Body { + $k = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Printers\PointAndPrint' + $restrict = Get-RegValue $k 'RestrictDriverInstallationToAdministrators' + $noWarn = Get-RegValue $k 'NoWarningNoElevationOnInstall' + $update = Get-RegValue $k 'UpdatePromptSettings' + $ev = "RestrictDriverInstallationToAdministrators=$(if ($null -eq $restrict) { 'not set (default since KB5005652: administrators only)' } else { $restrict }); NoWarningNoElevationOnInstall=$noWarn; UpdatePromptSettings=$update." + $fix = 'Set the policy RestrictDriverInstallationToAdministrators=1 (or remove the value) and NoWarningNoElevationOnInstall=0 (PrintNightmare, CVE-2021-34527).' + if ($restrict -ne 0) { return New-Finding Pass $ev } + if ($noWarn -eq 1 -or $update -in 1, 2) { return New-Finding Fail $ev $fix } + New-Finding Warn $ev $fix +} + +Invoke-Check -Id 'NET-09' -Category 'Network' -Title 'Legacy client features removed (SMB1, Telnet, TFTP)' -Severity 'Medium' -Weight 2 -RequiresAdmin -Body { + $names = 'SMB1Protocol', 'TelnetClient', 'TFTP' + # one full listing instead of -FeatureName: an unknown name (e.g. on Windows Server) must not end the check + $features = @(Get-WindowsOptionalFeature -Online -ErrorAction Stop | Where-Object { $_.FeatureName -in $names }) + $state = @{}; foreach ($f in $features) { $state[$f.FeatureName] = $f.State.ToString() } + $ev = ($names | ForEach-Object { "${_}: $(if ($state.ContainsKey($_)) { $state[$_] } else { 'not present' })" }) -join '; ' + $on = @($names | Where-Object { $state.ContainsKey($_) -and $state[$_] -eq 'Enabled' }) + $fix = "Disable-WindowsOptionalFeature -Online -FeatureName $($on -join ', ') (elevated)." + if ($on -contains 'SMB1Protocol') { return New-Finding Fail "$ev." $fix } + if ($on) { return New-Finding Warn "$ev." $fix } + New-Finding Pass "$ev." +} + # --------------------------------------------------------------------------- # Accounts # --------------------------------------------------------------------------- @@ -584,6 +619,41 @@ Invoke-Check -Id 'LOG-03' -Category 'Logging' -Title 'PowerShell 2.0 engine remo New-Finding Pass "PowerShell 2.0: $state." } +Invoke-Check -Id 'LOG-04' -Category 'Logging' -Title 'Security event logs large enough for forensics' -Severity 'Low' -Weight 2 -Body { + $targets = [ordered]@{ 'Security' = 256; 'Microsoft-Windows-PowerShell/Operational' = 128; 'Microsoft-Windows-Windows Defender/Operational' = 64 } + $parts = @(); $issues = @() + foreach ($name in $targets.Keys) { + try { $log = Get-WinEvent -ListLog $name -ErrorAction Stop } + catch { + $id = $_.FullyQualifiedErrorId + if ($id -like 'NoMatchingLogsFound*') { $parts += "$name not present"; continue } + # without elevation the Security log reports LogInfoUnavailable + if ($id -like 'LogInfoUnavailable*' -or $_.Exception -is [System.UnauthorizedAccessException]) { $parts += "$name unreadable"; $issues += "$name not readable (run elevated)"; continue } + throw + } + $mb = [math]::Round($log.MaximumSizeInBytes / 1MB) + $parts += "$name $mb MB$(if (-not $log.IsEnabled) { ' (disabled)' })" + if (-not $log.IsEnabled) { $issues += "$name disabled" } + elseif ($log.MaximumSizeInBytes -lt [int64]$targets[$name] * 1MB) { $issues += "$name $mb MB < $($targets[$name]) MB" } + } + $ev = "$($parts -join '; ')." + if (-not $issues) { return New-Finding Pass $ev } + New-Finding Warn "$ev $($issues -join '; ')." 'Raise the maximum size (Event Viewer > log properties, or the Event Log policies): Security 256 MB, PowerShell/Operational 128 MB, Defender/Operational 64 MB.' +} + +Invoke-Check -Id 'LOG-05' -Category 'Logging' -Title 'Process creation auditing on (event 4688)' -Severity 'Medium' -Weight 3 -RequiresAdmin -Body { + # Elevation is required: without it, a Security log query returns "no events" instead of access denied. + $newest = $null + try { $newest = Get-WinEvent -FilterHashtable @{ LogName = 'Security'; Id = 4688 } -MaxEvents 1 -ErrorAction Stop } + catch { if ($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*') { throw } } + $fix = 'auditpol /set /subcategory:{0CCE922B-69AE-11D9-BED3-505054503030} /success:enable (elevated), or the policy "Audit Process Creation". LOG-01 adds the command lines.' + if (-not $newest) { return New-Finding Fail 'No event 4688 in the Security log: process creation is not audited.' $fix } + $age = (Get-Date) - $newest.TimeCreated + $ev = "Newest event 4688: $($newest.TimeCreated.ToString('s')) ($([math]::Round($age.TotalHours, 1)) h ago)." + if ($age.TotalHours -le 24) { return New-Finding Pass $ev } + New-Finding Warn "$ev Processes start constantly, so auditing was probably turned off since." $fix +} + # --------------------------------------------------------------------------- # Persistence # --------------------------------------------------------------------------- From d3bc710a19b17558e7be3c8945a1d0ab4f3c8e16 Mon Sep 17 00:00:00 2001 From: Blackwell Code <161588956+Blackwellcode@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:47:07 +0100 Subject: [PATCH 2/4] audit: no Pass from unreadable data; review fixes for the new checks - Get-MpPreference is queried apart from Get-MpComputerStatus. DEF-04/05/ 06/09 assert readable preferences; DEF-02/03/04/09 read values through Get-DefenderValue, which throws on $null instead of casting it to 0/False. Before, unreadable preferences or missing ages gave Pass (DEF-02, DEF-03, DEF-09; DEF-06 when elevated). - DEF-09 also rates CloudExtendedTimeout (target 50 s, the maximum). - LOG-04: unreadable or absent logs are Info (not scored) instead of Warn or Pass; size targets live in one table and build the fix text. - NET-09 counts EnablePending as on and only rates Telnet/TFTP. SMBv1 is scored once, in NET-01, which now also reads the SMB1Protocol feature when elevated (shared, cached optional-feature listing). - LOG-05 names its limits: Retain log mode warns, a switch-off within the last 24 h is not detectable. - Smoke test fails on parse errors of the script. - New Test-DefenderUnavailable.ps1 (CI step) shadows the two Defender cmdlets and requires Error for every dependent check. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 14 +-- CLAUDE.md | 2 + README.md | 6 +- tests/Test-DefenderUnavailable.ps1 | 73 +++++++++++++++ tests/Test-Smoke.ps1 | 2 + winsec-audit.ps1 | 143 ++++++++++++++++++++--------- 7 files changed, 188 insertions(+), 54 deletions(-) create mode 100644 tests/Test-DefenderUnavailable.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e29ad32..560b659 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,3 +43,5 @@ jobs: run: ./tests/Test-GuardSelf.ps1 - name: Smoke test (state fingerprint) run: ./tests/Test-Smoke.ps1 + - name: Defender data unavailable (Error, never Pass) + run: ./tests/Test-DefenderUnavailable.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a9a2f2..c1d670f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,29 +6,31 @@ All notable changes to this project are documented here. Format: [Keep a Changel ### Added - Five read-only checks carried over from the original manual audit (now 49): - - DEF-09 email and removable drive scanning; + - DEF-09 email and removable drive scanning, extended cloud check (`CloudExtendedTimeout` 50 s); - NET-08 Point and Print driver installation restricted to administrators (PrintNightmare); - - NET-09 legacy SMB1/Telnet/TFTP client features (elevated); - - LOG-04 maximum size of the Security, PowerShell and Defender event logs; - - LOG-05 process creation auditing, proven by a recorded event 4688 (elevated). - + - NET-09 legacy Telnet/TFTP clients (elevated; `EnablePending` counts as on); + - LOG-04 maximum size of the Security, PowerShell and Defender event logs (a log that cannot be read or does not exist is reported as Info, not scored); + - LOG-05 process creation auditing, proven by a recorded event 4688 (elevated; Retain log mode warns; a switch-off within the last 24 h is not detectable and the finding says so). ### Security - `dism`, `sfc` and `tpmtool` are started from `%SystemRoot%\System32` (Sysnative under 32-bit PowerShell) instead of being resolved through `PATH`. - `winget.exe` is taken from the installed App Installer package and must carry a valid Microsoft signature; the per-user execution alias on `PATH` is no longer used. ### Fixed - DEF-07 (*No active threats*) reports **Error** when the Defender threat query fails instead of reporting **Pass**. +- Unreadable Defender data no longer yields a false **Pass**: `Get-MpPreference` is queried separately from `Get-MpComputerStatus`, DEF-04/05/06/09 require readable preferences, and DEF-02/03/04/09 end as **Error** when a value is not reported (a `$null` cast to 0/False used to pass). ### Tests - Read-only guard: `Write-*` is limited to Host/Output/Verbose/Warning/Error/Information (`Write-EventLog` and `Write-Progress` fail); `New-Object` and `::new()` of file and stream types are rejected; the method deny list adds `Create`, `Copy`, `Move`, `Replace`, `OpenWrite`, `CreateDirectory`, `Stop`, `Terminate`, `ChangeStartMode` and `Remove`. - Guard self-test: one negative sample per new rule (32 samples), runs the guard under the same PowerShell edition, `-WorkDir` for the sample files. -- Smoke test: every `Invoke-Check` declared in the script must appear exactly once in `report.json`. +- Smoke test: every `Invoke-Check` declared in the script must appear exactly once in `report.json`; parse errors of the script fail the test. +- New `Test-DefenderUnavailable.ps1`: shadows `Get-MpPreference` (throws) and `Get-MpComputerStatus` (ages `$null`) and requires **Error** for DEF-02/03/04/05/09 (DEF-06 when elevated). ### CI - GitHub Actions pinned to commit SHAs; PSScriptAnalyzer pinned with `-RequiredVersion`. ### Changed - License holder: Blackwell Code. +- NET-01 *SMBv1 disabled* also reads the `SMB1Protocol` optional feature (SMBv1 client) when elevated. SMBv1 is scored only in NET-01. ## [0.1.0] - 2026-11-10 diff --git a/CLAUDE.md b/CLAUDE.md index ff55ff9..cb3e1b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,8 @@ Read-only Security-Audit für Windows 10/11, ein PowerShell-Skript (`winsec-audi - `.\tests\Test-ReadOnly.ps1` (Exit 0) - `.\tests\Test-GuardSelf.ps1` (Exit 0) - `.\tests\Test-Smoke.ps1` (Exit 0, Fingerprint unverändert) + - `.\tests\Test-DefenderUnavailable.ps1` (Exit 0) +- Nicht lesbare Werte enden als `Error` (oder `Info`, wenn nichts bewertbar ist), nie als `Pass`: kein Cast von `$null` auf 0/False (siehe `Get-DefenderValue`). - Guard-Regeln nicht aufweichen, um einen Check durchzubringen: Der Check wird angepasst, nicht der Guard. ## Konventionen diff --git a/README.md b/README.md index fccff11..81291b8 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,9 @@ Downloaded the ZIP instead of cloning? Windows marks it as coming from the inter | Area | Checks | | :--- | :--- | | Patching | last successful update install, pending reboot | -| Microsoft Defender | real-time/tamper protection, signature age, scan age, cloud/PUA/network protection, ASR rules, exclusions, active threats, Smart App Control, email and removable drive scanning | +| Microsoft Defender | real-time/tamper protection, signature age, scan age, cloud/PUA/network protection, ASR rules, exclusions, active threats, Smart App Control, email and removable drive scanning, extended cloud check | | Firewall | profiles and default inbound action, script interpreters reachable inbound on Public networks, network categories | -| Network | SMBv1, SMB signing and guest logons, RDP/NLA, WinRM/RemoteRegistry/sshd/Telnet/Remote Assistance, LLMNR/NetBIOS, listening ports, hosts file, Point and Print driver installation (PrintNightmare), legacy SMB1/Telnet/TFTP client features | +| Network | SMBv1 (server; client feature when elevated), SMB signing and guest logons, RDP/NLA, WinRM/RemoteRegistry/sshd/Telnet/Remote Assistance, LLMNR/NetBIOS, listening ports, hosts file, Point and Print driver installation (PrintNightmare), legacy Telnet/TFTP clients | | Accounts | UAC, built-in Administrator/Guest, local administrators, auto-logon password, enabled local accounts | | Platform | Secure Boot, TPM 2.0, BitLocker, HVCI/Credential Guard, LSA protection/WDigest, vulnerable driver blocklist | | Logging | script block logging and 4688 command lines, execution policy, PowerShell 2.0, event log sizes, process creation auditing (event 4688 recorded) | @@ -94,7 +94,7 @@ Reports contain host name, user name, account names, installed software and list .\tests\Test-Smoke.ps1 # run + state fingerprint + report schema ``` -CI (`.github/workflows/ci.yml`) runs PSScriptAnalyzer and all three tests on `windows-latest` under Windows PowerShell 5.1 and PowerShell 7. +CI (`.github/workflows/ci.yml`) runs PSScriptAnalyzer and all four tests on `windows-latest` under Windows PowerShell 5.1 and PowerShell 7. Adding a check: call `Invoke-Check` with an `Id`, `Category`, `Title`, `Severity`, `Weight` and a body that returns `New-Finding Pass|Warn|Fail|Info "" ""`. Use `-RequiresAdmin`/`-DeepOnly` where needed. The guard tells you if the body is not read-only. diff --git a/tests/Test-DefenderUnavailable.ps1 b/tests/Test-DefenderUnavailable.ps1 new file mode 100644 index 0000000..c57ca91 --- /dev/null +++ b/tests/Test-DefenderUnavailable.ps1 @@ -0,0 +1,73 @@ +<# +.SYNOPSIS + Defender checks must end as Error, never as Pass, when Defender data cannot be read. + +.DESCRIPTION + Shadows two Defender cmdlets with functions for one audit run (functions take precedence over cmdlets): + - Get-MpPreference throws (preferences not readable), + - Get-MpComputerStatus returns a status object whose signature and scan ages are $null. + Casting $null gives 0/False, which used to turn into a Pass. The test requires Error for every check that + depends on the missing data. Nothing on the system is read or changed through Defender in this run. +#> +[CmdletBinding()] +param( + # Folder for the temporary report (default: the user's temp folder) + [string]$WorkDir = [IO.Path]::GetTempPath() +) + +Set-StrictMode -Version 2.0 +$ErrorActionPreference = 'Stop' + +$audit = Join-Path $PSScriptRoot '..\winsec-audit.ps1' +$out = Join-Path $WorkDir ("winsec-defender-" + [guid]::NewGuid().ToString('N')) +$isAdmin = (New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) +$problems = New-Object System.Collections.Generic.List[string] + +# check id -> required status +$expect = [ordered]@{ + 'DEF-02' = 'Error' # AntivirusSignatureAge $null + 'DEF-03' = 'Error' # QuickScanAge/FullScanAge $null + 'DEF-04' = 'Error' # preferences not readable + 'DEF-05' = 'Error' + 'DEF-06' = $(if ($isAdmin) { 'Error' } else { 'Skipped' }) + 'DEF-09' = 'Error' +} + +function global:Get-MpPreference { throw (New-Object System.UnauthorizedAccessException('simulated: preferences not readable')) } +function global:Get-MpComputerStatus { + [pscustomobject]@{ + AMServiceEnabled = $true; AntivirusEnabled = $true; RealTimeProtectionEnabled = $true; BehaviorMonitorEnabled = $true + IoavProtectionEnabled = $true; OnAccessProtectionEnabled = $true; IsTamperProtected = $true + AMProductVersion = 'simulated'; AMEngineVersion = 'simulated'; AntivirusSignatureVersion = 'simulated' + AntivirusSignatureAge = $null; QuickScanAge = $null; FullScanAge = $null + } +} + +Write-Host "Elevated: $isAdmin" +try { + & $audit -OutputPath $out -SkipWinget -Quiet + if ($LASTEXITCODE -ne 0) { $problems.Add("audit exit code $LASTEXITCODE") } + $dir = @(Get-ChildItem -LiteralPath $out -Directory) | Select-Object -First 1 + if (-not $dir) { throw 'No report folder written.' } + $results = @((Get-Content -LiteralPath (Join-Path $dir.FullName 'report.json') -Raw | ConvertFrom-Json).results) + foreach ($id in $expect.Keys) { + $r = @($results | Where-Object { $_.Id -eq $id }) | Select-Object -First 1 + if (-not $r) { $problems.Add("$id missing from report"); continue } + $mark = if ($r.Status -eq $expect[$id]) { 'ok ' } else { 'FAIL' } + Write-Host ("[{0}] {1,-7} {2,-7} (expected {3}) {4}" -f $mark, $id, $r.Status, $expect[$id], $r.Evidence) + if ($r.Status -ne $expect[$id]) { $problems.Add("${id}: status $($r.Status), expected $($expect[$id])") } + } +} +finally { + Remove-Item -LiteralPath function:global:Get-MpPreference, function:global:Get-MpComputerStatus + if (Test-Path -LiteralPath $out) { Remove-Item -LiteralPath $out -Recurse -Force } +} + +if ($problems.Count) { + Write-Host 'DEFENDER-UNAVAILABLE TEST FAILED:' -ForegroundColor Red + $problems | ForEach-Object { Write-Host " $_" -ForegroundColor Red } + exit 1 +} +Write-Host "Defender-unavailable test passed: $($expect.Count) check(s) end as expected, none as Pass." -ForegroundColor Green +exit 0 diff --git a/tests/Test-Smoke.ps1 b/tests/Test-Smoke.ps1 index c33b5cc..9ff6cc4 100644 --- a/tests/Test-Smoke.ps1 +++ b/tests/Test-Smoke.ps1 @@ -147,12 +147,14 @@ try { # every Invoke-Check declared in the script reports exactly once $tokens = $null; $parseErrors = $null $scriptAst = [System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path -LiteralPath $audit).Path, [ref]$tokens, [ref]$parseErrors) + foreach ($pe in @($parseErrors)) { $problems.Add("parse error line $($pe.Extent.StartLineNumber): $($pe.Message)") } $declared = @($scriptAst.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -eq 'Invoke-Check' }, $true) | ForEach-Object { $els = $_.CommandElements for ($i = 1; $i -lt $els.Count - 1; $i++) { if ($els[$i] -is [System.Management.Automation.Language.CommandParameterAst] -and $els[$i].ParameterName -eq 'Id') { $els[$i + 1].Extent.Text.Trim("'", '"') } } }) + if (-not $declared) { $problems.Add('no Invoke-Check -Id found in the script') } $reported = @($results | ForEach-Object { [string]$_.Id }) foreach ($id in $declared) { $n = @($reported | Where-Object { $_ -eq $id }).Count diff --git a/winsec-audit.ps1 b/winsec-audit.ps1 index 0a91550..441ecf7 100644 --- a/winsec-audit.ps1 +++ b/winsec-audit.ps1 @@ -185,6 +185,22 @@ function Invoke-ReadOnlyNative { } } +$script:FeatureStates = $null +# Enabled or waiting for a reboot to become enabled: both count as on. +$script:ActiveFeatureStates = 'Enabled', 'EnablePending' + +function Get-OptionalFeatureState([string]$Name) { + # One listing for all checks, cached. -FeatureName with a name this edition does not know (e.g. TFTP on + # Windows Server) raises an error instead of returning nothing. Requires elevation. + if ($null -eq $script:FeatureStates) { + $map = @{} + foreach ($f in @(Get-WindowsOptionalFeature -Online -ErrorAction Stop)) { $map[$f.FeatureName] = $f.State.ToString() } + $script:FeatureStates = $map + } + if ($script:FeatureStates.ContainsKey($Name)) { return $script:FeatureStates[$Name] } + return 'not present' +} + function Join-Limited([object[]]$Items, [int]$Max = 12) { $list = @($Items) $shown = ($list | Select-Object -First $Max) -join '; ' @@ -209,12 +225,23 @@ $script:Meta = [ordered]@{ Deep = [bool]$Deep } -$script:MpStatus = $null; $script:MpPref = $null; $script:MpError = '' -try { $script:MpStatus = Get-MpComputerStatus; $script:MpPref = Get-MpPreference } -catch { $script:MpError = $_.Exception.Message } +$script:MpStatus = $null; $script:MpPref = $null; $script:MpError = ''; $script:MpPrefError = '' +# Queried separately: the status can be readable while the preferences are not. Both errors surface via Assert-Defender. +try { $script:MpStatus = Get-MpComputerStatus } +catch { $script:MpError = "$($_.Exception.GetType().Name): $($_.Exception.Message)" } +try { $script:MpPref = Get-MpPreference } +catch { $script:MpPrefError = "$($_.Exception.GetType().Name): $($_.Exception.Message)" } -function Assert-Defender { +function Assert-Defender([switch]$Preference) { if (-not $script:MpStatus) { throw "Microsoft Defender not queryable: $script:MpError" } + if ($Preference -and -not $script:MpPref) { throw "Microsoft Defender preferences not queryable: $script:MpPrefError" } +} + +function Get-DefenderValue($Object, [string]$Name) { + # A missing value must end the check as Error; casting $null gives 0/False and with it a false Pass. + $v = Get-PropValue $Object $Name + if ($null -eq $v) { throw "Microsoft Defender did not report '$Name'." } + return $v } if (-not $Quiet) { @@ -271,7 +298,7 @@ Invoke-Check -Id 'DEF-01' -Category 'Defender' -Title 'Real-time protection and Invoke-Check -Id 'DEF-02' -Category 'Defender' -Title 'Security intelligence up to date' -Severity 'High' -Weight 5 -Body { Assert-Defender - $age = [int]$script:MpStatus.AntivirusSignatureAge + $age = [int](Get-DefenderValue $script:MpStatus 'AntivirusSignatureAge') $ev = "Signature $($script:MpStatus.AntivirusSignatureVersion), $age day(s) old." if ($age -le 2) { return New-Finding Pass $ev } if ($age -le 7) { return New-Finding Warn $ev 'Update-MpSignature or Windows Update.' } @@ -280,7 +307,7 @@ Invoke-Check -Id 'DEF-02' -Category 'Defender' -Title 'Security intelligence up Invoke-Check -Id 'DEF-03' -Category 'Defender' -Title 'Recent quick and full scan' -Severity 'Low' -Weight 3 -Body { Assert-Defender - $q = [int64]$script:MpStatus.QuickScanAge; $f = [int64]$script:MpStatus.FullScanAge + $q = [int64](Get-DefenderValue $script:MpStatus 'QuickScanAge'); $f = [int64](Get-DefenderValue $script:MpStatus 'FullScanAge') $fs = if ($f -ge 4294967295) { 'never' } else { "$f day(s) ago" } $ev = "Quick scan $q day(s) ago, full scan $fs." if ($q -le 7 -and $f -le 35) { return New-Finding Pass $ev } @@ -288,14 +315,16 @@ Invoke-Check -Id 'DEF-03' -Category 'Defender' -Title 'Recent quick and full sca } Invoke-Check -Id 'DEF-04' -Category 'Defender' -Title 'Cloud protection, PUA and network protection' -Severity 'Medium' -Weight 5 -Body { - Assert-Defender + Assert-Defender -Preference $p = $script:MpPref + $maps = [int](Get-DefenderValue $p 'MAPSReporting'); $pua = [int](Get-DefenderValue $p 'PUAProtection') + $np = [int](Get-DefenderValue $p 'EnableNetworkProtection'); $cbl = [int](Get-DefenderValue $p 'CloudBlockLevel') $miss = @() - if ([int]$p.MAPSReporting -eq 0) { $miss += 'cloud protection (MAPS) off' } - if ([int]$p.PUAProtection -ne 1) { $miss += 'PUA protection not blocking' } - if ([int]$p.EnableNetworkProtection -ne 1) { $miss += 'network protection not enabled' } - if ([int]$p.CloudBlockLevel -lt 2) { $miss += "cloud block level $([int]$p.CloudBlockLevel) (<2 High)" } - $ev = "MAPS=$([int]$p.MAPSReporting), PUA=$([int]$p.PUAProtection), NetworkProtection=$([int]$p.EnableNetworkProtection), CloudBlockLevel=$([int]$p.CloudBlockLevel)." + if ($maps -eq 0) { $miss += 'cloud protection (MAPS) off' } + if ($pua -ne 1) { $miss += 'PUA protection not blocking' } + if ($np -ne 1) { $miss += 'network protection not enabled' } + if ($cbl -lt 2) { $miss += "cloud block level $cbl (<2 High)" } + $ev = "MAPS=$maps, PUA=$pua, NetworkProtection=$np, CloudBlockLevel=$cbl." $fix = 'Set-MpPreference -CloudBlockLevel High -EnableNetworkProtection Enabled -PUAProtection Enabled' if (-not $miss) { return New-Finding Pass $ev } if ($miss.Count -le 2) { return New-Finding Warn "$ev $($miss -join '; ')." $fix } @@ -303,7 +332,7 @@ Invoke-Check -Id 'DEF-04' -Category 'Defender' -Title 'Cloud protection, PUA and } Invoke-Check -Id 'DEF-05' -Category 'Defender' -Title 'Attack surface reduction rules' -Severity 'Medium' -Weight 8 -Body { - Assert-Defender + Assert-Defender -Preference $ids = @($script:MpPref.AttackSurfaceReductionRules_Ids | Where-Object { $_ }) $acts = @($script:MpPref.AttackSurfaceReductionRules_Actions | Where-Object { $null -ne $_ } | ForEach-Object { [int]$_ }) $block = @($acts | Where-Object { $_ -eq 1 }).Count @@ -317,7 +346,7 @@ Invoke-Check -Id 'DEF-05' -Category 'Defender' -Title 'Attack surface reduction } Invoke-Check -Id 'DEF-06' -Category 'Defender' -Title 'No Defender exclusions' -Severity 'High' -Weight 4 -RequiresAdmin -Body { - Assert-Defender + Assert-Defender -Preference $p = $script:MpPref $ex = @() foreach ($n in 'ExclusionPath', 'ExclusionProcess', 'ExclusionExtension', 'ExclusionIpAddress') { @@ -350,14 +379,23 @@ Invoke-Check -Id 'DEF-08' -Category 'Defender' -Title 'Smart App Control state' New-Finding Info "Smart App Control: $sac. (Once off it can only be re-enabled by reinstalling Windows.)" } -Invoke-Check -Id 'DEF-09' -Category 'Defender' -Title 'Email and removable drive scanning on' -Severity 'Low' -Weight 2 -Body { - Assert-Defender +Invoke-Check -Id 'DEF-09' -Category 'Defender' -Title 'Email and removable drive scanning on, extended cloud check' -Severity 'Low' -Weight 2 -Body { + Assert-Defender -Preference $p = $script:MpPref - $email = [bool](Get-PropValue $p 'DisableEmailScanning') - $usb = [bool](Get-PropValue $p 'DisableRemovableDriveScanning') - $ev = "DisableEmailScanning=$email, DisableRemovableDriveScanning=$usb, CloudExtendedTimeout=$([int](Get-PropValue $p 'CloudExtendedTimeout')) s." - if (-not $email -and -not $usb) { return New-Finding Pass $ev } - New-Finding Warn $ev 'Set-MpPreference -DisableEmailScanning $false -DisableRemovableDriveScanning $false (both are off by default).' + $email = [bool](Get-DefenderValue $p 'DisableEmailScanning') + $usb = [bool](Get-DefenderValue $p 'DisableRemovableDriveScanning') + $timeout = [int](Get-DefenderValue $p 'CloudExtendedTimeout') + # CloudExtendedTimeout adds 0-50 s to the default 10 s that Defender holds a first-seen file while it waits for + # the cloud verdict. 50 s (the maximum) gives the cloud the most time; the delay only hits files held for that + # verdict, not every file, which is why the maximum is the target here. + $target = 50 + $ev = "DisableEmailScanning=$email, DisableRemovableDriveScanning=$usb, CloudExtendedTimeout=$timeout s (target $target s)." + $issues = @() + if ($email) { $issues += '-DisableEmailScanning $false' } + if ($usb) { $issues += '-DisableRemovableDriveScanning $false' } + if ($timeout -lt $target) { $issues += "-CloudExtendedTimeout $target" } + if (-not $issues) { return New-Finding Pass $ev } + New-Finding Warn $ev "Set-MpPreference $($issues -join ' ') (email and removable drive scanning are off by default)." } # --------------------------------------------------------------------------- @@ -396,10 +434,17 @@ Invoke-Check -Id 'FW-03' -Category 'Firewall' -Title 'Network categories' -Sever # Network exposure # --------------------------------------------------------------------------- -Invoke-Check -Id 'NET-01' -Category 'Network' -Title 'SMBv1 server disabled' -Severity 'High' -Weight 6 -Body { +Invoke-Check -Id 'NET-01' -Category 'Network' -Title 'SMBv1 disabled' -Severity 'High' -Weight 6 -Body { + # SMBv1 is scored only here. The optional feature SMB1Protocol (it also carries the SMBv1 client) needs elevation + # to read; without it the check rests on the server configuration alone and says so. $c = Get-SmbServerConfiguration - if ($c.EnableSMB1Protocol) { return New-Finding Fail 'EnableSMB1Protocol = True.' 'Disable SMBv1: Set-SmbServerConfiguration -EnableSMB1Protocol $false' } - New-Finding Pass 'EnableSMB1Protocol = False.' + $ev = "Server EnableSMB1Protocol=$($c.EnableSMB1Protocol)" + $feature = 'not checked (requires elevation)' + if ($script:IsAdmin) { $feature = Get-OptionalFeatureState 'SMB1Protocol' } + $ev += "; optional feature SMB1Protocol: $feature." + $fix = 'Disable SMBv1: Set-SmbServerConfiguration -EnableSMB1Protocol $false; Disable-WindowsOptionalFeature -Online -FeatureName SMB1Protocol (elevated).' + if ($c.EnableSMB1Protocol -or $feature -in $script:ActiveFeatureStates) { return New-Finding Fail $ev $fix } + New-Finding Pass $ev } Invoke-Check -Id 'NET-02' -Category 'Network' -Title 'SMB signing required, no insecure guest logons' -Severity 'Medium' -Weight 3 -Body { @@ -467,17 +512,14 @@ Invoke-Check -Id 'NET-08' -Category 'Network' -Title 'Point and Print: printer d New-Finding Warn $ev $fix } -Invoke-Check -Id 'NET-09' -Category 'Network' -Title 'Legacy client features removed (SMB1, Telnet, TFTP)' -Severity 'Medium' -Weight 2 -RequiresAdmin -Body { - $names = 'SMB1Protocol', 'TelnetClient', 'TFTP' - # one full listing instead of -FeatureName: an unknown name (e.g. on Windows Server) must not end the check - $features = @(Get-WindowsOptionalFeature -Online -ErrorAction Stop | Where-Object { $_.FeatureName -in $names }) - $state = @{}; foreach ($f in $features) { $state[$f.FeatureName] = $f.State.ToString() } - $ev = ($names | ForEach-Object { "${_}: $(if ($state.ContainsKey($_)) { $state[$_] } else { 'not present' })" }) -join '; ' - $on = @($names | Where-Object { $state.ContainsKey($_) -and $state[$_] -eq 'Enabled' }) - $fix = "Disable-WindowsOptionalFeature -Online -FeatureName $($on -join ', ') (elevated)." - if ($on -contains 'SMB1Protocol') { return New-Finding Fail "$ev." $fix } - if ($on) { return New-Finding Warn "$ev." $fix } - New-Finding Pass "$ev." +Invoke-Check -Id 'NET-09' -Category 'Network' -Title 'Legacy Telnet and TFTP clients removed' -Severity 'Low' -Weight 2 -RequiresAdmin -Body { + # SMB1Protocol is deliberately not scored here: NET-01 owns SMBv1, so one finding is not counted twice. + $names = 'TelnetClient', 'TFTP' + $state = @{}; foreach ($n in $names) { $state[$n] = Get-OptionalFeatureState $n } + $ev = "$((($names | ForEach-Object { "${_}: $($state[$_])" }) -join '; ')). SMBv1: see NET-01." + $on = @($names | Where-Object { $state[$_] -in $script:ActiveFeatureStates }) + if (-not $on) { return New-Finding Pass $ev } + New-Finding Warn $ev "Disable-WindowsOptionalFeature -Online -FeatureName $($on -join ', ') (elevated). Plain-text clients that attackers use to move files." } # --------------------------------------------------------------------------- @@ -620,15 +662,17 @@ Invoke-Check -Id 'LOG-03' -Category 'Logging' -Title 'PowerShell 2.0 engine remo } Invoke-Check -Id 'LOG-04' -Category 'Logging' -Title 'Security event logs large enough for forensics' -Severity 'Low' -Weight 2 -Body { + # Minimum sizes in MB: the only place these targets are defined; the fix text is built from it. $targets = [ordered]@{ 'Security' = 256; 'Microsoft-Windows-PowerShell/Operational' = 128; 'Microsoft-Windows-Windows Defender/Operational' = 64 } - $parts = @(); $issues = @() + $parts = @(); $issues = @(); $unknown = @() foreach ($name in $targets.Keys) { try { $log = Get-WinEvent -ListLog $name -ErrorAction Stop } catch { $id = $_.FullyQualifiedErrorId - if ($id -like 'NoMatchingLogsFound*') { $parts += "$name not present"; continue } + # e.g. no Defender log when another antivirus replaced Defender + if ($id -like 'NoMatchingLogsFound*') { $parts += "$name not present"; $unknown += $name; continue } # without elevation the Security log reports LogInfoUnavailable - if ($id -like 'LogInfoUnavailable*' -or $_.Exception -is [System.UnauthorizedAccessException]) { $parts += "$name unreadable"; $issues += "$name not readable (run elevated)"; continue } + if ($id -like 'LogInfoUnavailable*' -or $_.Exception -is [System.UnauthorizedAccessException]) { $parts += "$name not readable (requires elevation)"; $unknown += $name; continue } throw } $mb = [math]::Round($log.MaximumSizeInBytes / 1MB) @@ -637,21 +681,30 @@ Invoke-Check -Id 'LOG-04' -Category 'Logging' -Title 'Security event logs large elseif ($log.MaximumSizeInBytes -lt [int64]$targets[$name] * 1MB) { $issues += "$name $mb MB < $($targets[$name]) MB" } } $ev = "$($parts -join '; ')." - if (-not $issues) { return New-Finding Pass $ev } - New-Finding Warn "$ev $($issues -join '; ')." 'Raise the maximum size (Event Viewer > log properties, or the Event Log policies): Security 256 MB, PowerShell/Operational 128 MB, Defender/Operational 64 MB.' + $fix = "Raise the maximum size (Event Viewer > log properties, or the Event Log policies) to at least: $(($targets.Keys | ForEach-Object { "$_ $($targets[$_]) MB" }) -join ', ')." + if ($issues) { return New-Finding Warn "$ev $($issues -join '; ')." $fix } + # a log that could not be measured is neither a pass nor a failure: report it without scoring + if ($unknown) { return New-Finding Info "$ev Not assessed: $($unknown -join ', ')." $fix } + New-Finding Pass $ev } Invoke-Check -Id 'LOG-05' -Category 'Logging' -Title 'Process creation auditing on (event 4688)' -Severity 'Medium' -Weight 3 -RequiresAdmin -Body { # Elevation is required: without it, a Security log query returns "no events" instead of access denied. + # The audit policy itself is not read (that would need auditpol.exe outside the native allowlist); a recorded + # event is the evidence. Limits, stated in the finding: it proves auditing was on when that event was written, + # so turning it off within the last 24 h goes unnoticed; and a full log in "Retain" mode stops recording. $newest = $null try { $newest = Get-WinEvent -FilterHashtable @{ LogName = 'Security'; Id = 4688 } -MaxEvents 1 -ErrorAction Stop } catch { if ($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*') { throw } } - $fix = 'auditpol /set /subcategory:{0CCE922B-69AE-11D9-BED3-505054503030} /success:enable (elevated), or the policy "Audit Process Creation". LOG-01 adds the command lines.' - if (-not $newest) { return New-Finding Fail 'No event 4688 in the Security log: process creation is not audited.' $fix } + $mode = (Get-WinEvent -ListLog 'Security' -ErrorAction Stop).LogMode.ToString() + $fix = 'auditpol /set /subcategory:{0CCE922B-69AE-11D9-BED3-505054503030} /success:enable (elevated), or the policy "Audit Process Creation". LOG-01 adds the command lines. Keep the Security log in Circular mode so a full log does not stop recording.' + if (-not $newest) { return New-Finding Fail "No event 4688 in the Security log (log mode $mode): process creation is not audited, or the log was cleared." $fix } $age = (Get-Date) - $newest.TimeCreated - $ev = "Newest event 4688: $($newest.TimeCreated.ToString('s')) ($([math]::Round($age.TotalHours, 1)) h ago)." - if ($age.TotalHours -le 24) { return New-Finding Pass $ev } - New-Finding Warn "$ev Processes start constantly, so auditing was probably turned off since." $fix + $ev = "Newest event 4688: $($newest.TimeCreated.ToString('s')) ($([math]::Round($age.TotalHours, 1)) h ago); Security log mode $mode." + $limit = 'This proves auditing was on at that time; turning it off in the last 24 h would not show here.' + if ($age.TotalHours -gt 24) { return New-Finding Warn "$ev Processes start constantly, so auditing was probably turned off since, or the log is full." $fix } + if ($mode -eq 'Retain') { return New-Finding Warn "$ev Retain mode: once the log is full, no new events are written. $limit" $fix } + New-Finding Pass "$ev $limit" } # --------------------------------------------------------------------------- From 70d69337ca2a8fb9053c9bb9b0b3a58a40c82531 Mon Sep 17 00:00:00 2001 From: Blackwell Code <161588956+Blackwellcode@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:48:48 +0100 Subject: [PATCH 3/4] fix: resolve $PSScriptRoot defaults in the script body (PS 5.1 -File) With [CmdletBinding()], Windows PowerShell 5.1 leaves $PSScriptRoot empty while it evaluates param defaults under "powershell -File". Join-Path then fails, so the README quick start (powershell -File .\winsec-audit.ps1) and "powershell -File tests\Test-ReadOnly.ps1" exited 1 before doing anything. Both defaults are now resolved after the param block. CI runs the guard once through "powershell -File" to keep it that way. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 ++ CHANGELOG.md | 1 + tests/Test-ReadOnly.ps1 | 6 +++++- winsec-audit.ps1 | 8 ++++++-- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 560b659..b8e3788 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,8 @@ jobs: run: wevtutil sl Microsoft-Windows-TaskScheduler/Operational /e:true - name: Read-only guard run: ./tests/Test-ReadOnly.ps1 + - name: Read-only guard via "powershell -File" (5.1 param defaults) + run: powershell -NoProfile -ExecutionPolicy Bypass -File ./tests/Test-ReadOnly.ps1 - name: Guard self-test run: ./tests/Test-GuardSelf.ps1 - name: Smoke test (state fingerprint) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1d670f..0286cfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel - `winget.exe` is taken from the installed App Installer package and must carry a valid Microsoft signature; the per-user execution alias on `PATH` is no longer used. ### Fixed +- `powershell -File .\winsec-audit.ps1` (the README quick start) and `powershell -File .\tests\Test-ReadOnly.ps1` failed under Windows PowerShell 5.1 with a `Join-Path` error: with `[CmdletBinding()]`, `$PSScriptRoot` is empty while param defaults are evaluated. Both defaults are now resolved in the script body. - DEF-07 (*No active threats*) reports **Error** when the Defender threat query fails instead of reporting **Pass**. - Unreadable Defender data no longer yields a false **Pass**: `Get-MpPreference` is queried separately from `Get-MpComputerStatus`, DEF-04/05/06/09 require readable preferences, and DEF-02/03/04/09 end as **Error** when a value is not reported (a `$null` cast to 0/False used to pass). diff --git a/tests/Test-ReadOnly.ps1 b/tests/Test-ReadOnly.ps1 index 773bbe4..1a97641 100644 --- a/tests/Test-ReadOnly.ps1 +++ b/tests/Test-ReadOnly.ps1 @@ -15,11 +15,15 @@ - native arguments that repair, install or configure. #> [CmdletBinding()] -param([string]$Path = (Join-Path $PSScriptRoot '..\winsec-audit.ps1')) +param([string]$Path) Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' +# Resolved here, not as a param default: with [CmdletBinding()], Windows PowerShell 5.1 leaves $PSScriptRoot +# empty while it evaluates param defaults under "powershell -File". +if (-not $Path) { $Path = Join-Path $PSScriptRoot '..\winsec-audit.ps1' } + $file = (Resolve-Path -LiteralPath $Path).Path $tokens = $null; $parseErrors = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile($file, [ref]$tokens, [ref]$parseErrors) diff --git a/winsec-audit.ps1 b/winsec-audit.ps1 index 441ecf7..f87fdb7 100644 --- a/winsec-audit.ps1 +++ b/winsec-audit.ps1 @@ -9,7 +9,7 @@ enforces that on every commit. Without elevation, admin-only checks are reported as Skipped. .PARAMETER OutputPath - Folder for the reports. A sub folder - is created inside it. + Folder for the reports. A sub folder - is created inside it. Default: .\reports next to the script. .PARAMETER Deep Adds DISM /ScanHealth and sfc /verifyonly (elevated, several minutes). Both only verify. @@ -29,7 +29,7 @@ #> [CmdletBinding()] param( - [string]$OutputPath = (Join-Path $PSScriptRoot 'reports'), + [string]$OutputPath, [switch]$Deep, [switch]$SkipWinget, [switch]$Redact, @@ -40,6 +40,10 @@ param( Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' +# Resolved here, not as a param default: with [CmdletBinding()], Windows PowerShell 5.1 leaves $PSScriptRoot +# empty while it evaluates param defaults under "powershell -File", and Join-Path then fails. +if (-not $OutputPath) { $OutputPath = Join-Path $PSScriptRoot 'reports' } + $script:Version = '0.1.0' $script:Results = New-Object System.Collections.Generic.List[object] $script:SigCache = @{} From 8a78743ac0bd28b260b4a5c5453626e754028186 Mon Sep 17 00:00:00 2001 From: Blackwell Code <161588956+Blackwellcode@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:56:53 +0100 Subject: [PATCH 4/4] docs: guard self-test count (30 forbidden, 2 clean) Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 81291b8..5e8a950 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Each check runs on its own: an exception becomes an `Error` finding for that che - non-allowlisted COM objects; - repair/install arguments such as `/scannow` or `/RestoreHealth`. 2. **Native allowlist**: the only external programs are `tpmtool getdeviceinformation`, `winget upgrade --include-unknown --disable-interactivity` (lists, never installs), `dism /Online /Cleanup-Image /ScanHealth` and `sfc /verifyonly`. All four are hard-coded in one table with fixed arguments. -3. **Guard self-test** (`tests/Test-GuardSelf.ps1`) feeds 15 forbidden samples (`Set-ItemProperty`, `Remove-Item`, `Set-MpPreference`, `Invoke-CimMethod`, `sfc /scannow`, …) to the guard and requires each to be rejected. +3. **Guard self-test** (`tests/Test-GuardSelf.ps1`) feeds 30 forbidden samples (`Set-ItemProperty`, `Remove-Item`, `Set-MpPreference`, `Invoke-CimMethod`, `sfc /scannow`, …) to the guard and requires each to be rejected, plus 2 clean samples that must pass. 4. **Runtime proof** (`tests/Test-Smoke.ps1`) snapshots firewall rules, services, scheduled tasks, Defender preferences and key registry values, runs the audit, and compares. Windows changes some of this on its own: BITS toggles its start type, and maintenance jobs update tasks. So every changed object is attributed through the event log (System 7040, TaskScheduler 140–142). A change that an event assigns to another actor, such as SYSTEM or the machine account, is reported as background activity. Any change without such attribution fails the test. Side effects that remain: Windows itself writes to its own logs (`CBS.log`, `dism.log`) during `-Deep`, and `winget` refreshes its package cache.