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..8fa50ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ 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 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`). + 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. 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/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..74bc9c2 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' } @@ -168,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 } @@ -217,37 +162,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 +201,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..1d83cf3 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'})" @@ -276,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 new file mode 100644 index 0000000..05fd662 --- /dev/null +++ b/WinLogKit.Common.ps1 @@ -0,0 +1,199 @@ +# ============================================================================= +# 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. +# +# 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 = @{} + $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' + } + 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 +# 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 + +# 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 +} + +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..584326e 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,43 @@ 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, 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. +$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) + # Root-relative path, so two files with the same name in different + # folders stay distinct. + $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] = @() } + $defs[$fn.Name] += $rel + } +} +$dupes = @($defs.Keys | Where-Object { $defs[$_].Count -gt 1 } | Sort-Object | ForEach-Object { "$_ ($($defs[$_] -join ', '))" }) +if ($dupes) { + Fail "function defined more than once (shared helpers belong in WinLogKit.Common.ps1): $($dupes -join '; ')" +} else { + 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. +$commonExpected = @('Test-IsAdmin', 'Get-DomainRole', 'Get-OsType', 'ConvertTo-NetRegPath', 'Get-RegValue', + '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 ', ')" +} else { + Pass "the $($commonExpected.Count) shared helpers are defined in WinLogKit.Common.ps1 only" +} + # 2. Settings table consistency ----------------------------------------------- . (Join-Path $KitRoot 'LoggingBaseline.Settings.ps1') @@ -127,6 +166,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