Repository navigation
Expand file tree
/
Copy pathWinLogKit.Common.ps1
More file actions
408 lines (378 loc) · 19.9 KB
/
Copy pathWinLogKit.Common.ps1
File metadata and controls
408 lines (378 loc) · 19.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
# =============================================================================
# WinLogKit.Common.ps1
# Shared helpers, dot-sourced by the kit scripts right after the settings
# table. Everything here is read-only against the host: host probes, registry
# reads, the audit policy and SMB audit-state readers and the one selection model (tier switches
# or a selection CSV) that Enable, Test, the coverage report and the fleet
# generators all use. Registry writers stay in Enable-LoggingBaseline.ps1,
# the only script that writes.
#
# PowerShell 5.1 compatible. No external module dependencies.
# =============================================================================
Set-StrictMode -Version 2.0
# ------------------------------------------------------------ host probes ---
function Test-IsAdmin {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
(New-Object Security.Principal.WindowsPrincipal $id).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Get-DomainRole {
# Win32_ComputerSystem.DomainRole: 0/1 standalone, 2/3 member, 4/5 domain controller
$role = (Get-CimInstance -ClassName Win32_ComputerSystem).DomainRole
if ($role -ge 4) { return 'DomainController' }
if ($role -ge 2) { return 'Member' }
return 'Standalone'
}
# Is PowerShell 7 (pwsh.exe) on this host? True when this script is itself
# running in PowerShell 7; otherwise MSI installs register under
# PowerShellCore\InstalledVersions, zip installs usually sit in Program
# Files, and Store installs put pwsh.exe on the PATH. A portable copy
# unpacked anywhere else, and not running this script, can't be found.
function Test-PowerShell7Installed {
if ($PSVersionTable.PSEdition -eq 'Core' -and $PSVersionTable.PSVersion.Major -ge 7) { return $true }
if (Test-Path 'HKLM:\SOFTWARE\Microsoft\PowerShellCore\InstalledVersions') { return $true }
if ($env:ProgramFiles -and (Test-Path (Join-Path $env:ProgramFiles 'PowerShell\7\pwsh.exe'))) { return $true }
return ($null -ne (Get-Command pwsh.exe -CommandType Application -ErrorAction SilentlyContinue))
}
function Get-OsType {
# Win32_OperatingSystem.ProductType: 1 workstation, 2 domain controller, 3 server
$pt = (Get-CimInstance -ClassName Win32_OperatingSystem).ProductType
if ($pt -eq 1) { return 'Workstation' }
if ($pt -eq 2) { return 'Domain Controller' }
return 'Server'
}
# ---------------------------------------------------------- registry read ---
# Registry access uses the .NET API throughout, not *-ItemProperty, because
# one required value is literally named '*' and the ItemProperty cmdlets
# treat that as a wildcard.
function ConvertTo-NetRegPath { param([string]$Path) $Path -replace '^HKLM:\\', 'HKEY_LOCAL_MACHINE\' -replace '^HKCU:\\', 'HKEY_CURRENT_USER\' }
function Get-RegValue {
param([string]$Path, [string]$Name)
[Microsoft.Win32.Registry]::GetValue((ConvertTo-NetRegPath $Path), $Name, $null)
}
# ----------------------------------------------------------- audit policy ---
# Audit policy is read as numbers, not words (#45). auditpol /get /r prints
# each setting as text that is translated on non-English Windows ("Success
# and Failure" only in English), so matching on it failed correct hosts.
# auditpol /backup writes the same data with a numeric Setting Value
# (0 none, 1 Success, 2 Failure, 3 both) keyed by subcategory GUID - the
# format Group Policy uses. Columns are read by position (4th = GUID,
# 7th = value) in case the header row is translated too.
# Parses auditpol /backup output into subcategory GUID -> setting value.
# Rows without a GUID (audit options, global SACLs) are skipped. The export
# can also hold per-user audit rows, so the system-wide rows (Policy Target
# 'System') win; if none carry that word (a translated export), the first row
# per subcategory is used rather than reading nothing.
function ConvertFrom-AuditPolicyBackup {
param([string[]]$Lines)
$rows = @(@($Lines | Select-Object -Skip 1 | Where-Object { $_ -match '\S' }) |
ConvertFrom-Csv -Header 'Machine', 'Target', 'Subcategory', 'Guid', 'Inclusion', 'Exclusion', 'Value' |
Where-Object { $null -ne $_ })
$valid = @($rows | Where-Object {
("$($_.Guid)" -replace '[{}]', '').Trim() -match '^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$' -and "$($_.Value)".Trim() -match '^[0-3]$' })
$system = @($valid | Where-Object { "$($_.Target)".Trim() -eq 'System' })
if ($system.Count -gt 0) { $valid = $system }
$map = @{}
foreach ($row in $valid) {
$guid = ("$($row.Guid)" -replace '[{}]', '').Trim().ToUpper()
if (-not $map.ContainsKey($guid)) { $map[$guid] = [int]"$($row.Value)".Trim() }
}
return $map
}
# Live audit policy: subcategory GUID -> setting value 0..3. Needs admin.
function Get-AuditPolicyByGuid {
$file = Join-Path ([IO.Path]::GetTempPath()) ('winlogkit-auditpol-{0}.csv' -f [guid]::NewGuid())
try {
$out = auditpol /backup /file:"$file"
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $file)) {
throw "auditpol /backup failed with exit code $LASTEXITCODE (run elevated): $(($out | Select-Object -First 2) -join ' ')"
}
return (ConvertFrom-AuditPolicyBackup -Lines (Get-Content -LiteralPath $file))
} finally {
if (Test-Path -LiteralPath $file) { Remove-Item -LiteralPath $file -Force }
}
}
# Success/Failure flags -> setting value, and back to English for output.
function Get-AuditSettingValue {
param([bool]$Success, [bool]$Failure)
return ([int]$Success + 2 * [int]$Failure)
}
function Format-AuditSetting {
param($Value)
switch ("$Value") {
'0' { return 'No Auditing' }
'1' { return 'Success' }
'2' { return 'Failure' }
'3' { return 'Success and Failure' }
}
return 'Unknown'
}
# ------------------------------------------------------------ SMB auditing ---
# Current state of the SMB audit settings (Windows 11 24H2 / Server 2025+).
# Returns a hashtable Id -> current bool; items missing from the hashtable are
# unsupported on this OS (the properties only exist on Server 2025 / Win11 24H2+).
# Whether Group Policy forces a classic log to "do not overwrite": Event
# Log Service > <log> > "Control Event Log behavior when the log file
# reaches its maximum size" = Enabled (registry value Retention, per
# Microsoft's EventLogService CSP). Only the classic logs have that policy.
# A local change would be undone at the next refresh, so the kit reports it
# instead of fighting it. Compared as text: DWORD or string both read as 1.
function Test-RetentionForcedByPolicy {
param([string]$LogName)
if (@('Application', 'Security', 'Setup', 'System') -notcontains $LogName) { return $false }
$v = Get-RegValue -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\EventLog\$LogName" -Name 'Retention'
return ("$v" -eq '1')
}
# The PowerShell transcription policy (#91). The kit never sets it (removed
# in 2.0.0, #36), but another policy might. Enabled with no OutputDirectory,
# every Windows PowerShell session writes a transcript into the user's
# Documents folder: Microsoft documents that as the policy's default and
# says to restrict access to the output location. The policy exists under
# Computer and User Configuration, Computer taking precedence, so the user
# hive (of the account running this) is read only when the machine hive has
# no value. Returns a hashtable:
# State Off | Directed (on, folder set) | Undirected (on, no folder)
# OutputDirectory the configured folder, or ''
# Scope Machine | User (which hive decided) | '' (neither configured)
# Wow6432NodeValue the Wow6432Node copy's EnableTranscripting as text, '' if absent
# Read-only; compared as text so DWORD and string 1 read the same.
function Get-TranscriptionPolicyState {
$machine = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription'
$user = 'HKCU:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription'
$scope = 'Machine'; $path = $machine
$raw = Get-RegValue -Path $machine -Name 'EnableTranscripting'
if ($null -eq $raw) {
$scope = 'User'; $path = $user
$raw = Get-RegValue -Path $user -Name 'EnableTranscripting'
if ($null -eq $raw) { $scope = '' }
}
$on = ("$raw" -eq '1')
$dir = ''
if ($on) { $dir = "$(Get-RegValue -Path $path -Name 'OutputDirectory')".Trim() }
$wowRaw = Get-RegValue -Path 'HKLM:\SOFTWARE\Wow6432Node\Policies\Microsoft\Windows\PowerShell\Transcription' -Name 'EnableTranscripting'
$wow = ''
if ($null -ne $wowRaw) { $wow = "$wowRaw" }
$state = 'Off'
if ($on) { if ($dir -ne '') { $state = 'Directed' } else { $state = 'Undirected' } }
return @{ State = $state; OutputDirectory = $dir; Scope = $scope; Wow6432NodeValue = $wow }
}
# The Set-/Get-Smb*Configuration property an SMB audit item maps to: its
# Setting when it has one (the same setting exists on client and server,
# and Ids must be unique), otherwise its Id. Works for settings-table
# hashtables and for items read back from a rollback baseline.
function Get-SmbSettingName {
param($Item)
if ($Item -is [hashtable]) { if ($Item.ContainsKey('Setting') -and $Item.Setting) { return $Item.Setting } }
elseif ($Item.PSObject.Properties.Name -contains 'Setting' -and $Item.Setting) { return $Item.Setting }
return $Item.Id
}
function Get-SmbAuditState {
$state = @{}
$srv = $null; $cli = $null
try { $srv = Get-SmbServerConfiguration -ErrorAction Stop } catch { $srv = $null }
try { $cli = Get-SmbClientConfiguration -ErrorAction Stop } catch { $cli = $null }
foreach ($item in $script:BaselineSmbAuditSettings) {
$cfg = $srv
if ($item.Side -eq 'Client') { $cfg = $cli }
$name = Get-SmbSettingName $item
if ($null -ne $cfg -and ($cfg.PSObject.Properties.Name -contains $name)) {
$state[$item.Id] = [bool]$cfg.($name)
}
}
return $state
}
# -------------------------------------------------------------- selection ---
#
# A selection answers "is this item on?" for every item in the settings
# table. It comes from one of two places, and a baseline CSV always wins:
# - a selection CSV from New-LoggingBaseline.ps1 (or a preset): listed rows
# decide, unlisted items are off
# - the tier switches: Core is always on, HighVolume only with
# -IncludeHighVolume. (v2 folded the Optional tier into HighVolume, ADR-002;
# -IncludeOptional is still accepted for one release and only warns.)
# Every "ITEMTYPE|ID" key the settings table defines, as a hashtable set.
function Get-BaselineItemKeySet {
$keys = @{}
foreach ($ch in $script:BaselineChannels) { $keys[("Channel|$($ch.Name)").ToUpper()] = $true }
foreach ($sub in $script:BaselineAuditSubcategories) { $keys[("AuditPolicy|$($sub.Guid)").ToUpper()] = $true }
foreach ($rs in $script:BaselineRegistrySettings) { $keys[("Registry|$($rs.Id)").ToUpper()] = $true }
foreach ($sa in $script:BaselineSmbAuditSettings) { $keys[("SmbAudit|$($sa.Id)").ToUpper()] = $true }
$keys[("Registry|$($script:BaselineAdcsAuditFilter.Id)").ToUpper()] = $true
return $keys
}
# Selection map from a selection CSV: "ITEMTYPE|ID" -> bool. The file is
# checked first: the wrong CSV (a Results export, say) must stop the run,
# not quietly select nothing. A CSV whose rows match nothing in the settings
# table is rejected for the same reason; rows for items this kit version
# does not know (an older CSV, a renamed setting) are warned about and
# ignored, since unlisted items are excluded anyway.
function Import-BaselineSelection {
param([string]$Path)
$rows = @(Import-Csv $Path)
if ($rows.Count -eq 0) {
Write-Error "Baseline file has no rows: $Path"
exit 1
}
$columns = @($rows[0].PSObject.Properties.Name)
$missing = @('ItemType', 'Id', 'Selected' | Where-Object { $columns -notcontains $_ })
if ($missing.Count -gt 0) {
Write-Error "Not a selection CSV (missing column(s): $($missing -join ', ')): $Path (build one with New-LoggingBaseline.ps1 or use a preset)"
exit 1
}
$map = @{}
$n = 0
foreach ($row in $rows) {
$n++
if ([string]::IsNullOrWhiteSpace($row.ItemType) -or [string]::IsNullOrWhiteSpace($row.Id)) {
Write-Error "Baseline file row $n has an empty ItemType or Id: $Path"
exit 1
}
$key = ("$($row.ItemType)|$($row.Id)").ToUpper()
if ($map.ContainsKey($key)) {
Write-Error "Baseline file lists $($row.ItemType) '$($row.Id)' more than once (row $n): $Path"
exit 1
}
$map[$key] = ("$($row.Selected)".Trim() -match '^(Y|YES|TRUE|1)$')
}
$known = Get-BaselineItemKeySet
$unknown = @($map.Keys | Where-Object { -not $known.ContainsKey($_) } | Sort-Object)
if ($unknown.Count -eq $map.Count) {
Write-Error "No row in the baseline file matches a setting in this kit (first: $($unknown[0])): $Path (build one with New-LoggingBaseline.ps1 or use a preset)"
exit 1
}
foreach ($u in $unknown) { Write-Warning "Baseline file lists an item this kit does not know, ignored: $u" }
return $map
}
# Is this settings item part of a reference baseline definition (one entry
# of tools\reference-baselines.psd1)? Used by the preset and Reference page
# generators, so both read membership the same way.
function Test-ReferenceBaselineItem {
param([hashtable]$Definition, [string]$ItemType, [string]$Id)
switch ($ItemType) {
'AuditPolicy' { foreach ($prefix in $Definition.AuditPrefixes) { if ($Id.ToUpper().StartsWith($prefix)) { return $true } }; return $false }
'Registry' { return ($Definition.RegistryIds -contains $Id) }
'Channel' { return ($Definition.Channels -contains $Id) }
}
return $false
}
# The one deprecation message for the v1 -IncludeOptional switch.
function Write-IncludeOptionalWarning {
param([bool]$IncludeOptional)
if ($IncludeOptional) {
Write-Warning '-IncludeOptional is deprecated and ignored: v2 folded the Optional tier into HighVolume (ADR-002). Use -IncludeHighVolume. The switch will be removed in a later release.'
}
}
function Test-TierSelected {
param([string]$Tier, [bool]$IncludeHighVolume)
if ($Tier -eq 'Core') { return $true }
if ($Tier -eq 'HighVolume') { return $IncludeHighVolume }
return $false
}
# Resolves a script's -BaselineFile / -IncludeHighVolume parameters into one
# selection object; call it once at setup and pass the result to
# Test-ItemSelected. Stops the script (exit 1) when the file does not exist,
# which is what every caller did before this helper existed.
# -IncludeOptional is the deprecated v1 switch: every script still accepts it
# and passes it here, where it only produces the warning.
# Map hashtable "ITEMTYPE|ID" -> bool, or $null for tier mode
# IncludeHighVolume the tier switch (tier mode only)
# Description "baseline file X.csv" or "Core tier [+ HighVolume]"
# BaselineFile the path as given, or ''
function Resolve-BaselineSelection {
param([string]$BaselineFile, [bool]$IncludeHighVolume, [bool]$IncludeOptional)
Write-IncludeOptionalWarning $IncludeOptional
$map = $null
$description = "Core tier$(if ($IncludeHighVolume) {' + HighVolume'})"
if (-not [string]::IsNullOrEmpty($BaselineFile)) {
if (-not (Test-Path $BaselineFile)) {
Write-Error "Baseline file not found: $BaselineFile (build one with New-LoggingBaseline.ps1 or use a preset)"
exit 1
}
$map = Import-BaselineSelection -Path $BaselineFile
$description = "baseline file $(Split-Path $BaselineFile -Leaf)"
}
return @{
Map = $map
IncludeHighVolume = $IncludeHighVolume
Description = $description
BaselineFile = "$BaselineFile"
}
}
# The one predicate: is this item on under this selection?
function Test-ItemSelected {
param([hashtable]$Selection, [string]$ItemType, [string]$Id, [string]$Tier)
if ($null -ne $Selection.Map) {
$key = ("$ItemType|$Id").ToUpper()
return ($Selection.Map.ContainsKey($key) -and $Selection.Map[$key])
}
return (Test-TierSelected -Tier $Tier -IncludeHighVolume $Selection.IncludeHighVolume)
}
# ------------------------------------------------------------ log storage ---
# Disk the kit's channels need once each has filled to its maximum size, per
# drive, against the free space there (#51). Event rates vary too much from
# host to host (and day to day on workstations) to predict, but the maximum
# sizes the baseline sets are fixed, and they are what fill the disk. A log
# only takes that space as events arrive, so this warns and never blocks.
#
# $Logs: one object per selected, registered channel with LogFilePath,
# FileSize, MaximumSizeInBytes (current) and TargetBytes (kit). $DriveSpace
# maps a drive root to @{ Free; Total } in bytes, for the self-checks; when
# absent the live drive is read.
function Get-LogStorageCheck {
param([object[]]$Logs, [hashtable]$DriveSpace, [int]$LowFreePercent = 10)
$byDrive = @{}
foreach ($l in $Logs) {
$path = [Environment]::ExpandEnvironmentVariables("$($l.LogFilePath)")
if ([string]::IsNullOrEmpty($path)) { continue }
$root = [IO.Path]::GetPathRoot($path)
if ([string]::IsNullOrEmpty($root)) { continue }
$root = $root.ToUpper()
if (-not $byDrive.ContainsKey($root)) { $byDrive[$root] = @{ Logs = 0; Growth = [double]0; Raise = [double]0 } }
$current = [double]$l.MaximumSizeInBytes
$final = [math]::Max($current, [double]$l.TargetBytes) # sizes are only ever raised
$used = 0.0; if ($null -ne $l.FileSize) { $used = [double]$l.FileSize }
$byDrive[$root].Logs++
$byDrive[$root].Growth += [math]::Max(0.0, $final - $used)
$byDrive[$root].Raise += ($final - $current)
}
foreach ($root in ($byDrive.Keys | Sort-Object)) {
if ($null -ne $DriveSpace -and $DriveSpace.ContainsKey($root)) {
$free = [double]$DriveSpace[$root].Free; $total = [double]$DriveSpace[$root].Total
} else {
try { $d = New-Object IO.DriveInfo $root; $free = [double]$d.AvailableFreeSpace; $total = [double]$d.TotalSize }
catch { continue }
}
$after = $free - $byDrive[$root].Growth
$status = 'OK'
if ($after -lt 0) { $status = 'Insufficient' }
elseif ($total -gt 0 -and ($after / $total * 100) -lt $LowFreePercent) { $status = 'Low' }
[pscustomobject]@{
Drive = $root
Logs = $byDrive[$root].Logs
RaiseGB = [math]::Round($byDrive[$root].Raise / 1GB, 1)
GrowthGB = [math]::Round($byDrive[$root].Growth / 1GB, 1)
FreeGB = [math]::Round($free / 1GB, 1)
TotalGB = [math]::Round($total / 1GB, 1)
FreeAfterGB = [math]::Round($after / 1GB, 1)
Status = $status
}
}
}
function Write-LogStorageCheck {
param([object[]]$Check, [int]$LowFreePercent = 10)
foreach ($c in $Check) {
$line = "$($c.Drive) $($c.Logs) kit log(s) can still grow by $($c.GrowthGB) GB until full"
if ($c.RaiseGB -gt 0) { $line += " ($($c.RaiseGB) GB of it from raised maximum sizes)" }
$line += "; $($c.FreeGB) GB of $($c.TotalGB) GB free now, $($c.FreeAfterGB) GB once they are full."
switch ($c.Status) {
'Insufficient' {
Write-Host "[STORAGE LOW] $line" -ForegroundColor Red
Write-Host ' Not enough free space for these logs to reach their maximum size. Add disk (or free space), or apply a smaller selection.' -ForegroundColor Red
}
'Low' {
Write-Host "[STORAGE LOW] $line" -ForegroundColor Yellow
Write-Host " Under $LowFreePercent% of the drive would be left free. Plan a disk upgrade or free space before rolling out." -ForegroundColor Yellow
}
default { Write-Host "[STORAGE OK ] $line" -ForegroundColor DarkGray }
}
}
}