diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e29ad32..b8e3788 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,11 @@ 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) 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 dc1b956..0286cfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,22 +4,34 @@ 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, extended cloud check (`CloudExtendedTimeout` 50 s); + - NET-08 Point and Print driver installation restricted to administrators (PrintNightmare); + - 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 +- `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). ### 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`; 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 b6d7e73..cb3e1b8 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) @@ -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 2d5267d..5e8a950 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, 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 | +| 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 | +| 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 @@ -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. @@ -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-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/tests/Test-Smoke.ps1 b/tests/Test-Smoke.ps1 index 14ac6df..9ff6cc4 100644 --- a/tests/Test-Smoke.ps1 +++ b/tests/Test-Smoke.ps1 @@ -144,6 +144,24 @@ 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) + 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 + 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..f87fdb7 100644 --- a/winsec-audit.ps1 +++ b/winsec-audit.ps1 @@ -3,13 +3,13 @@ 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. .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 = @{} @@ -185,6 +189,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 +229,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 +302,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 +311,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 +319,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 +336,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 +350,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,6 +383,25 @@ 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, extended cloud check' -Severity 'Low' -Weight 2 -Body { + Assert-Defender -Preference + $p = $script:MpPref + $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)." +} + # --------------------------------------------------------------------------- # Firewall # --------------------------------------------------------------------------- @@ -386,10 +438,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 { @@ -445,6 +504,28 @@ 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 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." +} + # --------------------------------------------------------------------------- # Accounts # --------------------------------------------------------------------------- @@ -584,6 +665,52 @@ 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 { + # 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 = @(); $unknown = @() + foreach ($name in $targets.Keys) { + try { $log = Get-WinEvent -ListLog $name -ErrorAction Stop } + catch { + $id = $_.FullyQualifiedErrorId + # 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 not readable (requires elevation)"; $unknown += $name; 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 '; ')." + $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 } } + $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); 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" +} + # --------------------------------------------------------------------------- # Persistence # ---------------------------------------------------------------------------