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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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
Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 |
Expand All @@ -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<br/>-RequiresAdmin, -DeepOnly"] --> F["Findings<br/>Pass · Warn · Fail · Info · Skipped · Error"]
C["49 × Invoke-Check<br/>-RequiresAdmin, -DeepOnly"] --> F["Findings<br/>Pass · Warn · Fail · Info · Skipped · Error"]
F --> W["Write-Report"]
end
N["Native allowlist<br/>tpmtool · winget · dism · sfc"] -->|fixed arguments| C
Expand Down Expand Up @@ -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.
Expand All @@ -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 "<evidence>" "<fix>"`. Use `-RequiresAdmin`/`-DeepOnly` where needed. The guard tells you if the body is not read-only.

Expand Down
73 changes: 73 additions & 0 deletions tests/Test-DefenderUnavailable.ps1
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion tests/Test-ReadOnly.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions tests/Test-Smoke.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)") }
Expand Down
Loading