From ef7f2d9d383931739e36e0e580fcb3fdb56b4531 Mon Sep 17 00:00:00 2001 From: spydisec <9101327+spydisec@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:59:32 +1000 Subject: [PATCH 1/7] Add WinLogKit.Common.ps1: one copy of the shared helpers Step 2 of the v1.0 restructure (ADR-001). Pure refactor: no setting, no switch and no output format changes. Moved into WinLogKit.Common.ps1, dot-sourced right after the settings table: Test-IsAdmin (was in 3 scripts), Get-DomainRole, Get-OsType, ConvertTo-NetRegPath, Get-RegValue, Get-AuditPolicyByGuid (each in 2), and the selection model. Resolve-BaselineSelection replaces the five inline "-BaselineFile or tier switches" blocks; Test-ItemSelected replaces Test-TierSelected, Test-ItemOn, Test-Wanted and Get-DefaultSelected. Registry writers stay in Enable-LoggingBaseline.ps1, the one script that writes. The Intune pack generator keeps embedding its own helpers so the generated scripts run alone. Only visible difference: every generated artefact now describes its source the same way (Core tier [+ HighVolume] [+ Optional] or baseline file X.csv); the Intune header said "recommended tiers" and the WEF subscription said "kit Core tier". Self-checks gain 1c: a function defined in more than one kit file fails. Co-Authored-By: Claude Fable 5.1 --- .coderabbit.yaml | 4 ++ CHANGELOG.md | 8 +++ CONTRIBUTING.md | 5 ++ Enable-LoggingBaseline.ps1 | 92 ++++-------------------- Export-AttackCoverage.ps1 | 36 ++-------- Invoke-WELACheck.ps1 | 5 +- New-GpoPack.ps1 | 34 ++------- New-IntuneRemediationPack.ps1 | 41 ++--------- New-LoggingBaseline.ps1 | 16 ++--- New-WefSubscription.ps1 | 33 +++------ Test-LoggingBaseline.ps1 | 77 +++----------------- WinLogKit.Common.ps1 | 130 ++++++++++++++++++++++++++++++++++ docs/commands.md | 3 +- docs/mapping.md | 3 +- tests/Invoke-KitChecks.ps1 | 24 +++++++ 15 files changed, 232 insertions(+), 279 deletions(-) create mode 100644 WinLogKit.Common.ps1 diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 196b11b..f484106 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -47,6 +47,10 @@ reviews: - Every logging setting lives in LoggingBaseline.Settings.ps1 with a plain-language Purpose (and Risk where volume/stability matters). Flag settings hardcoded in the other scripts. + - Shared helpers live in WinLogKit.Common.ps1 (host probes, registry + reads, selection model). Flag a helper duplicated across scripts; + the generated Intune pack embedding its own is the intended + exception. - No external module dependencies, no third party agents (no Sysmon). - Never introduce CrashOnAuditFail, "do not overwrite" log retention, global object access auditing, blanket SACLs, service restarts or diff --git a/CHANGELOG.md b/CHANGELOG.md index f873d47..757e7b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ releases are tagged `vX.Y.Z` and published with a zip + SHA256 checksum. ## Unreleased ### Changed +- **`WinLogKit.Common.ps1`.** The helpers that Enable, Test, the WELA + check, the coverage report and the fleet generators each carried their + own copy of (admin check, host role and OS type, registry reads, the + auditpol reader, selection-CSV loading and the tier logic) now live in + one dot-sourced file next to the settings table. Behaviour is unchanged + except that every generated artefact now describes its source the same + way (`Core tier [+ HighVolume] [+ Optional]` or `baseline file X.csv`). + The self-checks fail if a function is defined in more than one file. - **Docs cut.** README reduced to one screen and reused as the site home page (MkDocs snippet include, one copy of the text). The site goes from 13 pages to 10: the WEC Collector page absorbs the WEF section of diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fdb535a..9560e84 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,11 @@ is what tunes the presets. Open an issue with the *Field report* template. plain-language purpose and, where it matters, a risk note. Scripts, presets, packs and docs derive from it; never hard-code a setting anywhere else. +- **Shared helpers live in `WinLogKit.Common.ps1`** (host probes, registry + reads, the audit policy reader, the selection model). A function + defined in two kit files fails the self-checks. The Intune pack + generator embeds its own helpers on purpose: the generated scripts must + run alone. - **Windows PowerShell 5.1 compatible, no external modules, no agents.** The design intent is a kit that runs on a bare server with nothing installed. PowerShell 7 is fully supported (CI tests every change on diff --git a/Enable-LoggingBaseline.ps1 b/Enable-LoggingBaseline.ps1 index c086d9b..f307cbf 100644 --- a/Enable-LoggingBaseline.ps1 +++ b/Enable-LoggingBaseline.ps1 @@ -100,40 +100,14 @@ if ([string]::IsNullOrEmpty($BaselineDir)) { $BaselineDir = Join-Path $PSScriptR if ([string]::IsNullOrEmpty($LogDir)) { $LogDir = Join-Path $PSScriptRoot 'Logs' } . (Join-Path $PSScriptRoot 'LoggingBaseline.Settings.ps1') +. (Join-Path $PSScriptRoot 'WinLogKit.Common.ps1') # ---------------------------------------------------------------- helpers --- -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' -} - -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 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\' } - -function Get-RegValue { - param([string]$Path, [string]$Name) - [Microsoft.Win32.Registry]::GetValue((ConvertTo-NetRegPath $Path), $Name, $null) -} - +# Host probes, registry reads and the selection model come from +# WinLogKit.Common.ps1. The registry writers live here because this is +# the one script that writes; they use the .NET API for the reason noted +# there (a required value is literally named '*'). function Set-RegValue { param([string]$Path, [string]$Name, $Value, [string]$Kind) $kindEnum = [Microsoft.Win32.RegistryValueKind]::$Kind @@ -149,17 +123,6 @@ function Remove-RegValue { } } -function Get-AuditPolicyByGuid { - # One auditpol call for everything; returns hashtable GUID -> inclusion setting text. - $map = @{} - $csv = auditpol /get /category:* /r | Where-Object { $_ -match '\S' } | ConvertFrom-Csv - foreach ($row in $csv) { - $guid = ($row.'Subcategory GUID' -replace '[{}]', '').ToUpper() - $map[$guid] = $row.'Inclusion Setting' - } - return $map -} - function Get-DesiredInclusion { param([bool]$Success, [bool]$Failure) if ($Success -and $Failure) { return 'Success and Failure' } @@ -217,37 +180,20 @@ function Add-Result { Write-Host ('[{0,-15}] {1,-9} {2} {3}' -f $Action, $Area, $Item, $Detail) -ForegroundColor $colour } -function Test-TierSelected { - param([string]$Tier) - if ($Tier -eq 'Core') { return $true } - if ($Tier -eq 'HighVolume') { return [bool]$IncludeHighVolume } - if ($Tier -eq 'Optional') { return [bool]$IncludeOptional } - return $false -} - -# Selection map from a New-LoggingBaseline.ps1 CSV: "ITEMTYPE|ID" -> bool. -$script:Selection = $null -function Import-BaselineSelection { - param([string]$Path) - $map = @{} - foreach ($row in (Import-Csv $Path)) { - $map[("$($row.ItemType)|$($row.Id)").ToUpper()] = ("$($row.Selected)".Trim() -match '^(Y|YES|TRUE|1)$') - } - return $map -} - # One decision point for every item: baseline file wins when present, -# otherwise the tier switches decide. Returns Apply | PendingDecision | -# Excluded | NotListed. +# otherwise the tier switches decide (WinLogKit.Common.ps1 resolves both +# into $script:Selection). Returns Apply | PendingDecision | Excluded | +# NotListed. +$script:Selection = Resolve-BaselineSelection -BaselineFile $BaselineFile -IncludeHighVolume $IncludeHighVolume -IncludeOptional $IncludeOptional function Get-ItemDecision { param([string]$Tier, [string]$ItemType, [string]$Id) - if ($null -ne $script:Selection) { + if ($null -ne $script:Selection.Map) { $key = ("$ItemType|$Id").ToUpper() - if (-not $script:Selection.ContainsKey($key)) { return 'NotListed' } - if ($script:Selection[$key]) { return 'Apply' } + if (-not $script:Selection.Map.ContainsKey($key)) { return 'NotListed' } + if ($script:Selection.Map[$key]) { return 'Apply' } return 'Excluded' } - if (Test-TierSelected $Tier) { return 'Apply' } + if (Test-ItemSelected $script:Selection $ItemType $Id $Tier) { return 'Apply' } return 'PendingDecision' } @@ -273,18 +219,10 @@ try { $baselineJson = Join-Path $BaselineDir 'LoggingBaseline-FirstRun.json' $auditBackup = Join-Path $BaselineDir 'auditpol-backup.csv' - if (-not [string]::IsNullOrEmpty($BaselineFile)) { - if (-not (Test-Path $BaselineFile)) { - Write-Error "Baseline file not found: $BaselineFile (build one with New-LoggingBaseline.ps1)" - exit 1 - } - $script:Selection = Import-BaselineSelection -Path $BaselineFile - } - Write-Host '' Write-Host "Host profile : $(Get-OsType), $domainRole" - if ($null -ne $script:Selection) { - Write-Host "Baseline file : $BaselineFile ($(@($script:Selection.Values | Where-Object { $_ }).Count) items selected; tier switches ignored)" + if ($null -ne $script:Selection.Map) { + Write-Host "Baseline file : $BaselineFile ($(@($script:Selection.Map.Values | Where-Object { $_ }).Count) items selected; tier switches ignored)" } else { Write-Host "Tiers selected : Core$(if ($IncludeHighVolume) {' + HighVolume'})$(if ($IncludeOptional) {' + Optional'})" } diff --git a/Export-AttackCoverage.ps1 b/Export-AttackCoverage.ps1 index c24b057..ce6e344 100644 --- a/Export-AttackCoverage.ps1 +++ b/Export-AttackCoverage.ps1 @@ -64,48 +64,27 @@ $ErrorActionPreference = 'Stop' if ([string]::IsNullOrEmpty($OutDir)) { $OutDir = Join-Path $PSScriptRoot 'Results' } . (Join-Path $PSScriptRoot 'LoggingBaseline.Settings.ps1') +. (Join-Path $PSScriptRoot 'WinLogKit.Common.ps1') # ---------------------------------------------- resolve the selection sets --- -$selection = $null -if (-not [string]::IsNullOrEmpty($BaselineFile)) { - if (-not (Test-Path $BaselineFile)) { - Write-Error "Baseline file not found: $BaselineFile" - exit 1 - } - $selection = @{} - foreach ($row in (Import-Csv $BaselineFile)) { - $selection[("$($row.ItemType)|$($row.Id)").ToUpper()] = ("$($row.Selected)".Trim() -match '^(Y|YES|TRUE|1)$') - } -} - -function Test-ItemOn { - param([string]$ItemType, [string]$Id, [string]$Tier) - if ($null -ne $selection) { - $key = ("$ItemType|$Id").ToUpper() - return ($selection.ContainsKey($key) -and $selection[$key]) - } - if ($Tier -eq 'Core') { return $true } - if ($Tier -eq 'HighVolume') { return [bool]$IncludeHighVolume } - if ($Tier -eq 'Optional') { return [bool]$IncludeOptional } - return $false -} +$sel = Resolve-BaselineSelection -BaselineFile $BaselineFile -IncludeHighVolume $IncludeHighVolume -IncludeOptional $IncludeOptional $subcatSelected = @{}; $subcatKnownByGuid = @{}; $subcatNameByGuid = @{} foreach ($sub in $script:BaselineAuditSubcategories) { $g = $sub.Guid.ToUpper() $subcatKnownByGuid[$g] = $true $subcatNameByGuid[$g] = $sub.Name - if (Test-ItemOn 'AuditPolicy' $sub.Guid $sub.Tier) { $subcatSelected[$g] = $true } + if (Test-ItemSelected $sel 'AuditPolicy' $sub.Guid $sub.Tier) { $subcatSelected[$g] = $true } } $channelSelected = @{}; $channelKnown = @{} foreach ($ch in $script:BaselineChannels) { $channelKnown[$ch.Name] = $true - if (Test-ItemOn 'Channel' $ch.Name $ch.Tier) { $channelSelected[$ch.Name] = $true } + if (Test-ItemSelected $sel 'Channel' $ch.Name $ch.Tier) { $channelSelected[$ch.Name] = $true } } $regSelected = @{} foreach ($rs in $script:BaselineRegistrySettings) { - if (Test-ItemOn 'Registry' $rs.Id $rs.Tier) { $regSelected[$rs.Id] = $true } + if (Test-ItemSelected $sel 'Registry' $rs.Id $rs.Tier) { $regSelected[$rs.Id] = $true } } function Test-Prereq { @@ -121,8 +100,7 @@ function Test-Prereq { return $false } -$sourceDesc = "Core tier$(if ($IncludeHighVolume) {' + HighVolume'})$(if ($IncludeOptional) {' + Optional'})" -if ($null -ne $selection) { $sourceDesc = "baseline file $(Split-Path $BaselineFile -Leaf)" } +$sourceDesc = $sel.Description $detail = New-Object System.Collections.Generic.List[object] @@ -134,7 +112,7 @@ if ($UseOssem) { $subcatSelectedByName = @{}; $subcatKnownByName = @{} foreach ($sub in $script:BaselineAuditSubcategories) { $subcatKnownByName[$sub.Name] = $true - if (Test-ItemOn 'AuditPolicy' $sub.Guid $sub.Tier) { $subcatSelectedByName[$sub.Name] = $true } + if (Test-ItemSelected $sel 'AuditPolicy' $sub.Guid $sub.Tier) { $subcatSelectedByName[$sub.Name] = $true } } foreach ($r in (Import-Csv $snapshot)) { $status = ''; $via = '' diff --git a/Invoke-WELACheck.ps1 b/Invoke-WELACheck.ps1 index 159a1b9..f4782ae 100644 --- a/Invoke-WELACheck.ps1 +++ b/Invoke-WELACheck.ps1 @@ -70,10 +70,7 @@ Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' if ([string]::IsNullOrEmpty($EvidenceDir)) { $EvidenceDir = Join-Path $PSScriptRoot 'Evidence' } -function Test-IsAdmin { - $id = [Security.Principal.WindowsIdentity]::GetCurrent() - (New-Object Security.Principal.WindowsPrincipal $id).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -} +. (Join-Path $PSScriptRoot 'WinLogKit.Common.ps1') if (-not (Test-IsAdmin)) { Write-Error 'Run as local Administrator - WELA audit-settings reads the audit policy via auditpol.' diff --git a/New-GpoPack.ps1 b/New-GpoPack.ps1 index e575774..892cf7f 100644 --- a/New-GpoPack.ps1 +++ b/New-GpoPack.ps1 @@ -66,33 +66,11 @@ $ErrorActionPreference = 'Stop' if ([string]::IsNullOrEmpty($OutDir)) { $OutDir = Join-Path $PSScriptRoot 'GPO' } . (Join-Path $PSScriptRoot 'LoggingBaseline.Settings.ps1') +. (Join-Path $PSScriptRoot 'WinLogKit.Common.ps1') -$selection = $null -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 - } - $selection = @{} - foreach ($row in (Import-Csv $BaselineFile)) { - $selection[("$($row.ItemType)|$($row.Id)").ToUpper()] = ("$($row.Selected)".Trim() -match '^(Y|YES|TRUE|1)$') - } -} - -function Test-ItemOn { - param([string]$ItemType, [string]$Id, [string]$Tier) - if ($null -ne $selection) { - $key = ("$ItemType|$Id").ToUpper() - return ($selection.ContainsKey($key) -and $selection[$key]) - } - if ($Tier -eq 'Core') { return $true } - if ($Tier -eq 'HighVolume') { return [bool]$IncludeHighVolume } - if ($Tier -eq 'Optional') { return [bool]$IncludeOptional } - return $false -} +$sel = Resolve-BaselineSelection -BaselineFile $BaselineFile -IncludeHighVolume $IncludeHighVolume -IncludeOptional $IncludeOptional -$sourceDesc = "Core tier$(if ($IncludeHighVolume) {' + HighVolume'})$(if ($IncludeOptional) {' + Optional'})" -if ($null -ne $selection) { $sourceDesc = "baseline file $(Split-Path $BaselineFile -Leaf)" } +$sourceDesc = $sel.Description New-Item -ItemType Directory -Path $OutDir -Force | Out-Null $outDirFull = (Resolve-Path $OutDir).Path @@ -104,7 +82,7 @@ $auditLines = New-Object System.Collections.Generic.List[string] $auditLines.Add('Machine Name,Policy Target,Subcategory,Subcategory GUID,Inclusion Setting,Exclusion Setting,Setting Value') $auditCount = 0 foreach ($sub in $script:BaselineAuditSubcategories) { - if (-not (Test-ItemOn 'AuditPolicy' $sub.Guid $sub.Tier)) { continue } + if (-not (Test-ItemSelected $sel 'AuditPolicy' $sub.Guid $sub.Tier)) { continue } $value = 0 if ($sub.Success) { $value += 1 } if ($sub.Failure) { $value += 2 } @@ -127,7 +105,7 @@ $regEntries = New-Object System.Collections.Generic.List[string] $skipped = New-Object System.Collections.Generic.List[string] $regCount = 0 foreach ($rs in $script:BaselineRegistrySettings) { - if (-not (Test-ItemOn 'Registry' $rs.Id $rs.Tier)) { continue } + if (-not (Test-ItemSelected $sel 'Registry' $rs.Id $rs.Tier)) { continue } if ($rs.Path -notmatch $policyPathPattern) { $skipped.Add("$($rs.Path)\$($rs.Name) (GPO Security Options territory - set in GPMC, not a registry.pol value)") continue @@ -162,7 +140,7 @@ if ($auditCount -lt $totalAudit) { Write-Host ("PARTIAL SELECTION: audit.csv covers {0} of {1} kit subcategories. Apply semantics for the others depend on the tool " -f $auditCount, $totalAudit) -ForegroundColor Yellow Write-Host 'and existing policy (LGPO /ac and GPO application may not preserve unlisted subcategories). After applying, ALWAYS verify' -ForegroundColor Yellow $verifyArgs = '' - if ($null -ne $selection) { $verifyArgs = " -BaselineFile `"$BaselineFile`"" } + if ($null -ne $sel.Map) { $verifyArgs = " -BaselineFile `"$BaselineFile`"" } else { if ($IncludeHighVolume) { $verifyArgs += ' -IncludeHighVolume' } if ($IncludeOptional) { $verifyArgs += ' -IncludeOptional' } diff --git a/New-IntuneRemediationPack.ps1 b/New-IntuneRemediationPack.ps1 index 6b00eb5..f33e0df 100644 --- a/New-IntuneRemediationPack.ps1 +++ b/New-IntuneRemediationPack.ps1 @@ -69,38 +69,12 @@ Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' if ([string]::IsNullOrEmpty($OutDir)) { $OutDir = Join-Path $PSScriptRoot 'Intune' } -# Captured here so the nested Test-Wanted function reads script-level state -# (also keeps PSScriptAnalyzer's unused-parameter analysis accurate). -$wantHighVolume = [bool]$IncludeHighVolume -$wantOptional = [bool]$IncludeOptional - . (Join-Path $PSScriptRoot 'LoggingBaseline.Settings.ps1') +. (Join-Path $PSScriptRoot 'WinLogKit.Common.ps1') # ---------------------------------------------------------- item selection --- -$selection = $null -if (-not [string]::IsNullOrEmpty($BaselineFile)) { - if (-not (Test-Path $BaselineFile)) { - Write-Error "Baseline file not found: $BaselineFile (build one with New-LoggingBaseline.ps1)" - exit 1 - } - $selection = @{} - foreach ($row in (Import-Csv $BaselineFile)) { - $selection[("$($row.ItemType)|$($row.Id)").ToUpper()] = ("$($row.Selected)".Trim() -match '^(Y|YES|TRUE|1)$') - } -} - -function Test-Wanted { - param([string]$ItemType, [string]$Id, [string]$Tier) - if ($null -ne $selection) { - $key = ("$ItemType|$Id").ToUpper() - return ($selection.ContainsKey($key) -and $selection[$key]) - } - if ($Tier -eq 'Core') { return $true } - if ($Tier -eq 'HighVolume') { return $script:wantHighVolume } - if ($Tier -eq 'Optional') { return $script:wantOptional } - return $false -} +$sel = Resolve-BaselineSelection -BaselineFile $BaselineFile -IncludeHighVolume $IncludeHighVolume -IncludeOptional $IncludeOptional # ------------------------------------------- build the embedded item table --- @@ -110,19 +84,19 @@ function ConvertTo-PsBool { param([bool]$b) if ($b) { '$true' } else { '$false $lines = New-Object System.Collections.Generic.List[string] foreach ($ch in $script:BaselineChannels) { - if (-not (Test-Wanted 'Channel' $ch.Name $ch.Tier)) { continue } + if (-not (Test-ItemSelected $sel 'Channel' $ch.Name $ch.Tier)) { continue } $lines.Add((' @{{ Type=''Channel''; Name={0}; TargetBytes={1}; MustEnable={2}; DCOnly=$false }}' -f ` (ConvertTo-PsString $ch.Name), $ch.TargetBytes, (ConvertTo-PsBool $ch.MustEnable))) } foreach ($sub in $script:BaselineAuditSubcategories) { - if (-not (Test-Wanted 'AuditPolicy' $sub.Guid $sub.Tier)) { continue } + if (-not (Test-ItemSelected $sel 'AuditPolicy' $sub.Guid $sub.Tier)) { continue } $lines.Add((' @{{ Type=''AuditPolicy''; Name={0}; Guid={1}; Success={2}; Failure={3}; DCOnly={4} }}' -f ` (ConvertTo-PsString $sub.Name), (ConvertTo-PsString $sub.Guid.ToUpper()), ` (ConvertTo-PsBool $sub.Success), (ConvertTo-PsBool $sub.Failure), ` (ConvertTo-PsBool ($sub.Scope -eq 'DomainController')))) } foreach ($rs in $script:BaselineRegistrySettings) { - if (-not (Test-Wanted 'Registry' $rs.Id $rs.Tier)) { continue } + if (-not (Test-ItemSelected $sel 'Registry' $rs.Id $rs.Tier)) { continue } $valueLiteral = $rs.Value if ($rs.Kind -eq 'String') { $valueLiteral = ConvertTo-PsString "$($rs.Value)" } $lines.Add((' @{{ Type=''Registry''; Path={0}; Name={1}; Kind={2}; Value={3}; DCOnly={4} }}' -f ` @@ -130,7 +104,7 @@ foreach ($rs in $script:BaselineRegistrySettings) { $valueLiteral, (ConvertTo-PsBool ($rs.Scope -eq 'DomainController')))) } foreach ($sa in $script:BaselineSmbAuditSettings) { - if (-not (Test-Wanted 'SmbAudit' $sa.Id $sa.Tier)) { continue } + if (-not (Test-ItemSelected $sel 'SmbAudit' $sa.Id $sa.Tier)) { continue } $lines.Add((' @{{ Type=''SmbAudit''; Id={0}; Side={1}; Value={2}; DCOnly=$false }}' -f ` (ConvertTo-PsString $sa.Id), (ConvertTo-PsString $sa.Side), (ConvertTo-PsBool $sa.Value))) } @@ -140,8 +114,7 @@ if ($lines.Count -eq 0) { exit 1 } -$sourceDesc = 'recommended tiers' -if ($null -ne $selection) { $sourceDesc = "baseline file $(Split-Path $BaselineFile -Leaf)" } +$sourceDesc = $sel.Description # ----------------------------------------------------------- the template --- # Single-quoted here-string: everything is literal; placeholders are replaced diff --git a/New-LoggingBaseline.ps1 b/New-LoggingBaseline.ps1 index ab8f5d8..e780515 100644 --- a/New-LoggingBaseline.ps1 +++ b/New-LoggingBaseline.ps1 @@ -92,6 +92,7 @@ $ErrorActionPreference = 'Stop' if ([string]::IsNullOrEmpty($OutFile)) { $OutFile = Join-Path $PSScriptRoot 'MyBaseline.csv' } . (Join-Path $PSScriptRoot 'LoggingBaseline.Settings.ps1') +. (Join-Path $PSScriptRoot 'WinLogKit.Common.ps1') if ((Test-Path $OutFile) -and -not $Force -and -not $Show) { Write-Error "$OutFile already exists. Use -Force to overwrite, or pick another -OutFile." @@ -101,10 +102,7 @@ if ((Test-Path $OutFile) -and -not $Force -and -not $Show) { function Get-DefaultSelected { # The kit recommendation: Core is in, heavier tiers are opt-in. param([string]$Tier) - if ($Tier -eq 'Core') { return $true } - if ($Tier -eq 'HighVolume') { return [bool]$IncludeHighVolume } - if ($Tier -eq 'Optional') { return [bool]$IncludeOptional } - return $false + Test-TierSelected -Tier $Tier -IncludeHighVolume $IncludeHighVolume -IncludeOptional $IncludeOptional } function Get-ItemField { @@ -231,17 +229,11 @@ function Show-BaselineTree { if ($Show) { $decided = @{} if (-not [string]::IsNullOrEmpty($BaselineFile)) { - if (-not (Test-Path $BaselineFile)) { - Write-Error "Baseline file not found: $BaselineFile" - exit 1 - } + $sel = Resolve-BaselineSelection -BaselineFile $BaselineFile Write-Host "Showing selection from: $BaselineFile" # Items absent from the CSV are excluded - matching how Enable/Test # treat unlisted items - so a partial CSV cannot overstate coverage. - foreach ($it in $items) { $decided["$($it.ItemType)|$($it.Id)"] = $false } - foreach ($row in (Import-Csv $BaselineFile)) { - $decided["$($row.ItemType)|$($row.Id)"] = ("$($row.Selected)".Trim() -match '^(Y|YES|TRUE|1)$') - } + foreach ($it in $items) { $decided["$($it.ItemType)|$($it.Id)"] = (Test-ItemSelected $sel $it.ItemType $it.Id $it.Tier) } } else { Write-Host "Showing the kit recommendation (Core$(if ($IncludeHighVolume) {' + HighVolume'})$(if ($IncludeOptional) {' + Optional'}))" foreach ($it in $items) { $decided["$($it.ItemType)|$($it.Id)"] = Get-DefaultSelected $it.Tier } diff --git a/New-WefSubscription.ps1 b/New-WefSubscription.ps1 index 3e951a5..15191a9 100644 --- a/New-WefSubscription.ps1 +++ b/New-WefSubscription.ps1 @@ -143,6 +143,7 @@ $ErrorActionPreference = 'Stop' if ([string]::IsNullOrEmpty($OutDir)) { $OutDir = Join-Path $PSScriptRoot 'WEF' } . (Join-Path $PSScriptRoot 'LoggingBaseline.Settings.ps1') +. (Join-Path $PSScriptRoot 'WinLogKit.Common.ps1') $wefDefaults = $script:BaselineWefDefaults if ([string]::IsNullOrEmpty($ContentFormat)) { $ContentFormat = $wefDefaults.ContentFormat } @@ -166,32 +167,14 @@ $maxExpressionsPerSelect = 20 $channels = New-Object System.Collections.Generic.List[string] $subcategoryGuids = New-Object System.Collections.Generic.List[string] -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 - } - foreach ($row in (Import-Csv $BaselineFile)) { - if ("$($row.Selected)".Trim() -notmatch '^(Y|YES|TRUE|1)$') { continue } - if ($row.ItemType -eq 'Channel') { $channels.Add($row.Id) } - if ($row.ItemType -eq 'AuditPolicy') { $subcategoryGuids.Add($row.Id.ToUpper()) } - } - $sourceDesc = "baseline file $(Split-Path $BaselineFile -Leaf)" -} else { - foreach ($ch in $script:BaselineChannels) { - $take = ($ch.Tier -eq 'Core') - if ($ch.Tier -eq 'HighVolume' -and $IncludeHighVolume) { $take = $true } - if ($ch.Tier -eq 'Optional' -and $IncludeOptional) { $take = $true } - if ($take) { $channels.Add($ch.Name) } - } - foreach ($sub in $script:BaselineAuditSubcategories) { - $take = ($sub.Tier -eq 'Core') - if ($sub.Tier -eq 'HighVolume' -and $IncludeHighVolume) { $take = $true } - if ($sub.Tier -eq 'Optional' -and $IncludeOptional) { $take = $true } - if ($take) { $subcategoryGuids.Add($sub.Guid.ToUpper()) } - } - $sourceDesc = "kit Core tier$(if ($IncludeHighVolume) {' + HighVolume'})$(if ($IncludeOptional) {' + Optional'})" +$sel = Resolve-BaselineSelection -BaselineFile $BaselineFile -IncludeHighVolume $IncludeHighVolume -IncludeOptional $IncludeOptional +foreach ($ch in $script:BaselineChannels) { + if (Test-ItemSelected $sel 'Channel' $ch.Name $ch.Tier) { $channels.Add($ch.Name) } +} +foreach ($sub in $script:BaselineAuditSubcategories) { + if (Test-ItemSelected $sel 'AuditPolicy' $sub.Guid $sub.Tier) { $subcategoryGuids.Add($sub.Guid.ToUpper()) } } +$sourceDesc = $sel.Description if ($channels.Count -eq 0) { Write-Error 'No channels selected - nothing to forward.' diff --git a/Test-LoggingBaseline.ps1 b/Test-LoggingBaseline.ps1 index 518f2cc..5982b43 100644 --- a/Test-LoggingBaseline.ps1 +++ b/Test-LoggingBaseline.ps1 @@ -66,76 +66,25 @@ $ErrorActionPreference = 'Stop' if ([string]::IsNullOrEmpty($OutputDir)) { $OutputDir = Join-Path $PSScriptRoot 'Results' } . (Join-Path $PSScriptRoot 'LoggingBaseline.Settings.ps1') +. (Join-Path $PSScriptRoot 'WinLogKit.Common.ps1') # ---------------------------------------------------------------- helpers --- -function Test-IsAdmin { - $id = [Security.Principal.WindowsIdentity]::GetCurrent() - (New-Object Security.Principal.WindowsPrincipal $id).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -} - -function Get-DomainRole { - $role = (Get-CimInstance -ClassName Win32_ComputerSystem).DomainRole - if ($role -ge 4) { return 'DomainController' } - if ($role -ge 2) { return 'Member' } - return 'Standalone' -} - -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' -} - -function ConvertTo-NetRegPath { param([string]$Path) $Path -replace '^HKLM:\\', 'HKEY_LOCAL_MACHINE\' } - -function Get-RegValue { - param([string]$Path, [string]$Name) - [Microsoft.Win32.Registry]::GetValue((ConvertTo-NetRegPath $Path), $Name, $null) -} - -function Get-AuditPolicyByGuid { - $map = @{} - $csv = auditpol /get /category:* /r | Where-Object { $_ -match '\S' } | ConvertFrom-Csv - foreach ($row in $csv) { - $guid = ($row.'Subcategory GUID' -replace '[{}]', '').ToUpper() - $map[$guid] = $row.'Inclusion Setting' - } - return $map -} - -function Test-TierSelected { - param([string]$Tier) - if ($Tier -eq 'Core') { return $true } - if ($Tier -eq 'HighVolume') { return [bool]$IncludeHighVolume } - if ($Tier -eq 'Optional') { return [bool]$IncludeOptional } - return $false -} - -# Selection map from a New-LoggingBaseline.ps1 CSV: "ITEMTYPE|ID" -> bool. -$script:Selection = $null -function Import-BaselineSelection { - param([string]$Path) - $map = @{} - foreach ($row in (Import-Csv $Path)) { - $map[("$($row.ItemType)|$($row.Id)").ToUpper()] = ("$($row.Selected)".Trim() -match '^(Y|YES|TRUE|1)$') - } - return $map -} +# Host probes, registry reads, the audit policy reader and the selection +# model come from WinLogKit.Common.ps1. +$script:Selection = Resolve-BaselineSelection -BaselineFile $BaselineFile -IncludeHighVolume $IncludeHighVolume -IncludeOptional $IncludeOptional # Returns $null when the item should be assessed, otherwise the # NOT APPLICABLE reason text. function Get-SkipReason { param([string]$Tier, [string]$ItemType, [string]$Id) - if ($null -ne $script:Selection) { + if ($null -ne $script:Selection.Map) { $key = ("$ItemType|$Id").ToUpper() - if (-not $script:Selection.ContainsKey($key)) { return 'Not listed in baseline file' } - if (-not $script:Selection[$key]) { return 'Selected = N in baseline file' } + if (-not $script:Selection.Map.ContainsKey($key)) { return 'Not listed in baseline file' } + if (-not $script:Selection.Map[$key]) { return 'Selected = N in baseline file' } return $null } - if (Test-TierSelected $Tier) { return $null } + if (Test-ItemSelected $script:Selection $ItemType $Id $Tier) { return $null } return "$Tier tier not selected for this assessment" } @@ -160,18 +109,10 @@ if (-not (Test-IsAdmin)) { exit 1 } -if (-not [string]::IsNullOrEmpty($BaselineFile)) { - if (-not (Test-Path $BaselineFile)) { - Write-Error "Baseline file not found: $BaselineFile (build one with New-LoggingBaseline.ps1)" - exit 1 - } - $script:Selection = Import-BaselineSelection -Path $BaselineFile -} - $domainRole = Get-DomainRole Write-Host "Test-LoggingBaseline (verification only, nothing is changed)" Write-Host "Host profile : $(Get-OsType), $domainRole" -if ($null -ne $script:Selection) { +if ($null -ne $script:Selection.Map) { Write-Host "Baseline file : $BaselineFile (tier switches ignored)" } else { Write-Host "Tiers assessed : Core$(if ($IncludeHighVolume) {' + HighVolume'})$(if ($IncludeOptional) {' + Optional'})" diff --git a/WinLogKit.Common.ps1 b/WinLogKit.Common.ps1 new file mode 100644 index 0000000..a64722d --- /dev/null +++ b/WinLogKit.Common.ps1 @@ -0,0 +1,130 @@ +# ============================================================================= +# 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 reader 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. +# +# The Intune pack generator embeds its own copies of what the generated +# scripts need: those must stay self-contained. +# +# 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' +} + +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\' } + +function Get-RegValue { + param([string]$Path, [string]$Name) + [Microsoft.Win32.Registry]::GetValue((ConvertTo-NetRegPath $Path), $Name, $null) +} + +# ----------------------------------------------------------- audit policy --- + +function Get-AuditPolicyByGuid { + # One auditpol call for everything; returns hashtable GUID -> inclusion setting text. + $map = @{} + $csv = auditpol /get /category:* /r | Where-Object { $_ -match '\S' } | ConvertFrom-Csv + foreach ($row in $csv) { + $guid = ($row.'Subcategory GUID' -replace '[{}]', '').ToUpper() + $map[$guid] = $row.'Inclusion Setting' + } + return $map +} + +# -------------------------------------------------------------- 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 and Optional only when +# their switch is given + +# Selection map from a selection CSV: "ITEMTYPE|ID" -> bool. +function Import-BaselineSelection { + param([string]$Path) + $map = @{} + foreach ($row in (Import-Csv $Path)) { + $map[("$($row.ItemType)|$($row.Id)").ToUpper()] = ("$($row.Selected)".Trim() -match '^(Y|YES|TRUE|1)$') + } + return $map +} + +function Test-TierSelected { + param([string]$Tier, [bool]$IncludeHighVolume, [bool]$IncludeOptional) + if ($Tier -eq 'Core') { return $true } + if ($Tier -eq 'HighVolume') { return $IncludeHighVolume } + if ($Tier -eq 'Optional') { return $IncludeOptional } + return $false +} + +# Resolves a script's -BaselineFile / -IncludeHighVolume / -IncludeOptional +# 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. +# Map hashtable "ITEMTYPE|ID" -> bool, or $null for tier mode +# IncludeHighVolume, IncludeOptional the tier switches (tier mode only) +# Description "baseline file X.csv" or "Core tier [+ HighVolume] [+ Optional]" +# BaselineFile the path as given, or '' +function Resolve-BaselineSelection { + param([string]$BaselineFile, [bool]$IncludeHighVolume, [bool]$IncludeOptional) + $map = $null + $description = "Core tier$(if ($IncludeHighVolume) {' + HighVolume'})$(if ($IncludeOptional) {' + Optional'})" + 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 + IncludeOptional = $IncludeOptional + 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 -IncludeOptional $Selection.IncludeOptional) +} diff --git a/docs/commands.md b/docs/commands.md index 8699aae..d026ce3 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,7 +1,8 @@ # Commands Every script, what it does, and the flags you'll actually use. They all -read the same settings table (`LoggingBaseline.Settings.ps1`), so - given +read the same settings table (`LoggingBaseline.Settings.ps1`) and share one +helper file (`WinLogKit.Common.ps1`), so - given the same selection, and regenerating artefacts after any settings change - what you apply, what you verify and what you deploy can't disagree. All of the kit's scripts run on PowerShell 7 and on stock Windows diff --git a/docs/mapping.md b/docs/mapping.md index 34daf8a..f229f4a 100644 --- a/docs/mapping.md +++ b/docs/mapping.md @@ -93,7 +93,8 @@ Reading it top to bottom: generated from them. - **One table**: every script - the builder, Enable, Test, the coverage report and all three fleet generators - dot-sources - `LoggingBaseline.Settings.ps1`, so applied config, deployed artefacts and + `LoggingBaseline.Settings.ps1` (and the shared helpers in + `WinLogKit.Common.ps1`), so applied config, deployed artefacts and verification can never disagree. - **Events out**: hosts write to the Windows Event Log service; [Windows Event Forwarding](https://learn.microsoft.com/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection) diff --git a/tests/Invoke-KitChecks.ps1 b/tests/Invoke-KitChecks.ps1 index e2dea9a..46f900d 100644 --- a/tests/Invoke-KitChecks.ps1 +++ b/tests/Invoke-KitChecks.ps1 @@ -6,6 +6,8 @@ Safe on any machine: nothing is applied, no admin needed. Checks: 1. Every .ps1 parses cleanly on the current PowerShell engine (CI runs this under both Windows PowerShell 5.1 and PowerShell 7). + 1c. Every helper function is defined in exactly one file (shared ones + live in WinLogKit.Common.ps1). 2. The settings table is internally consistent: category tags valid, coverage notes complete, audit GUIDs unique and well-formed. 3. New-LoggingBaseline.ps1 runs end-to-end non-interactively and its @@ -73,6 +75,28 @@ if ($badNewItem) { Pass 'every New-Item declares -ItemType Directory/File (WELA issue #243 class fenced)' } +# 1c. One definition per helper. Shared helpers live in WinLogKit.Common.ps1; +# a function defined in two kit files is the copy-paste drift this fences. +# The Intune pack generator embeds its helpers inside a here-string, which +# the AST does not see as definitions - the generated pack must stay +# self-contained, so that is intended. +$defs = @{} +foreach ($f in Get-ChildItem $KitRoot -Filter *.ps1 -Recurse | + Where-Object { $_.FullName.Substring($kitRootFull.Length) -notmatch '\\(WELA[^\\]*|Baseline|Logs|Results|Evidence|Intune)\\' }) { + $tokens = $null; $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$tokens, [ref]$errors) + foreach ($fn in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) { + if (-not $defs.ContainsKey($fn.Name)) { $defs[$fn.Name] = @() } + if ($defs[$fn.Name] -notcontains $f.Name) { $defs[$fn.Name] += $f.Name } + } +} +$dupes = @($defs.Keys | Where-Object { $defs[$_].Count -gt 1 } | Sort-Object | ForEach-Object { "$_ ($($defs[$_] -join ', '))" }) +if ($dupes) { + Fail "function defined in more than one file (move it to WinLogKit.Common.ps1): $($dupes -join '; ')" +} else { + Pass "every helper function is defined in exactly one file ($($defs.Count) functions)" +} + # 2. Settings table consistency ----------------------------------------------- . (Join-Path $KitRoot 'LoggingBaseline.Settings.ps1') From d5c0e10a0aec109c0512431abf2d7fe78818de0c Mon Sep 17 00:00:00 2001 From: spydisec <9101327+spydisec@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:03:06 +1000 Subject: [PATCH 2/7] Common: validate selection CSVs; pin the shared helpers in the self-check Local CodeRabbit review on step 2: - Import-BaselineSelection rejects a CSV without ItemType/Id/Selected columns, an empty ItemType or Id, or a duplicate item, with a message naming the problem. The wrong -BaselineFile used to fail obscurely or select nothing. - Self-check 1c also asserts the ten shared helpers are defined in WinLogKit.Common.ps1 only, so a copy cannot migrate back into a script. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 5 +++++ WinLogKit.Common.ps1 | 30 +++++++++++++++++++++++++++--- tests/Invoke-KitChecks.ps1 | 10 ++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 757e7b1..1be8f1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ releases are tagged `vX.Y.Z` and published with a zip + SHA256 checksum. except that every generated artefact now describes its source the same way (`Core tier [+ HighVolume] [+ Optional]` or `baseline file X.csv`). The self-checks fail if a function is defined in more than one file. +- **Selection CSVs are checked before use.** A `-BaselineFile` that is not a + selection CSV (missing `ItemType`, `Id` or `Selected` columns), has an + empty ItemType or Id, or lists the same item twice now stops the run with + a message naming the problem, instead of an obscure error or a silent + select-nothing. - **Docs cut.** README reduced to one screen and reused as the site home page (MkDocs snippet include, one copy of the text). The site goes from 13 pages to 10: the WEC Collector page absorbs the WEF section of diff --git a/WinLogKit.Common.ps1 b/WinLogKit.Common.ps1 index a64722d..9f61d4f 100644 --- a/WinLogKit.Common.ps1 +++ b/WinLogKit.Common.ps1 @@ -72,12 +72,36 @@ function Get-AuditPolicyByGuid { # - the tier switches: Core is always on, HighVolume and Optional only when # their switch is given -# Selection map from a selection CSV: "ITEMTYPE|ID" -> bool. +# 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. 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 = @{} - foreach ($row in (Import-Csv $Path)) { - $map[("$($row.ItemType)|$($row.Id)").ToUpper()] = ("$($row.Selected)".Trim() -match '^(Y|YES|TRUE|1)$') + $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)$') } return $map } diff --git a/tests/Invoke-KitChecks.ps1 b/tests/Invoke-KitChecks.ps1 index 46f900d..8244100 100644 --- a/tests/Invoke-KitChecks.ps1 +++ b/tests/Invoke-KitChecks.ps1 @@ -96,6 +96,16 @@ if ($dupes) { } else { Pass "every helper function is defined in exactly one file ($($defs.Count) functions)" } +# The shared helpers must stay in the common file: a copy that migrated back +# into one script would still be a single definition, so name them. +$commonExpected = @('Test-IsAdmin', 'Get-DomainRole', 'Get-OsType', 'ConvertTo-NetRegPath', 'Get-RegValue', + 'Get-AuditPolicyByGuid', 'Import-BaselineSelection', 'Test-TierSelected', 'Resolve-BaselineSelection', 'Test-ItemSelected') +$notInCommon = @($commonExpected | Where-Object { -not $defs.ContainsKey($_) -or $defs[$_] -ne @('WinLogKit.Common.ps1') }) +if ($notInCommon) { + Fail "shared helper not defined in WinLogKit.Common.ps1 (only): $($notInCommon -join ', ')" +} else { + Pass "the $($commonExpected.Count) shared helpers are defined in WinLogKit.Common.ps1 only" +} # 2. Settings table consistency ----------------------------------------------- . (Join-Path $KitRoot 'LoggingBaseline.Settings.ps1') From 6b4adafd5e930bb3f580ebce0477b5150f329add Mon Sep 17 00:00:00 2001 From: spydisec <9101327+spydisec@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:12:16 +1000 Subject: [PATCH 3/7] Common: move Get-SmbAuditState; check auditpol exit code; key helpers by path CodeRabbit on #31: - Get-SmbAuditState was a function in Enable and the same code inline in Test; one copy in WinLogKit.Common.ps1 now, pinned by the self-check - Get-AuditPolicyByGuid throws when auditpol exits non-zero instead of returning an empty map - self-check 1c tracks definitions by root-relative path, so same-named files in different folders cannot mask a duplicate Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 3 ++- Enable-LoggingBaseline.ps1 | 18 ------------------ Test-LoggingBaseline.ps1 | 12 +----------- WinLogKit.Common.ps1 | 28 ++++++++++++++++++++++++++-- tests/Invoke-KitChecks.ps1 | 7 +++++-- 5 files changed, 34 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1be8f1f..70b83d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ releases are tagged `vX.Y.Z` and published with a zip + SHA256 checksum. - **`WinLogKit.Common.ps1`.** The helpers that Enable, Test, the WELA check, the coverage report and the fleet generators each carried their own copy of (admin check, host role and OS type, registry reads, the - auditpol reader, selection-CSV loading and the tier logic) now live in + auditpol and SMB audit-state readers, selection-CSV loading and the tier + logic) now live in one dot-sourced file next to the settings table. Behaviour is unchanged except that every generated artefact now describes its source the same way (`Core tier [+ HighVolume] [+ Optional]` or `baseline file X.csv`). diff --git a/Enable-LoggingBaseline.ps1 b/Enable-LoggingBaseline.ps1 index f307cbf..74bc9c2 100644 --- a/Enable-LoggingBaseline.ps1 +++ b/Enable-LoggingBaseline.ps1 @@ -131,24 +131,6 @@ function Get-DesiredInclusion { return 'No Auditing' } -# Current state of the Server 2025+ SMB signing/encryption audit settings. -# 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+). -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 } - if ($null -ne $cfg -and ($cfg.PSObject.Properties.Name -contains $item.Id)) { - $state[$item.Id] = [bool]$cfg.($item.Id) - } - } - return $state -} - function Set-SmbAuditSetting { param([hashtable]$Item) $setParams = @{ $Item.Id = $Item.Value; Force = $true } diff --git a/Test-LoggingBaseline.ps1 b/Test-LoggingBaseline.ps1 index 5982b43..1d83cf3 100644 --- a/Test-LoggingBaseline.ps1 +++ b/Test-LoggingBaseline.ps1 @@ -217,17 +217,7 @@ foreach ($rs in $script:BaselineRegistrySettings) { # ------------------- SMB signing/encryption auditing (Server 2025+) --------- -$smbState = @{} -$srvCfg = $null; $cliCfg = $null -try { $srvCfg = Get-SmbServerConfiguration -ErrorAction Stop } catch { $srvCfg = $null } -try { $cliCfg = Get-SmbClientConfiguration -ErrorAction Stop } catch { $cliCfg = $null } -foreach ($sa in $script:BaselineSmbAuditSettings) { - $cfg = $srvCfg - if ($sa.Side -eq 'Client') { $cfg = $cliCfg } - if ($null -ne $cfg -and ($cfg.PSObject.Properties.Name -contains $sa.Id)) { - $smbState[$sa.Id] = [bool]$cfg.($sa.Id) - } -} +$smbState = Get-SmbAuditState foreach ($sa in $script:BaselineSmbAuditSettings) { $expected = "$($sa.Value)" $skip = Get-SkipReason $sa.Tier 'SmbAudit' $sa.Id diff --git a/WinLogKit.Common.ps1 b/WinLogKit.Common.ps1 index 9f61d4f..1836020 100644 --- a/WinLogKit.Common.ps1 +++ b/WinLogKit.Common.ps1 @@ -2,7 +2,7 @@ # 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 reader and the one selection model (tier switches +# 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. @@ -55,7 +55,11 @@ function Get-RegValue { function Get-AuditPolicyByGuid { # One auditpol call for everything; returns hashtable GUID -> inclusion setting text. $map = @{} - $csv = auditpol /get /category:* /r | Where-Object { $_ -match '\S' } | ConvertFrom-Csv + $lines = auditpol /get /category:* /r + if ($LASTEXITCODE -ne 0) { + throw "auditpol /get /category:* /r failed with exit code $LASTEXITCODE (run elevated): $(($lines | Select-Object -First 2) -join ' ')" + } + $csv = $lines | Where-Object { $_ -match '\S' } | ConvertFrom-Csv foreach ($row in $csv) { $guid = ($row.'Subcategory GUID' -replace '[{}]', '').ToUpper() $map[$guid] = $row.'Inclusion Setting' @@ -63,6 +67,26 @@ function Get-AuditPolicyByGuid { return $map } +# ------------------------------------------------------------ SMB auditing --- + +# Current state of the Server 2025+ SMB signing/encryption audit settings. +# 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+). +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 } + if ($null -ne $cfg -and ($cfg.PSObject.Properties.Name -contains $item.Id)) { + $state[$item.Id] = [bool]$cfg.($item.Id) + } + } + return $state +} + # -------------------------------------------------------------- selection --- # # A selection answers "is this item on?" for every item in the settings diff --git a/tests/Invoke-KitChecks.ps1 b/tests/Invoke-KitChecks.ps1 index 8244100..9080d3a 100644 --- a/tests/Invoke-KitChecks.ps1 +++ b/tests/Invoke-KitChecks.ps1 @@ -85,9 +85,12 @@ foreach ($f in Get-ChildItem $KitRoot -Filter *.ps1 -Recurse | Where-Object { $_.FullName.Substring($kitRootFull.Length) -notmatch '\\(WELA[^\\]*|Baseline|Logs|Results|Evidence|Intune)\\' }) { $tokens = $null; $errors = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$tokens, [ref]$errors) + # Root-relative path, so two files with the same name in different + # folders stay distinct. + $rel = $f.FullName.Substring($kitRootFull.Length).TrimStart('') foreach ($fn in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) { if (-not $defs.ContainsKey($fn.Name)) { $defs[$fn.Name] = @() } - if ($defs[$fn.Name] -notcontains $f.Name) { $defs[$fn.Name] += $f.Name } + if ($defs[$fn.Name] -notcontains $rel) { $defs[$fn.Name] += $rel } } } $dupes = @($defs.Keys | Where-Object { $defs[$_].Count -gt 1 } | Sort-Object | ForEach-Object { "$_ ($($defs[$_] -join ', '))" }) @@ -99,7 +102,7 @@ if ($dupes) { # The shared helpers must stay in the common file: a copy that migrated back # into one script would still be a single definition, so name them. $commonExpected = @('Test-IsAdmin', 'Get-DomainRole', 'Get-OsType', 'ConvertTo-NetRegPath', 'Get-RegValue', - 'Get-AuditPolicyByGuid', 'Import-BaselineSelection', 'Test-TierSelected', 'Resolve-BaselineSelection', 'Test-ItemSelected') + 'Get-AuditPolicyByGuid', 'Get-SmbAuditState', 'Import-BaselineSelection', 'Test-TierSelected', 'Resolve-BaselineSelection', 'Test-ItemSelected') $notInCommon = @($commonExpected | Where-Object { -not $defs.ContainsKey($_) -or $defs[$_] -ne @('WinLogKit.Common.ps1') }) if ($notInCommon) { Fail "shared helper not defined in WinLogKit.Common.ps1 (only): $($notInCommon -join ', ')" From 055a9270912e608078803f49747d9983e957d69c Mon Sep 17 00:00:00 2001 From: spydisec <9101327+spydisec@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:13:23 +1000 Subject: [PATCH 4/7] Self-check 1c: trim the leading backslash from the root-relative path The previous commit lost the backslash in TrimStart, so every helper's recorded path kept its leading separator and the pinned-helper check failed on both engines. Use [char]92 and an explicit join comparison. Co-Authored-By: Claude Fable 5.1 --- tests/Invoke-KitChecks.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Invoke-KitChecks.ps1 b/tests/Invoke-KitChecks.ps1 index 9080d3a..ae284db 100644 --- a/tests/Invoke-KitChecks.ps1 +++ b/tests/Invoke-KitChecks.ps1 @@ -87,7 +87,7 @@ foreach ($f in Get-ChildItem $KitRoot -Filter *.ps1 -Recurse | $ast = [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$tokens, [ref]$errors) # Root-relative path, so two files with the same name in different # folders stay distinct. - $rel = $f.FullName.Substring($kitRootFull.Length).TrimStart('') + $rel = $f.FullName.Substring($kitRootFull.Length).TrimStart([char]92) foreach ($fn in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) { if (-not $defs.ContainsKey($fn.Name)) { $defs[$fn.Name] = @() } if ($defs[$fn.Name] -notcontains $rel) { $defs[$fn.Name] += $rel } @@ -103,7 +103,7 @@ if ($dupes) { # into one script would still be a single definition, so name them. $commonExpected = @('Test-IsAdmin', 'Get-DomainRole', 'Get-OsType', 'ConvertTo-NetRegPath', 'Get-RegValue', 'Get-AuditPolicyByGuid', 'Get-SmbAuditState', 'Import-BaselineSelection', 'Test-TierSelected', 'Resolve-BaselineSelection', 'Test-ItemSelected') -$notInCommon = @($commonExpected | Where-Object { -not $defs.ContainsKey($_) -or $defs[$_] -ne @('WinLogKit.Common.ps1') }) +$notInCommon = @($commonExpected | Where-Object { -not $defs.ContainsKey($_) -or (($defs[$_] -join ';') -ne 'WinLogKit.Common.ps1') }) if ($notInCommon) { Fail "shared helper not defined in WinLogKit.Common.ps1 (only): $($notInCommon -join ', ')" } else { From fac7e4c8e7a3fc5cedcc6b3b08e47369ecc978f1 Mon Sep 17 00:00:00 2001 From: spydisec <9101327+spydisec@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:23:48 +1000 Subject: [PATCH 5/7] Common: reject a selection CSV that matches nothing in the settings table CodeRabbit on #31: a CSV with the right columns but unknown ids made Test report every item NOT APPLICABLE and exit 0. Import-BaselineSelection now stops when no row matches a known item, and warns (then ignores) rows for items this kit version does not know, so an older CSV still works. Self-check 4b covers both cases in a child process. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 5 ++++- WinLogKit.Common.ps1 | 23 ++++++++++++++++++++++- tests/Invoke-KitChecks.ps1 | 24 +++++++++++++++++++++++- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70b83d4..8fa50ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,10 @@ releases are tagged `vX.Y.Z` and published with a zip + SHA256 checksum. selection CSV (missing `ItemType`, `Id` or `Selected` columns), has an empty ItemType or Id, or lists the same item twice now stops the run with a message naming the problem, instead of an obscure error or a silent - select-nothing. + select-nothing. So does a CSV whose rows match nothing in the settings + table (Test would otherwise report everything NOT APPLICABLE and exit 0); + rows for items this kit version does not know are warned about and + ignored, so an older CSV still works. - **Docs cut.** README reduced to one screen and reused as the site home page (MkDocs snippet include, one copy of the text). The site goes from 13 pages to 10: the WEC Collector page absorbs the WEF section of diff --git a/WinLogKit.Common.ps1 b/WinLogKit.Common.ps1 index 1836020..433e534 100644 --- a/WinLogKit.Common.ps1 +++ b/WinLogKit.Common.ps1 @@ -96,9 +96,23 @@ function Get-SmbAuditState { # - the tier switches: Core is always on, HighVolume and Optional only when # their switch is given +# Every "ITEMTYPE|ID" key the settings table defines, as a hashtable set. +function Get-BaselineItemKeys { + $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. +# 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) @@ -127,6 +141,13 @@ function Import-BaselineSelection { } $map[$key] = ("$($row.Selected)".Trim() -match '^(Y|YES|TRUE|1)$') } + $known = Get-BaselineItemKeys + $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 } diff --git a/tests/Invoke-KitChecks.ps1 b/tests/Invoke-KitChecks.ps1 index ae284db..cdcc25a 100644 --- a/tests/Invoke-KitChecks.ps1 +++ b/tests/Invoke-KitChecks.ps1 @@ -102,7 +102,7 @@ if ($dupes) { # The shared helpers must stay in the common file: a copy that migrated back # into one script would still be a single definition, so name them. $commonExpected = @('Test-IsAdmin', 'Get-DomainRole', 'Get-OsType', 'ConvertTo-NetRegPath', 'Get-RegValue', - 'Get-AuditPolicyByGuid', 'Get-SmbAuditState', 'Import-BaselineSelection', 'Test-TierSelected', 'Resolve-BaselineSelection', 'Test-ItemSelected') + 'Get-AuditPolicyByGuid', 'Get-SmbAuditState', 'Get-BaselineItemKeys', 'Import-BaselineSelection', 'Test-TierSelected', 'Resolve-BaselineSelection', 'Test-ItemSelected') $notInCommon = @($commonExpected | Where-Object { -not $defs.ContainsKey($_) -or (($defs[$_] -join ';') -ne 'WinLogKit.Common.ps1') }) if ($notInCommon) { Fail "shared helper not defined in WinLogKit.Common.ps1 (only): $($notInCommon -join ', ')" @@ -164,6 +164,28 @@ try { if ($selCore -ne $coreCount -or $selOther -ne 0) { Fail "recommended defaults wrong (core=$coreCount selected-core=$selCore selected-noncore=$selOther)" } else { Pass 'recommended defaults select exactly Core' } if (@($r2 | Where-Object { $_.Selected -eq 'Y' }).Count -ne $r2.Count) { Fail 'all-tiers run did not select everything' } else { Pass 'all-tiers run selects everything' } + # 4b. Selection CSV validation: a file with the right columns but no row + # matching this kit must be rejected (otherwise Test would report every + # item NOT APPLICABLE and exit 0), while one stale row only warns. + # Child process: the rejection is a terminating error in-session, and + # the child's stderr is captured with ErrorActionPreference relaxed, + # because Windows PowerShell 5.1 turns redirected native stderr into + # a terminating error under 'Stop'. + $engine = (Get-Process -Id $PID).Path + $badCsv = Join-Path $tmp 'unknown-only.csv' + '"ItemType","Id","Selected"', '"Channel","No-Such-Channel/Operational","Y"' | Set-Content $badCsv + $staleCsv = Join-Path $tmp 'one-stale-row.csv' + (Get-Content $csv1) + '"Channel","No-Such-Channel/Operational","Core","All","Y","Y","","",""' | Set-Content $staleCsv + $prevEap = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + $badOut = & $engine -NoProfile -ExecutionPolicy Bypass -File (Join-Path $KitRoot 'New-LoggingBaseline.ps1') -Show -BaselineFile $badCsv 2>&1 | Out-String + $badExit = $LASTEXITCODE + $staleOut = & $engine -NoProfile -ExecutionPolicy Bypass -File (Join-Path $KitRoot 'New-LoggingBaseline.ps1') -Show -BaselineFile $staleCsv 2>&1 | Out-String + $staleExit = $LASTEXITCODE + $ErrorActionPreference = $prevEap + if ($badExit -ne 0 -and $badOut -match 'No row in the baseline file matches') { Pass 'selection CSV with no known item is rejected' } else { Fail "selection CSV with no known item was accepted (exit $badExit): $($badOut.Trim())" } + if ($staleExit -eq 0 -and $staleOut -match 'does not know, ignored: CHANNEL\|NO-SUCH-CHANNEL/OPERATIONAL') { Pass 'selection CSV with one unknown row warns and continues' } else { Fail "stale-row CSV handling wrong (exit $staleExit): $($staleOut.Trim())" } + # 5. Intune pack generation: files parse, placeholders replaced, selection respected $packDir = Join-Path $tmp 'intune' & (Join-Path $KitRoot 'New-IntuneRemediationPack.ps1') -OutDir $packDir | Out-Null From fce80b2e0b15cb896134fb3e0744db815bab0f5a Mon Sep 17 00:00:00 2001 From: spydisec <9101327+spydisec@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:30:24 +1000 Subject: [PATCH 6/7] Rename Get-BaselineItemKeys to Get-BaselineItemKeySet (PSUseSingularNouns) Co-Authored-By: Claude Fable 5.1 --- WinLogKit.Common.ps1 | 4 ++-- tests/Invoke-KitChecks.ps1 | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/WinLogKit.Common.ps1 b/WinLogKit.Common.ps1 index 433e534..05fd662 100644 --- a/WinLogKit.Common.ps1 +++ b/WinLogKit.Common.ps1 @@ -97,7 +97,7 @@ function Get-SmbAuditState { # their switch is given # Every "ITEMTYPE|ID" key the settings table defines, as a hashtable set. -function Get-BaselineItemKeys { +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 } @@ -141,7 +141,7 @@ function Import-BaselineSelection { } $map[$key] = ("$($row.Selected)".Trim() -match '^(Y|YES|TRUE|1)$') } - $known = Get-BaselineItemKeys + $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)" diff --git a/tests/Invoke-KitChecks.ps1 b/tests/Invoke-KitChecks.ps1 index cdcc25a..f2a5de9 100644 --- a/tests/Invoke-KitChecks.ps1 +++ b/tests/Invoke-KitChecks.ps1 @@ -102,7 +102,7 @@ if ($dupes) { # The shared helpers must stay in the common file: a copy that migrated back # into one script would still be a single definition, so name them. $commonExpected = @('Test-IsAdmin', 'Get-DomainRole', 'Get-OsType', 'ConvertTo-NetRegPath', 'Get-RegValue', - 'Get-AuditPolicyByGuid', 'Get-SmbAuditState', 'Get-BaselineItemKeys', 'Import-BaselineSelection', 'Test-TierSelected', 'Resolve-BaselineSelection', 'Test-ItemSelected') + 'Get-AuditPolicyByGuid', 'Get-SmbAuditState', 'Get-BaselineItemKeySet', 'Import-BaselineSelection', 'Test-TierSelected', 'Resolve-BaselineSelection', 'Test-ItemSelected') $notInCommon = @($commonExpected | Where-Object { -not $defs.ContainsKey($_) -or (($defs[$_] -join ';') -ne 'WinLogKit.Common.ps1') }) if ($notInCommon) { Fail "shared helper not defined in WinLogKit.Common.ps1 (only): $($notInCommon -join ', ')" From 91b78c11c38e34c09ef9b0d925f4b672753f5b83 Mon Sep 17 00:00:00 2001 From: spydisec <9101327+spydisec@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:39:38 +1000 Subject: [PATCH 7/7] Self-check 1c: count every definition, so a same-file duplicate fails too CodeRabbit on #31: the per-name path list was de-duplicated, so a function defined twice in one file (PowerShell keeps the last, silently) passed. Co-Authored-By: Claude Fable 5.1 --- tests/Invoke-KitChecks.ps1 | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/Invoke-KitChecks.ps1 b/tests/Invoke-KitChecks.ps1 index f2a5de9..584326e 100644 --- a/tests/Invoke-KitChecks.ps1 +++ b/tests/Invoke-KitChecks.ps1 @@ -76,7 +76,9 @@ if ($badNewItem) { } # 1c. One definition per helper. Shared helpers live in WinLogKit.Common.ps1; -# a function defined in two kit files is the copy-paste drift this fences. +# a function defined in two kit files is the copy-paste drift this fences, and +# a function defined twice in one file (PowerShell keeps the last, silently) +# fails the same way. # The Intune pack generator embeds its helpers inside a here-string, which # the AST does not see as definitions - the generated pack must stay # self-contained, so that is intended. @@ -90,14 +92,14 @@ foreach ($f in Get-ChildItem $KitRoot -Filter *.ps1 -Recurse | $rel = $f.FullName.Substring($kitRootFull.Length).TrimStart([char]92) foreach ($fn in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) { if (-not $defs.ContainsKey($fn.Name)) { $defs[$fn.Name] = @() } - if ($defs[$fn.Name] -notcontains $rel) { $defs[$fn.Name] += $rel } + $defs[$fn.Name] += $rel } } $dupes = @($defs.Keys | Where-Object { $defs[$_].Count -gt 1 } | Sort-Object | ForEach-Object { "$_ ($($defs[$_] -join ', '))" }) if ($dupes) { - Fail "function defined in more than one file (move it to WinLogKit.Common.ps1): $($dupes -join '; ')" + Fail "function defined more than once (shared helpers belong in WinLogKit.Common.ps1): $($dupes -join '; ')" } else { - Pass "every helper function is defined in exactly one file ($($defs.Count) functions)" + Pass "every helper function is defined exactly once ($($defs.Count) functions)" } # The shared helpers must stay in the common file: a copy that migrated back # into one script would still be a single definition, so name them.