tests/WindowsSecurityAudit.Tests.ps1
|
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } <# .SYNOPSIS Pester 5 structural tests for the WindowsSecurityAudit module. .DESCRIPTION Cross-platform tests (run on any OS with Pester 5+): * The module imports and exports exactly one function per .ps1 file found in the category folders dot-sourced by WindowsSecurityAudit.psm1 (the expected count is computed from the files on disk, not hardcoded). * Every exported function ships comment-based help with .SYNOPSIS and at least one .EXAMPLE (verified through Get-Help). * Every .ps1 file in the repository parses without errors. * No function file assigns to the automatic variables $PID or $Host. * No file invokes Invoke-Expression (or the IEX alias). The module targets Windows PowerShell 5.1+, so only APIs available in 5.1 are used here (no null-coalescing/ternary operators, no ForEach-Object -Parallel). #> BeforeDiscovery { $script:ModuleRoot = Split-Path -Parent $PSScriptRoot $script:ModuleName = 'WindowsSecurityAudit' $script:ModulePath = Join-Path -Path $script:ModuleRoot -ChildPath "$script:ModuleName.psm1" # Category folders dot-sourced by WindowsSecurityAudit.psm1 (kept in sync with the psm1) $script:CategoryFolders = @( 'Core', 'Detection', 'Analysis', 'Response', 'Enterprise', 'Hardening', 'WindowsDefender', 'ThreatHunting', 'Compliance', 'ActiveDirectory', 'Vulnerability', 'Forensics', 'CloudSecurity', 'Reporting' ) # One exported function is expected per .ps1 file in these folders $script:FunctionFiles = @( foreach ($folder in $script:CategoryFolders) { $folderPath = Join-Path -Path $script:ModuleRoot -ChildPath $folder if (Test-Path -Path $folderPath) { Get-ChildItem -Path $folderPath -Filter '*.ps1' -File } } ) # Structural checks (parse / Invoke-Expression) cover every .ps1 file in the repo $script:AllScriptFiles = @( Get-ChildItem -Path $script:ModuleRoot -Filter '*.ps1' -Recurse -File | Where-Object { $_.FullName -notmatch '[\\/]\.git[\\/]' } ) } BeforeAll { $script:ModuleRoot = Split-Path -Parent $PSScriptRoot $script:ModuleName = 'WindowsSecurityAudit' $script:ModulePath = Join-Path -Path $script:ModuleRoot -ChildPath "$script:ModuleName.psm1" $script:CategoryFolders = @( 'Core', 'Detection', 'Analysis', 'Response', 'Enterprise', 'Hardening', 'WindowsDefender', 'ThreatHunting', 'Compliance', 'ActiveDirectory', 'Vulnerability', 'Forensics', 'CloudSecurity', 'Reporting' ) $script:FunctionFiles = @( foreach ($folder in $script:CategoryFolders) { $folderPath = Join-Path -Path $script:ModuleRoot -ChildPath $folder if (Test-Path -Path $folderPath) { Get-ChildItem -Path $folderPath -Filter '*.ps1' -File } } ) Import-Module -Name $script:ModulePath -Force -DisableNameChecking $script:ExportedFunctions = @(Get-Command -Module $script:ModuleName -CommandType Function) function Get-ScriptFileAst { param([Parameter(Mandatory)][string]$Path) $tokens = $null $parseErrors = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$parseErrors) [PSCustomObject]@{ Ast = $ast; Errors = @($parseErrors) } } } AfterAll { Remove-Module -Name $script:ModuleName -Force -ErrorAction SilentlyContinue } Describe 'Module import and exported functions' { It 'imports without throwing' { { Import-Module -Name $script:ModulePath -Force -DisableNameChecking -ErrorAction Stop } | Should -Not -Throw } It 'exports one function per .ps1 file in the category folders' { # Expected count is derived from the files on disk, never hardcoded $script:ExportedFunctions.Count | Should -Be $script:FunctionFiles.Count } It 'exports <Name>' -ForEach $script:FunctionFiles { @($script:ExportedFunctions).Name | Should -Contain $_.BaseName } } Describe 'Comment-based help' { It '<Name> has .SYNOPSIS and at least one .EXAMPLE' -ForEach $script:FunctionFiles { $help = Get-Help -Name $_.BaseName -Full # Without comment-based help Get-Help falls back to an auto-generated # syntax line ("<Name> [[-Param] ...]") as the synopsis $help.Synopsis | Should -Not -BeNullOrEmpty $help.Synopsis | Should -Not -Match ('^' + [regex]::Escape($_.BaseName) + '\s*\[') $examples = @($help.Examples.Example | Where-Object { $null -ne $_ }) $examples.Count | Should -BeGreaterOrEqual 1 } } Describe 'Script parsing' { It '<Name> parses without errors' -ForEach $script:AllScriptFiles { $result = Get-ScriptFileAst -Path $_.FullName $messages = @($result.Errors | ForEach-Object { $_.Message }) -join '; ' $result.Errors.Count | Should -Be 0 -Because $messages } } Describe 'Static safety checks' { It '<Name> does not assign to $PID or $Host' -ForEach $script:FunctionFiles { $result = Get-ScriptFileAst -Path $_.FullName $assignments = @($result.Ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.AssignmentStatementAst] }, $true)) $badAssignments = @($assignments | Where-Object { $left = $_.Left $left -is [System.Management.Automation.Language.VariableExpressionAst] -and $left.VariablePath.UserPath -in @('PID', 'Host') }) $badAssignments.Count | Should -Be 0 } It '<Name> does not invoke Invoke-Expression or IEX' -ForEach $script:AllScriptFiles { $result = Get-ScriptFileAst -Path $_.FullName $commands = @($result.Ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true)) # AST-based check: string literals mentioning IEX (e.g. detection # signatures) do not count, only actual command invocations $badCommands = @($commands | Where-Object { $commandName = $_.GetCommandName() $null -ne $commandName -and ($commandName -eq 'Invoke-Expression' -or $commandName -eq 'iex') }) $badCommands.Count | Should -Be 0 } } Describe 'Threat Intelligence and CVE Integration' { # Get-NVDCveData export and its .SYNOPSIS/.EXAMPLE help are already covered # dynamically by the data-driven Describes above (they iterate every .ps1 # file on disk), so only the gaps are tested here. # -ForEach data must exist at discovery time, so relative paths are used # here and resolved against $script:ModuleRoot (set in BeforeAll) at runtime Context 'API key handling' { It '<_> never interpolates an API key value into Write-Host or Write-Verbose output' -ForEach @( 'Vulnerability/Get-NVDCveData.ps1' 'ThreatHunting/Get-ThreatIntelligence.ps1' ) { $Path = Join-Path -Path $script:ModuleRoot -ChildPath $_ $result = Get-ScriptFileAst -Path $Path $loggingCommands = @($result.Ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true) | Where-Object { $commandName = $_.GetCommandName() $commandName -eq 'Write-Host' -or $commandName -eq 'Write-Verbose' }) # A direct "$variable" interpolation shows up as a VariableExpressionAst # in the string's NestedExpressions. Values referenced only inside a # $(...) subexpression (e.g. "$(if($ApiKey){'Provided'}else{...})") # are SubExpressionAst nodes and never emit the key itself $leaks = @() foreach ($command in $loggingCommands) { $strings = @($command.FindAll({ param($node) $node -is [System.Management.Automation.Language.StringConstantExpressionAst] -and $node.StringConstantType -eq [System.Management.Automation.Language.StringConstantType]::DoubleQuoted }, $true)) foreach ($string in $strings) { $leaks += @($string.NestedExpressions | Where-Object { $_ -is [System.Management.Automation.Language.VariableExpressionAst] -and $_.VariablePath.UserPath -match '(?i)(apikey|secret|token|^key$|api_key)' }) } } $leaks.Count | Should -Be 0 -Because 'API keys must only travel in request headers, never in log output' } It '<_> enforces TLS 1.2 before making network calls' -ForEach @( 'Vulnerability/Get-NVDCveData.ps1' 'ThreatHunting/Get-ThreatIntelligence.ps1' ) { $Path = Join-Path -Path $script:ModuleRoot -ChildPath $_ $content = Get-Content -Path $Path -Raw $content | Should -Match 'Tls12' } It '<_> contains no hardcoded API keys or secrets' -ForEach @( 'Vulnerability/Get-NVDCveData.ps1' 'ThreatHunting/Get-ThreatIntelligence.ps1' ) { $Path = Join-Path -Path $script:ModuleRoot -ChildPath $_ $result = Get-ScriptFileAst -Path $Path $assignments = @($result.Ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.AssignmentStatementAst] }, $true)) $hardcoded = @($assignments | Where-Object { $_.Left -is [System.Management.Automation.Language.VariableExpressionAst] -and $_.Left.VariablePath.UserPath -match '(?i)(apikey|secret|token|password|api_key)' -and $_.Right -is [System.Management.Automation.Language.StringConstantExpressionAst] -and -not [string]::IsNullOrWhiteSpace($_.Right.Value) }) $hardcoded.Count | Should -Be 0 -Because 'credentials must come from parameters, never from string literals' } } Context 'Comment-based help for new parameters' { It 'Get-ThreatIntelligence help documents the live feed parameters' { $help = Get-Help -Name 'Get-ThreatIntelligence' -Full $parameterNames = @($help.parameters.parameter | ForEach-Object { $_.Name }) foreach ($expected in 'UpdateFeeds', 'FeedCachePath', 'FeedMaxAgeHours', 'MaxFeedEntries', 'AbuseIPDBApiKey') { $parameterNames | Should -Contain $expected # Get-Help derives a bare parameter list from the param block even without # comment-based help, so assert the .PARAMETER text itself is present $helpParameter = @($help.parameters.parameter | Where-Object { $_.Name -eq $expected })[0] @($helpParameter.description | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace("$($_.Text)") }).Count | Should -BeGreaterThan 0 -Because 'the parameter must carry comment-based help text, not just exist in the param block' } } It 'Get-VulnerabilityAssessment help documents the NVD CVE parameters' { $help = Get-Help -Name 'Get-VulnerabilityAssessment' -Full $parameterNames = @($help.parameters.parameter | ForEach-Object { $_.Name }) foreach ($expected in 'CheckCVEs', 'NVDApiKey', 'MaxCVEChecks') { $parameterNames | Should -Contain $expected # Get-Help derives a bare parameter list from the param block even without # comment-based help, so assert the .PARAMETER text itself is present $helpParameter = @($help.parameters.parameter | Where-Object { $_.Name -eq $expected })[0] @($helpParameter.description | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace("$($_.Text)") }).Count | Should -BeGreaterThan 0 -Because 'the parameter must carry comment-based help text, not just exist in the param block' } } } Context 'Offline feed behavior' { BeforeAll { $script:ThreatIntelPath = Join-Path -Path $script:ModuleRoot -ChildPath 'ThreatHunting/Get-ThreatIntelligence.ps1' $script:ThreatIntelAst = (Get-ScriptFileAst -Path $script:ThreatIntelPath).Ast $script:FeedGate = @($script:ThreatIntelAst.FindAll({ param($node) $node -is [System.Management.Automation.Language.IfStatementAst] }, $true) | Where-Object { $conditionText = $_.Clauses[0].Item1.Extent.Text $conditionText -match '\$UpdateFeeds' -and $conditionText -match 'Test-Path\s+\$FeedCachePath' }) } It 'gates live feed processing on -UpdateFeeds or an existing -FeedCachePath' { $script:FeedGate.Count | Should -BeGreaterOrEqual 1 -Because 'feed processing must be skipped entirely when -UpdateFeeds is absent and the cache directory does not exist' } It 'keeps feed downloads inside that gate (no network calls when offline)' { $script:FeedGate.Count | Should -BeGreaterOrEqual 1 $downloads = @($script:ThreatIntelAst.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true) | Where-Object { $_.GetCommandName() -eq 'Invoke-WebRequest' }) $downloads.Count | Should -BeGreaterOrEqual 1 -Because 'the live feed feature is expected to download feed files' foreach ($download in $downloads) { $insideGate = @($script:FeedGate | Where-Object { $download.Extent.StartOffset -gt $_.Extent.StartOffset -and $download.Extent.EndOffset -lt $_.Extent.EndOffset }) $insideGate.Count | Should -BeGreaterOrEqual 1 -Because 'Invoke-WebRequest must only run when -UpdateFeeds is set or a feed cache exists' } } } } Describe 'v1.3 Features' { Context 'Comment-based help for new parameters' { It 'Get-VulnerabilityAssessment help documents ExactCVEMatch' { $help = Get-Help -Name 'Get-VulnerabilityAssessment' -Full $parameterNames = @($help.parameters.parameter | ForEach-Object { $_.Name }) $parameterNames | Should -Contain 'ExactCVEMatch' } It 'Get-ThreatIntelligence help documents the MISP/TAXII/STIX parameters' { $help = Get-Help -Name 'Get-ThreatIntelligence' -Full $parameterNames = @($help.parameters.parameter | ForEach-Object { $_.Name }) foreach ($expected in 'MISPUrl', 'MISPApiKey', 'TaxiiServerUrl', 'TaxiiCollection', 'StixFilePath') { $parameterNames | Should -Contain $expected # Get-Help derives a bare parameter list from the param block even without # comment-based help, so assert the .PARAMETER text itself is present $helpParameter = @($help.parameters.parameter | Where-Object { $_.Name -eq $expected })[0] @($helpParameter.description | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace("$($_.Text)") }).Count | Should -BeGreaterThan 0 -Because 'the parameter must carry comment-based help text, not just exist in the param block' } } It 'Resolve-CpeName help documents UseOnlineLookup and ApiKey' { $help = Get-Help -Name 'Resolve-CpeName' -Full $parameterNames = @($help.parameters.parameter | ForEach-Object { $_.Name }) foreach ($expected in 'UseOnlineLookup', 'ApiKey') { $parameterNames | Should -Contain $expected # Get-Help derives a bare parameter list from the param block even without # comment-based help, so assert the .PARAMETER text itself is present $helpParameter = @($help.parameters.parameter | Where-Object { $_.Name -eq $expected })[0] @($helpParameter.description | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace("$($_.Text)") }).Count | Should -BeGreaterThan 0 -Because 'the parameter must carry comment-based help text, not just exist in the param block' } } It 'Invoke-SecurityRemediation help documents the remediation parameters' { $help = Get-Help -Name 'Invoke-SecurityRemediation' -Full $parameterNames = @($help.parameters.parameter | ForEach-Object { $_.Name }) foreach ($expected in 'MaxSeverity', 'GenerateRollback', 'AssessmentPath', 'InputObject') { $parameterNames | Should -Contain $expected # Get-Help derives a bare parameter list from the param block even without # comment-based help, so assert the .PARAMETER text itself is present $helpParameter = @($help.parameters.parameter | Where-Object { $_.Name -eq $expected })[0] @($helpParameter.description | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace("$($_.Text)") }).Count | Should -BeGreaterThan 0 -Because 'the parameter must carry comment-based help text, not just exist in the param block' } } } Context 'Secret handling' { It '<_> never interpolates an API key value into Write-Host or Write-Verbose output' -ForEach @( 'Vulnerability/Resolve-CpeName.ps1' 'ThreatHunting/Get-ThreatIntelligence.ps1' ) { $Path = Join-Path -Path $script:ModuleRoot -ChildPath $_ $result = Get-ScriptFileAst -Path $Path $loggingCommands = @($result.Ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true) | Where-Object { $commandName = $_.GetCommandName() $commandName -eq 'Write-Host' -or $commandName -eq 'Write-Verbose' }) # A direct "$variable" interpolation shows up as a VariableExpressionAst # in the string's NestedExpressions. Values referenced only inside a # $(...) subexpression (e.g. "$(if($ApiKey){'Provided'}else{...})") # are SubExpressionAst nodes and never emit the key itself $leaks = @() foreach ($command in $loggingCommands) { $strings = @($command.FindAll({ param($node) $node -is [System.Management.Automation.Language.StringConstantExpressionAst] -and $node.StringConstantType -eq [System.Management.Automation.Language.StringConstantType]::DoubleQuoted }, $true)) foreach ($string in $strings) { $leaks += @($string.NestedExpressions | Where-Object { $_ -is [System.Management.Automation.Language.VariableExpressionAst] -and $_.VariablePath.UserPath -match '(?i)(apikey|secret|token|^key$|api_key)' }) } } $leaks.Count | Should -Be 0 -Because 'API keys must only travel in request headers, never in log output' } It 'Get-ThreatIntelligence types -TaxiiPassword as [SecureString]' { $Path = Join-Path -Path $script:ModuleRoot -ChildPath 'ThreatHunting/Get-ThreatIntelligence.ps1' $result = Get-ScriptFileAst -Path $Path $functionAst = $result.Ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Get-ThreatIntelligence' }, $true) $parameterAst = @($functionAst.Body.ParamBlock.Parameters | Where-Object { $_.Name.VariablePath.UserPath -eq 'TaxiiPassword' }) $parameterAst.Count | Should -Be 1 $parameterAst[0].StaticType.Name | Should -Be 'SecureString' -Because 'passwords must never be accepted as plain strings' } } Context 'Invoke-SecurityRemediation safety' { BeforeAll { $script:RemediationPath = Join-Path -Path $script:ModuleRoot -ChildPath 'Hardening/Invoke-SecurityRemediation.ps1' $script:RemediationContent = Get-Content -Path $script:RemediationPath -Raw $script:RemediationAst = (Get-ScriptFileAst -Path $script:RemediationPath).Ast } It 'declares SupportsShouldProcess with ConfirmImpact High in its CmdletBinding' { $functionAst = $script:RemediationAst.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Invoke-SecurityRemediation' }, $true) $cmdletBinding = @($functionAst.Body.ParamBlock.Attributes | Where-Object { $_.TypeName.Name -eq 'CmdletBinding' }) $cmdletBinding.Count | Should -Be 1 $namedArguments = $cmdletBinding[0].NamedArguments $shouldProcess = @($namedArguments | Where-Object { $_.ArgumentName -eq 'SupportsShouldProcess' }) $shouldProcess.Count | Should -Be 1 # A bare 'SupportsShouldProcess' has ExpressionOmitted = $true (defaults to $true) $shouldProcessEnabled = $shouldProcess[0].ExpressionOmitted -or $shouldProcess[0].Argument.SafeGetValue() -eq $true $shouldProcessEnabled | Should -BeTrue -Because 'remediation must support -WhatIf and -Confirm' $confirmImpact = @($namedArguments | Where-Object { $_.ArgumentName -eq 'ConfirmImpact' }) $confirmImpact.Count | Should -Be 1 $confirmImpact[0].Argument.SafeGetValue() | Should -Be 'High' } It 'routes state-changing calls through ShouldProcess' { $occurrences = [regex]::Matches($script:RemediationContent, [regex]::Escape('$PSCmdlet.ShouldProcess(')) $occurrences.Count | Should -BeGreaterOrEqual 2 -Because 'both remediation actions and rollback script writes must be gated behind ShouldProcess' } It 'routes unmatched findings to ManualReview' { $script:RemediationContent | Should -Match 'ManualReview' } } } Describe 'v1.4 Features' { Context 'Comment-based help for new parameters' { It 'ConvertTo-SiemEvent help documents Format and HostName' { $help = Get-Help -Name 'ConvertTo-SiemEvent' -Full $parameterNames = @($help.parameters.parameter | ForEach-Object { $_.Name }) foreach ($expected in 'Format', 'HostName') { $parameterNames | Should -Contain $expected # Get-Help derives a bare parameter list from the param block even without # comment-based help, so assert the .PARAMETER text itself is present $helpParameter = @($help.parameters.parameter | Where-Object { $_.Name -eq $expected })[0] @($helpParameter.description | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace("$($_.Text)") }).Count | Should -BeGreaterThan 0 -Because 'the parameter must carry comment-based help text, not just exist in the param block' } } It 'Get-CredentialAccessEvents help documents Hours and IncludeLSASSAccess' { $help = Get-Help -Name 'Get-CredentialAccessEvents' -Full $parameterNames = @($help.parameters.parameter | ForEach-Object { $_.Name }) foreach ($expected in 'Hours', 'IncludeLSASSAccess') { $parameterNames | Should -Contain $expected # Get-Help derives a bare parameter list from the param block even without # comment-based help, so assert the .PARAMETER text itself is present $helpParameter = @($help.parameters.parameter | Where-Object { $_.Name -eq $expected })[0] @($helpParameter.description | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace("$($_.Text)") }).Count | Should -BeGreaterThan 0 -Because 'the parameter must carry comment-based help text, not just exist in the param block' } } It 'Find-DefenseEvasion help documents Hours and IncludeLogGapAnalysis' { $help = Get-Help -Name 'Find-DefenseEvasion' -Full $parameterNames = @($help.parameters.parameter | ForEach-Object { $_.Name }) foreach ($expected in 'Hours', 'IncludeLogGapAnalysis') { $parameterNames | Should -Contain $expected # Get-Help derives a bare parameter list from the param block even without # comment-based help, so assert the .PARAMETER text itself is present $helpParameter = @($help.parameters.parameter | Where-Object { $_.Name -eq $expected })[0] @($helpParameter.description | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace("$($_.Text)") }).Count | Should -BeGreaterThan 0 -Because 'the parameter must carry comment-based help text, not just exist in the param block' } } It 'Get-UserActivityArtifacts help documents UserProfile and OutputPath' { $help = Get-Help -Name 'Get-UserActivityArtifacts' -Full $parameterNames = @($help.parameters.parameter | ForEach-Object { $_.Name }) foreach ($expected in 'UserProfile', 'OutputPath') { $parameterNames | Should -Contain $expected # Get-Help derives a bare parameter list from the param block even without # comment-based help, so assert the .PARAMETER text itself is present $helpParameter = @($help.parameters.parameter | Where-Object { $_.Name -eq $expected })[0] @($helpParameter.description | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace("$($_.Text)") }).Count | Should -BeGreaterThan 0 -Because 'the parameter must carry comment-based help text, not just exist in the param block' } } It 'Export-SecurityReport help documents NoAutoOpen' { $help = Get-Help -Name 'Export-SecurityReport' -Full $parameterNames = @($help.parameters.parameter | ForEach-Object { $_.Name }) $parameterNames | Should -Contain 'NoAutoOpen' } } Context 'Static safety checks' { # -ForEach data must exist at discovery time, so relative paths are used # here and resolved against $script:ModuleRoot (set in BeforeAll) at runtime It '<_> contains no hardcoded keys, secrets, tokens or passwords' -ForEach @( 'Detection/Get-CredentialAccessEvents.ps1' 'Detection/Find-DefenseEvasion.ps1' 'Response/ConvertTo-SiemEvent.ps1' 'Forensics/Get-UserActivityArtifacts.ps1' ) { $Path = Join-Path -Path $script:ModuleRoot -ChildPath $_ $result = Get-ScriptFileAst -Path $Path $assignments = @($result.Ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.AssignmentStatementAst] }, $true)) $hardcoded = @($assignments | Where-Object { $_.Left -is [System.Management.Automation.Language.VariableExpressionAst] -and $_.Left.VariablePath.UserPath -match '(?i)(key|secret|token|password)' -and $_.Right -is [System.Management.Automation.Language.StringConstantExpressionAst] -and -not [string]::IsNullOrWhiteSpace($_.Right.Value) }) $hardcoded.Count | Should -Be 0 -Because 'credentials must come from parameters, never from string literals' } It 'Find-DefenseEvasion attaches a Technique property to findings' { $Path = Join-Path -Path $script:ModuleRoot -ChildPath 'Detection/Find-DefenseEvasion.ps1' $content = Get-Content -Path $Path -Raw $content | Should -Match 'Technique' } It 'Get-CredentialAccessEvents labels the golden-ticket detection as a heuristic' { $Path = Join-Path -Path $script:ModuleRoot -ChildPath 'Detection/Get-CredentialAccessEvents.ps1' $content = Get-Content -Path $Path -Raw $content | Should -Match 'Heuristic' } It 'Get-UserActivityArtifacts never fabricates timestamps' { $Path = Join-Path -Path $script:ModuleRoot -ChildPath 'Forensics/Get-UserActivityArtifacts.ps1' $content = Get-Content -Path $Path -Raw $content.Contains('Timestamp = Get-Date') | Should -BeFalse -Because 'artifact timestamps must come from the evidence (FILETIME, file metadata), never from the scan time' } } } Describe 'v1.5 Features' { Context 'Comment-based help for new parameters' { It 'Send-SiemEvent help documents the streaming parameters' { $help = Get-Help -Name 'Send-SiemEvent' -Full $parameterNames = @($help.parameters.parameter | ForEach-Object { $_.Name }) foreach ($expected in 'Destination', 'Uri', 'BatchSize', 'MaxRetries') { $parameterNames | Should -Contain $expected # Get-Help derives a bare parameter list from the param block even without # comment-based help, so assert the .PARAMETER text itself is present $helpParameter = @($help.parameters.parameter | Where-Object { $_.Name -eq $expected })[0] @($helpParameter.description | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace("$($_.Text)") }).Count | Should -BeGreaterThan 0 -Because 'the parameter must carry comment-based help text, not just exist in the param block' } } It 'Get-SysmonAnalytics help documents Hours, EventTypes and MaxEvents' { $help = Get-Help -Name 'Get-SysmonAnalytics' -Full $parameterNames = @($help.parameters.parameter | ForEach-Object { $_.Name }) foreach ($expected in 'Hours', 'EventTypes', 'MaxEvents') { $parameterNames | Should -Contain $expected # Get-Help derives a bare parameter list from the param block even without # comment-based help, so assert the .PARAMETER text itself is present $helpParameter = @($help.parameters.parameter | Where-Object { $_.Name -eq $expected })[0] @($helpParameter.description | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace("$($_.Text)") }).Count | Should -BeGreaterThan 0 -Because 'the parameter must carry comment-based help text, not just exist in the param block' } } It 'Get-FileSystemJournal help documents Mode, DriveLetter and MaxRecords' { $help = Get-Help -Name 'Get-FileSystemJournal' -Full $parameterNames = @($help.parameters.parameter | ForEach-Object { $_.Name }) foreach ($expected in 'Mode', 'DriveLetter', 'MaxRecords') { $parameterNames | Should -Contain $expected # Get-Help derives a bare parameter list from the param block even without # comment-based help, so assert the .PARAMETER text itself is present $helpParameter = @($help.parameters.parameter | Where-Object { $_.Name -eq $expected })[0] @($helpParameter.description | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace("$($_.Text)") }).Count | Should -BeGreaterThan 0 -Because 'the parameter must carry comment-based help text, not just exist in the param block' } } It 'Invoke-EnterpriseSecurityScan help documents the remote deployment parameters' { $help = Get-Help -Name 'Invoke-EnterpriseSecurityScan' -Full $parameterNames = @($help.parameters.parameter | ForEach-Object { $_.Name }) foreach ($expected in 'UseModuleFunctions', 'RemoteFunctionSet', 'HostTimeoutSec', 'ProbeTimeoutSec', 'TargetFile') { $parameterNames | Should -Contain $expected # Get-Help derives a bare parameter list from the param block even without # comment-based help, so assert the .PARAMETER text itself is present $helpParameter = @($help.parameters.parameter | Where-Object { $_.Name -eq $expected })[0] @($helpParameter.description | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace("$($_.Text)") }).Count | Should -BeGreaterThan 0 -Because 'the parameter must carry comment-based help text, not just exist in the param block' } } It 'Register-ScheduledSecurityScan help documents the alert dedup parameters' { $help = Get-Help -Name 'Register-ScheduledSecurityScan' -Full $parameterNames = @($help.parameters.parameter | ForEach-Object { $_.Name }) foreach ($expected in 'RealertAfterDays', 'DisableAlertDedup') { $parameterNames | Should -Contain $expected # Get-Help derives a bare parameter list from the param block even without # comment-based help, so assert the .PARAMETER text itself is present $helpParameter = @($help.parameters.parameter | Where-Object { $_.Name -eq $expected })[0] @($helpParameter.description | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace("$($_.Text)") }).Count | Should -BeGreaterThan 0 -Because 'the parameter must carry comment-based help text, not just exist in the param block' } } It 'Export-SecurityReport help documents the SIEM streaming parameters' { $help = Get-Help -Name 'Export-SecurityReport' -Full $parameterNames = @($help.parameters.parameter | ForEach-Object { $_.Name }) foreach ($expected in 'StreamTo', 'StreamUri') { $parameterNames | Should -Contain $expected # Get-Help derives a bare parameter list from the param block even without # comment-based help, so assert the .PARAMETER text itself is present $helpParameter = @($help.parameters.parameter | Where-Object { $_.Name -eq $expected })[0] @($helpParameter.description | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace("$($_.Text)") }).Count | Should -BeGreaterThan 0 -Because 'the parameter must carry comment-based help text, not just exist in the param block' } } } Context 'Static safety checks' { # -ForEach data must exist at discovery time, so relative paths are used # here and resolved against $script:ModuleRoot (set in BeforeAll) at runtime It 'Send-SiemEvent never interpolates the token into Write-Host or Write-Verbose output' { $Path = Join-Path -Path $script:ModuleRoot -ChildPath 'Response/Send-SiemEvent.ps1' $result = Get-ScriptFileAst -Path $Path $loggingCommands = @($result.Ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true) | Where-Object { $commandName = $_.GetCommandName() $commandName -eq 'Write-Host' -or $commandName -eq 'Write-Verbose' }) # A direct "$Token" interpolation shows up as a VariableExpressionAst # in the string's NestedExpressions. The masked form # "$(ConvertTo-MaskedToken -Secret $Token)" is a SubExpressionAst node # and never emits the token itself $leaks = @() foreach ($command in $loggingCommands) { $strings = @($command.FindAll({ param($node) $node -is [System.Management.Automation.Language.StringConstantExpressionAst] -and $node.StringConstantType -eq [System.Management.Automation.Language.StringConstantType]::DoubleQuoted }, $true)) foreach ($string in $strings) { $leaks += @($string.NestedExpressions | Where-Object { $_ -is [System.Management.Automation.Language.VariableExpressionAst] -and $_.VariablePath.UserPath -match '(?i)(token|secret|sharedkey)' }) } # A bare variable argument (Write-Host $Token) contains no double-quoted # string at all, so it must be caught at the command-element level $leaks += @($command.CommandElements | Where-Object { $_ -is [System.Management.Automation.Language.VariableExpressionAst] -and $_.VariablePath.UserPath -match '(?i)(token|secret|sharedkey)' }) } $leaks.Count | Should -Be 0 -Because 'SIEM tokens must be masked before they reach any log output' } It '<_> contains no hardcoded keys, secrets, tokens or passwords' -ForEach @( 'Response/Send-SiemEvent.ps1' 'Detection/Get-SysmonAnalytics.ps1' 'Forensics/Get-FileSystemJournal.ps1' ) { $Path = Join-Path -Path $script:ModuleRoot -ChildPath $_ $result = Get-ScriptFileAst -Path $Path $assignments = @($result.Ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.AssignmentStatementAst] }, $true)) $hardcoded = @($assignments | Where-Object { $_.Left -is [System.Management.Automation.Language.VariableExpressionAst] -and $_.Left.VariablePath.UserPath -match '(?i)(key|secret|token|password)' -and $_.Right -is [System.Management.Automation.Language.StringConstantExpressionAst] -and -not [string]::IsNullOrWhiteSpace($_.Right.Value) }) $hardcoded.Count | Should -Be 0 -Because 'credentials must come from parameters, never from string literals' } It 'Get-SysmonAnalytics attaches a Technique property to findings' { $Path = Join-Path -Path $script:ModuleRoot -ChildPath 'Detection/Get-SysmonAnalytics.ps1' $content = Get-Content -Path $Path -Raw $content | Should -Match 'Technique' } It 'Get-FileSystemJournal labels timestomp detection as a heuristic' { $Path = Join-Path -Path $script:ModuleRoot -ChildPath 'Forensics/Get-FileSystemJournal.ps1' $content = Get-Content -Path $Path -Raw $content | Should -Match 'euristic' } It 'Register-ScheduledSecurityScan embeds the fingerprint state logic in the scan script' { $Path = Join-Path -Path $script:ModuleRoot -ChildPath 'Enterprise/Register-ScheduledSecurityScan.ps1' $content = Get-Content -Path $Path -Raw $content | Should -Match 'last-findings' } } } |