Enterprise/Invoke-EnterpriseSecurityScan.ps1
|
function Invoke-EnterpriseSecurityScan { <# .SYNOPSIS Performs security scans across multiple systems in an enterprise environment .DESCRIPTION Executes comprehensive security assessments on multiple remote computers simultaneously, collecting and aggregating results for enterprise-wide security analysis. Two execution modes are available: Built-in checks (default): a fixed set of native-cmdlet checks (Defender, firewall, UAC, updates, local admins, event log and disk health) runs on each target and a SecurityScore (0-100) is computed per system. Module function deployment (-UseModuleFunctions): the WindowsSecurityAudit module is packaged into a temporary zip archive, copied to each reachable target over a PowerShell session (Copy-Item -ToSession) into a remote temp directory, imported there, and the curated function bundle selected via -RemoteFunctionSet (Quick, Standard, or Deep) is executed with default parameters. The remote temp directory is removed again in a finally block on every path. Results are returned verbatim under Details.ModuleResults. The SecurityScore is only meaningful for the built-in checks; for module deployments any 'Findings' collections emitted by the module functions are rolled up heuristically (severity/category tagging) into the per-system Findings list - this roll-up is a heuristic convenience, not a detection guarantee. Remote targets require PowerShell 5.1+ with remoting enabled and Expand-Archive available. Fleet hardening: connectivity is probed in parallel through the runspace pool before any scan job is queued (-ProbeTimeoutSec bounds each probe), and every scan job is bounded by -HostTimeoutSec; jobs exceeding the limit are stopped, disposed, and reported with Status 'Timeout'. Per-host failures never abort the fleet run. .PARAMETER ComputerName Array of computer names or IP addresses to scan. Optional when -TargetFile is used .PARAMETER TargetFile Path to a CSV file containing a 'ComputerName' column with the targets to scan. Entries are combined with -ComputerName and de-duplicated .PARAMETER Credential PSCredential object for remote authentication. Never written to logs or output .PARAMETER ScanType Type of built-in scan: Quick, Standard, or Comprehensive .PARAMETER UseModuleFunctions Deploy the WindowsSecurityAudit module to each target and run the real module functions selected by -RemoteFunctionSet instead of the built-in native-cmdlet checks .PARAMETER RemoteFunctionSet Curated module function bundle to run remotely: Quick (Get-SecurityBaseline, Get-DefenderStatus), Standard (Quick + Find-SuspiciousProcesses, Find-PersistenceMechanisms, Get-SystemInfo), or Deep (Standard + Get-CredentialAccessEvents, Find-DefenseEvasion, Find-LateralMovement, Get-VulnerabilityAssessment). Default: Standard .PARAMETER ModulePath Explicit path to the WindowsSecurityAudit module root to package. When omitted, the root is resolved from the running module (or the parent of this script's folder) .PARAMETER ProbeTimeoutSec Maximum seconds to wait for each parallel connectivity probe (default: 3) .PARAMETER HostTimeoutSec Maximum seconds each per-host scan job may run before it is stopped, disposed, and marked with Status 'Timeout' (default: 300) .PARAMETER MaxThreads Maximum number of concurrent scans (1-512, default: 10) .PARAMETER OutputPath Path to save scan results .EXAMPLE Invoke-EnterpriseSecurityScan -ComputerName "SERVER01","SERVER02" -ScanType Standard Invoke-EnterpriseSecurityScan -ComputerName (Get-Content servers.txt) -Credential $cred -MaxThreads 20 .EXAMPLE Invoke-EnterpriseSecurityScan -TargetFile targets.csv -UseModuleFunctions -RemoteFunctionSet Deep -Credential $cred -HostTimeoutSec 600 Deploys the module to every host listed in targets.csv and runs the Deep function bundle .EXAMPLE Invoke-EnterpriseSecurityScan -ComputerName "SERVER01" -UseModuleFunctions -WhatIf Shows what would be packaged, deployed, and executed without changing anything .OUTPUTS PSCustomObject with enterprise scan results #> [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter()] [string[]]$ComputerName, [Parameter()] [string]$TargetFile, [Parameter()] [PSCredential]$Credential, [Parameter()] [ValidateSet('Quick', 'Standard', 'Comprehensive')] [string]$ScanType = 'Standard', [Parameter()] [switch]$UseModuleFunctions, [Parameter()] [ValidateSet('Quick', 'Standard', 'Deep')] [string]$RemoteFunctionSet = 'Standard', [Parameter()] [string]$ModulePath, [Parameter()] [ValidateRange(1, 3600)] [int]$ProbeTimeoutSec = 3, [Parameter()] [ValidateRange(1, 86400)] [int]$HostTimeoutSec = 300, [Parameter()] [ValidateRange(1, 512)] [int]$MaxThreads = 10, [Parameter()] [string]$OutputPath = "C:\EnterpriseScan_$(Get-Date -Format 'yyyyMMdd_HHmmss')" ) begin { $zipPath = $null $zipLeafName = $null $moduleRoot = $null $remoteFunctions = @() # Assemble the target list from -ComputerName and/or -TargetFile $targetComputers = @() if ($null -ne $ComputerName) { $targetComputers += $ComputerName } if (-not [string]::IsNullOrWhiteSpace($TargetFile)) { if (-not (Test-Path -Path $TargetFile -PathType Leaf)) { Write-Error "Target file not found: $TargetFile" throw "Target file not found: $TargetFile" } try { $targetRows = @(Import-Csv -Path $TargetFile -ErrorAction Stop) } catch { Write-Error "Could not parse target file '$TargetFile': $_" throw } if ($targetRows.Count -eq 0) { Write-Error "Target file '$TargetFile' contains no rows" throw "Target file '$TargetFile' contains no rows" } if ($null -eq $targetRows[0].PSObject.Properties['ComputerName']) { Write-Error "Target file '$TargetFile' must contain a 'ComputerName' column" throw "Target file '$TargetFile' must contain a 'ComputerName' column" } foreach ($row in $targetRows) { if (-not [string]::IsNullOrWhiteSpace($row.ComputerName)) { $targetComputers += $row.ComputerName.Trim() } } } $targetComputers = @($targetComputers | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique) if ($targetComputers.Count -eq 0) { Write-Error "No target computers specified. Provide -ComputerName and/or -TargetFile." throw "No target computers specified" } Write-Host "=== ENTERPRISE SECURITY SCAN ===" -ForegroundColor Cyan Write-Host "Target Systems: $($targetComputers.Count)" -ForegroundColor Yellow Write-Host "Scan Type: $ScanType" -ForegroundColor Yellow Write-Host "Start Time: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Yellow # Check if testing locally $isLocalTest = $targetComputers.Count -eq 1 -and ($targetComputers[0] -eq $env:COMPUTERNAME -or $targetComputers[0] -eq 'localhost' -or $targetComputers[0] -eq '127.0.0.1') if ($isLocalTest) { Write-Warning "Local testing detected. Ensure PSRemoting is enabled with 'Enable-PSRemoting -Force'" } # Create output directory if (-not (Test-Path $OutputPath)) { if ($PSCmdlet.ShouldProcess($OutputPath, 'Create output directory')) { New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null } } $results = [PSCustomObject]@{ ScanDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" ScanType = $ScanType TotalSystems = $targetComputers.Count SystemResults = @() Summary = @{ Successful = 0 Failed = 0 TimedOut = 0 CriticalFindings = 0 HighFindings = 0 MediumFindings = 0 LowFindings = 0 } FailedSystems = @() } # Curated module function bundles for -UseModuleFunctions $quickFunctions = @('Get-SecurityBaseline', 'Get-DefenderStatus') $standardFunctions = @($quickFunctions) + @('Find-SuspiciousProcesses', 'Find-PersistenceMechanisms', 'Get-SystemInfo') $deepFunctions = @($standardFunctions) + @('Get-CredentialAccessEvents', 'Find-DefenseEvasion', 'Find-LateralMovement', 'Get-VulnerabilityAssessment') switch ($RemoteFunctionSet) { 'Quick' { $remoteFunctions = $quickFunctions } 'Standard' { $remoteFunctions = $standardFunctions } 'Deep' { $remoteFunctions = $deepFunctions } } if ($UseModuleFunctions) { Write-Host "Mode: Module function deployment ($RemoteFunctionSet set)" -ForegroundColor Yellow Write-Host "Remote Functions: $($remoteFunctions -join ', ')" -ForegroundColor Yellow # Resolve the module root from -ModulePath, the running module, or this script's parent folder if (-not [string]::IsNullOrWhiteSpace($ModulePath)) { $moduleRoot = $ModulePath } else { $owningModule = $MyInvocation.MyCommand.Module if ($null -ne $owningModule -and -not [string]::IsNullOrEmpty($owningModule.ModuleBase)) { $moduleRoot = $owningModule.ModuleBase } elseif (-not [string]::IsNullOrEmpty($PSScriptRoot)) { $moduleRoot = Split-Path -Path $PSScriptRoot -Parent } } if ([string]::IsNullOrEmpty($moduleRoot) -or -not (Test-Path -Path $moduleRoot -PathType Container)) { Write-Error "Unable to locate the WindowsSecurityAudit module root. Provide -ModulePath." throw "Module root not found" } $moduleEntryPoints = @(Get-ChildItem -Path $moduleRoot -File -ErrorAction SilentlyContinue | Where-Object { $_.Extension -in @('.psd1', '.psm1') }) if ($moduleEntryPoints.Count -eq 0) { Write-Error "No .psd1 or .psm1 found under module root '$moduleRoot'" throw "No module entry point found under '$moduleRoot'" } # Package the module into a temporary zip archive for deployment $zipLeafName = "WindowsSecurityAudit_$([guid]::NewGuid().ToString('N')).zip" $candidateZipPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath $zipLeafName if ($PSCmdlet.ShouldProcess($moduleRoot, "Package module to temporary archive '$candidateZipPath' for deployment to $($targetComputers.Count) system(s)")) { try { $moduleContent = @(Get-ChildItem -Path $moduleRoot -Force -ErrorAction Stop | Where-Object { $_.Name -ne '.git' }) Compress-Archive -Path $moduleContent.FullName -DestinationPath $candidateZipPath -Force -ErrorAction Stop $zipPath = $candidateZipPath $zipSizeKB = [math]::Round((Get-Item -Path $zipPath).Length / 1KB, 1) Write-Host "Module packaged: $zipPath ($zipSizeKB KB)" -ForegroundColor Yellow } catch { Write-Error "Failed to package module from '$moduleRoot': $_" throw } } } # Script block for remote execution of the built-in native-cmdlet checks $scanScriptBlock = { param($ScanType) $result = [PSCustomObject]@{ ComputerName = $env:COMPUTERNAME ScanDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" Status = 'Success' SecurityScore = 0 Findings = @() Details = @{} } try { # Quick baseline check $baseline = Get-MpComputerStatus -ErrorAction SilentlyContinue if ($baseline) { $result.SecurityScore += if ($baseline.RealTimeProtectionEnabled) { 20 } else { 0 } $result.Details.WindowsDefender = $baseline.RealTimeProtectionEnabled if (-not $baseline.RealTimeProtectionEnabled) { $result.Findings += [PSCustomObject]@{ Severity = 'Critical' Category = 'Windows Defender' Finding = 'Real-time protection is disabled' } } } # Firewall check $firewall = Get-NetFirewallProfile -ErrorAction SilentlyContinue $firewallEnabled = @($firewall | Where-Object { $_.Enabled -eq $true }).Count $result.SecurityScore += ($firewallEnabled * 10) $result.Details.FirewallProfiles = $firewallEnabled if ($firewallEnabled -lt 3) { $result.Findings += [PSCustomObject]@{ Severity = 'High' Category = 'Firewall' Finding = "Only $firewallEnabled firewall profiles enabled" } } # UAC check $uacKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" $uacProps = Get-ItemProperty -Path $uacKey -ErrorAction SilentlyContinue $uacEnabled = if ($null -ne $uacProps -and $uacProps.PSObject.Properties['EnableLUA']) { $uacProps.EnableLUA } else { $null } $result.SecurityScore += if ($uacEnabled -eq 1) { 15 } else { 0 } $result.Details.UACEnabled = ($uacEnabled -eq 1) if ($uacEnabled -ne 1) { $result.Findings += [PSCustomObject]@{ Severity = 'High' Category = 'UAC' Finding = 'User Account Control is disabled' } } # Windows Update check try { $updateSession = New-Object -ComObject Microsoft.Update.Session $updateSearcher = $updateSession.CreateUpdateSearcher() $pendingUpdates = $updateSearcher.Search("IsInstalled=0").Updates.Count $result.Details.PendingUpdates = $pendingUpdates if ($pendingUpdates -eq 0) { $result.SecurityScore += 15 } else { $result.Findings += [PSCustomObject]@{ Severity = 'Medium' Category = 'Updates' Finding = "$pendingUpdates pending Windows updates" } } } catch { $result.Details.PendingUpdates = "Unable to check" } # Additional checks for Standard/Comprehensive scans if ($ScanType -in @('Standard', 'Comprehensive')) { # Check for admin accounts $adminAccounts = @() $adminLookupSucceeded = $false if (Get-Command Get-AdminGroupMembers -ErrorAction SilentlyContinue) { $adminAccounts = @(Get-AdminGroupMembers) $adminLookupSucceeded = ((Get-Variable -Name AdminGroupLookupSucceeded -Scope Script -ValueOnly -ErrorAction SilentlyContinue) -eq $true) } if ($adminLookupSucceeded) { $adminCount = $adminAccounts.Count $result.Details.AdminAccounts = $adminCount if ($adminCount -gt 3) { $result.Findings += [PSCustomObject]@{ Severity = 'Medium' Category = 'Accounts' Finding = "$adminCount administrator accounts (excessive)" } } } else { $result.Details.AdminAccounts = 'Unknown' } # Check for suspicious services $suspiciousServices = @(Get-Service | Where-Object { $_.StartType -eq 'Automatic' -and $_.Status -eq 'Running' -and $_.ServiceName -notmatch '^(Win|Dhcp|Dns|W3SVC|MSSQL|SQL)' }) if ($suspiciousServices.Count -gt 50) { $result.Findings += [PSCustomObject]@{ Severity = 'Low' Category = 'Services' Finding = "$($suspiciousServices.Count) non-standard services running" } } } # Comprehensive scan additions if ($ScanType -eq 'Comprehensive') { # Check event logs $recentErrors = @(Get-WinEvent -FilterHashtable @{ LogName = 'System' Level = 2 StartTime = (Get-Date).AddDays(-1) } -MaxEvents 100 -ErrorAction SilentlyContinue) $result.Details.RecentErrors = $recentErrors.Count # Check disk space $drives = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3" foreach ($drive in $drives) { if ($drive.Size -gt 0) { $freePercent = ($drive.FreeSpace / $drive.Size) * 100 if ($freePercent -lt 10) { $result.Findings += [PSCustomObject]@{ Severity = 'High' Category = 'Disk Space' Finding = "Drive $($drive.DeviceID) has less than 10% free space" } } } } } # Calculate final score $result.SecurityScore = [Math]::Min($result.SecurityScore, 100) } catch { $result.Status = 'Error' $result.Findings += [PSCustomObject]@{ Severity = 'Critical' Category = 'Scan Error' Finding = $_.Exception.Message } } return $result } # Worker for one host: deploy the module over a PS session, run the selected module # functions remotely, and remove the remote temp directory on every path $moduleWorker = { param($Computer, $Cred, [bool]$HasCredential, $ZipPath, $ZipFileName, [string[]]$FunctionNames) $result = [PSCustomObject]@{ ComputerName = $Computer ScanDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" Status = 'Success' SecurityScore = 0 Findings = @() Details = @{} } $session = $null $remoteTempDir = $null try { $sessionParams = @{ ComputerName = $Computer; ErrorAction = 'Stop' } if ($HasCredential) { $sessionParams['Credential'] = $Cred } $session = New-PSSession @sessionParams $remoteTempDir = Invoke-Command -Session $session -ErrorAction Stop -ScriptBlock { $dir = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ("WSA_Scan_" + [guid]::NewGuid().ToString('N')) New-Item -Path $dir -ItemType Directory -Force | Out-Null $dir } # Never feed a host-supplied path straight into Copy-Item or Remove-Item: # only accept the exact temp shape this script asked the host to create if ($remoteTempDir -isnot [string] -or $remoteTempDir -notmatch 'WSA_Scan_[0-9a-fA-F]{32}$') { $remoteTempDir = $null throw "Remote host returned an unexpected temp path; aborting deployment before any copy or cleanup runs." } Copy-Item -Path $ZipPath -Destination $remoteTempDir -ToSession $session -Force -ErrorAction Stop $remoteResult = Invoke-Command -Session $session -ErrorAction Stop -ScriptBlock { param($TempDir, $ZipFileName, $FunctionNames) $runOutput = [PSCustomObject]@{ Results = [ordered]@{}; Errors = @() } try { $zipFile = Join-Path -Path $TempDir -ChildPath $ZipFileName $extractDir = Join-Path -Path $TempDir -ChildPath 'Module' Expand-Archive -Path $zipFile -DestinationPath $extractDir -Force -ErrorAction Stop $manifest = @(Get-ChildItem -Path $extractDir -Filter '*.psd1' -Recurse -File | Select-Object -First 1) $rootModule = @(Get-ChildItem -Path $extractDir -Filter '*.psm1' -Recurse -File | Select-Object -First 1) $importTarget = $null if ($manifest.Count -gt 0) { $importTarget = $manifest[0].FullName } elseif ($rootModule.Count -gt 0) { $importTarget = $rootModule[0].FullName } if ($null -eq $importTarget) { throw "No .psd1 or .psm1 found in the deployed module archive" } Import-Module $importTarget -Force -ErrorAction Stop foreach ($functionName in $FunctionNames) { try { $runOutput.Results[$functionName] = & $functionName } catch { $runOutput.Errors += ("{0}: {1}" -f $functionName, $_.Exception.Message) } } } finally { Remove-Item -Path $TempDir -Recurse -Force -ErrorAction SilentlyContinue } return $runOutput } -ArgumentList $remoteTempDir, $ZipFileName, $FunctionNames $moduleResults = $null $functionErrors = @() if ($null -ne $remoteResult) { if ($null -ne $remoteResult.PSObject.Properties['Results']) { $moduleResults = $remoteResult.Results } if ($null -ne $remoteResult.PSObject.Properties['Errors'] -and $null -ne $remoteResult.Errors) { $functionErrors = @($remoteResult.Errors) } } $result.Details.ModuleDeployed = $true $result.Details.RemoteFunctionSet = ($FunctionNames -join ', ') $result.Details.ModuleResults = $moduleResults $result.Details.FunctionErrors = $functionErrors $result.Details.SecurityScoreNote = 'SecurityScore applies only to the built-in native-cmdlet scan; module function output is returned verbatim under Details.ModuleResults' # Heuristic roll-up: merge any Findings collections emitted by the module # functions and tag them with the source function name if ($null -ne $moduleResults) { foreach ($functionName in $FunctionNames) { $functionResult = $null if ($moduleResults -is [System.Collections.IDictionary]) { $functionResult = $moduleResults[$functionName] } elseif ($null -ne $moduleResults.PSObject.Properties[$functionName]) { $functionResult = $moduleResults.PSObject.Properties[$functionName].Value } if ($null -eq $functionResult) { continue } $findingsProperty = $functionResult.PSObject.Properties['Findings'] if ($null -eq $findingsProperty -or $null -eq $findingsProperty.Value) { continue } foreach ($finding in @($findingsProperty.Value)) { if ($null -eq $finding) { continue } $severityValue = 'Informational' if ($null -ne $finding.PSObject.Properties['Severity'] -and -not [string]::IsNullOrEmpty("$($finding.Severity)")) { $severityValue = "$($finding.Severity)" } $categoryValue = $functionName if ($null -ne $finding.PSObject.Properties['Category'] -and -not [string]::IsNullOrEmpty("$($finding.Category)")) { $categoryValue = "$($finding.Category)" } $findingText = ($finding | Out-String).Trim() if ($null -ne $finding.PSObject.Properties['Finding'] -and -not [string]::IsNullOrEmpty("$($finding.Finding)")) { $findingText = "$($finding.Finding)" } $result.Findings += [PSCustomObject]@{ Severity = $severityValue Category = $categoryValue Finding = $findingText } } } } } catch { $result.Status = 'Failed' $result.Details.Error = $_.Exception.Message $result.Findings += [PSCustomObject]@{ Severity = 'Critical' Category = 'Connection' Finding = $_.Exception.Message } } finally { if ($null -ne $session) { if (-not [string]::IsNullOrEmpty($remoteTempDir)) { try { $null = Invoke-Command -Session $session -ErrorAction SilentlyContinue -ScriptBlock { param($Dir) Remove-Item -Path $Dir -Recurse -Force -ErrorAction SilentlyContinue } -ArgumentList $remoteTempDir } catch { Write-Verbose "Remote cleanup invocation failed for ${Computer}: $_" } } Remove-PSSession $session } } return $result } } process { $runspacePool = $null $jobs = @() $probeJobs = @() try { Write-Host "`nStarting parallel scan of $($targetComputers.Count) systems..." -ForegroundColor Cyan # Propagate caller-defined function overrides (for example test doubles) for the # remoting cmdlets into the pool runspaces; in normal operation these commands are # cmdlets and nothing is propagated $shadowedCommands = @() foreach ($commandName in @('Test-Connection', 'New-PSSession', 'Copy-Item', 'Invoke-Command', 'Remove-PSSession')) { $functionCommand = @(Get-Command -Name $commandName -CommandType Function -ErrorAction SilentlyContinue | Select-Object -First 1) if ($functionCommand.Count -gt 0) { $shadowedCommands += $functionCommand[0] } } if ($shadowedCommands.Count -gt 0) { $initialState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() foreach ($functionCommand in $shadowedCommands) { $initialState.Commands.Add((New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry($functionCommand.Name, $functionCommand.ScriptBlock))) } $runspacePool = [runspacefactory]::CreateRunspacePool(1, $MaxThreads, $initialState, $Host) } else { $runspacePool = [runspacefactory]::CreateRunspacePool(1, $MaxThreads) } $runspacePool.Open() # Phase 1: parallel connectivity probe for every target before any scan job is queued $reachableHosts = @() $probeApproved = $true if ($UseModuleFunctions) { $probeApproved = $PSCmdlet.ShouldProcess(($targetComputers -join ', '), "Probe connectivity with ${ProbeTimeoutSec}s per-host timeout") } if ($probeApproved) { Write-Host "Probing connectivity ($ProbeTimeoutSec s per-host timeout)..." -ForegroundColor Yellow foreach ($computer in $targetComputers) { Write-Verbose "Queuing connectivity probe for: $computer" $probe = [powershell]::Create() $probe.RunspacePool = $runspacePool $probe.AddScript({ param($Computer) Test-Connection -ComputerName $Computer -Count 1 -Quiet -ErrorAction SilentlyContinue }).AddArgument($computer) | Out-Null $probeJobs += [PSCustomObject]@{ Computer = $computer Pipeline = $probe Status = $probe.BeginInvoke() StartTime = Get-Date } } $pendingProbes = @($probeJobs) while ($pendingProbes.Count -gt 0) { $stillPending = @() foreach ($probeJob in $pendingProbes) { if ($probeJob.Status.IsCompleted) { $isReachable = $false try { $isReachable = @($probeJob.Pipeline.EndInvoke($probeJob.Status))[0] } catch { Write-Verbose "Probe failed for $($probeJob.Computer): $_" $isReachable = $false } $probeJob.Pipeline.Dispose() $probeJob.Pipeline = $null if ($true -eq $isReachable) { $reachableHosts += $probeJob.Computer } else { Write-Warning "Cannot reach $($probeJob.Computer) - skipping" $results.FailedSystems += [PSCustomObject]@{ ComputerName = $probeJob.Computer Reason = 'Unreachable' } $results.Summary.Failed++ } } elseif (((Get-Date) - $probeJob.StartTime).TotalSeconds -gt $ProbeTimeoutSec) { try { $probeJob.Pipeline.Stop() } catch { Write-Verbose "Probe stop failed for $($probeJob.Computer): $_" } $probeJob.Pipeline.Dispose() $probeJob.Pipeline = $null Write-Warning "Connectivity probe timed out for $($probeJob.Computer) - skipping" $results.FailedSystems += [PSCustomObject]@{ ComputerName = $probeJob.Computer Reason = "Unreachable (probe timeout after $ProbeTimeoutSec s)" } $results.Summary.Failed++ } else { $stillPending += $probeJob } } if ($stillPending.Count -gt 0) { Start-Sleep -Milliseconds 100 } $pendingProbes = $stillPending } } else { $reachableHosts = $targetComputers } # Phase 2: queue one scan job per reachable host foreach ($computer in $reachableHosts) { if ($UseModuleFunctions) { if (-not $PSCmdlet.ShouldProcess($computer, "Deploy WindowsSecurityAudit module and run '$RemoteFunctionSet' function set ($($remoteFunctions -join ', '))")) { $results.SystemResults += [PSCustomObject]@{ ComputerName = $computer ScanDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" Status = 'WhatIf' SecurityScore = 0 Findings = @() Details = @{ PlannedAction = "Deploy module archive and run '$RemoteFunctionSet' function set" Functions = ($remoteFunctions -join ', ') } } continue } Write-Verbose "Queuing module deployment scan for: $computer" $powershell = [powershell]::Create() $powershell.RunspacePool = $runspacePool $powershell.AddScript($moduleWorker). AddArgument($computer). AddArgument($Credential). AddArgument(($null -ne $Credential)). AddArgument($zipPath). AddArgument($zipLeafName). AddArgument([string[]]$remoteFunctions) | Out-Null $jobs += [PSCustomObject]@{ Computer = $computer Pipeline = $powershell Status = $powershell.BeginInvoke() StartTime = Get-Date } continue } Write-Verbose "Queuing scan for: $computer" # Create PowerShell instance $powershell = [powershell]::Create() $powershell.RunspacePool = $runspacePool # Add script and parameters if ($Credential) { $powershell.AddScript({ param($Computer, $Cred, $ScriptBlock, $ScanType) try { Invoke-Command -ComputerName $Computer -Credential $Cred -ScriptBlock $ScriptBlock -ArgumentList $ScanType -ErrorAction Stop } catch { [PSCustomObject]@{ ComputerName = $Computer Status = 'Failed' SecurityScore = 0 Findings = @([PSCustomObject]@{ Severity = 'Critical' Category = 'Connection' Finding = $_.Exception.Message }) Details = @{} } } }).AddArgument($computer).AddArgument($Credential).AddArgument($scanScriptBlock).AddArgument($ScanType) | Out-Null } else { $powershell.AddScript({ param($Computer, $ScriptBlock, $ScanType) try { Invoke-Command -ComputerName $Computer -ScriptBlock $ScriptBlock -ArgumentList $ScanType -ErrorAction Stop } catch { [PSCustomObject]@{ ComputerName = $Computer Status = 'Failed' SecurityScore = 0 Findings = @([PSCustomObject]@{ Severity = 'Critical' Category = 'Connection' Finding = $_.Exception.Message }) Details = @{} } } }).AddArgument($computer).AddArgument($scanScriptBlock).AddArgument($ScanType) | Out-Null } # Start async execution $jobs += [PSCustomObject]@{ Computer = $computer Pipeline = $powershell Status = $powershell.BeginInvoke() StartTime = Get-Date } } # Collect results as jobs complete, enforcing the per-host execution timeout Write-Host "Scanning in progress..." -ForegroundColor Yellow $completed = 0 $totalJobs = @($jobs).Count $pendingJobs = @($jobs) while ($pendingJobs.Count -gt 0) { $stillPending = @() foreach ($job in $pendingJobs) { if ($job.Status.IsCompleted) { try { $result = @(@($job.Pipeline.EndInvoke($job.Status)) | Where-Object { $null -ne $_ } | Select-Object -Last 1) if ($result.Count -gt 0) { $result = $result[0] } else { $result = $null } if ($null -eq $result) { $results.Summary.Failed++ $results.FailedSystems += [PSCustomObject]@{ ComputerName = $job.Computer Reason = 'No result returned' } } else { $statusValue = 'Failed' if ($null -ne $result.PSObject.Properties['Status'] -and -not [string]::IsNullOrEmpty("$($result.Status)")) { $statusValue = "$($result.Status)" } if ($statusValue -eq 'Success') { $results.Summary.Successful++ # Count findings by severity $findingsProperty = $result.PSObject.Properties['Findings'] if ($null -ne $findingsProperty -and $null -ne $findingsProperty.Value) { foreach ($finding in @($findingsProperty.Value)) { if ($null -eq $finding -or $null -eq $finding.PSObject.Properties['Severity']) { continue } switch ("$($finding.Severity)") { 'Critical' { $results.Summary.CriticalFindings++ } 'High' { $results.Summary.HighFindings++ } 'Medium' { $results.Summary.MediumFindings++ } 'Low' { $results.Summary.LowFindings++ } } } } } else { $results.Summary.Failed++ $results.FailedSystems += [PSCustomObject]@{ ComputerName = $job.Computer Reason = 'Scan failed' } } $results.SystemResults += $result } } catch { Write-Warning "Error collecting results from $($job.Computer): $_" $results.Summary.Failed++ } finally { $job.Pipeline.Dispose() $job.Pipeline = $null } $completed++ } elseif (((Get-Date) - $job.StartTime).TotalSeconds -gt $HostTimeoutSec) { try { $job.Pipeline.Stop() } catch { Write-Verbose "Stop failed for $($job.Computer): $_" } $job.Pipeline.Dispose() $job.Pipeline = $null Write-Warning "Scan of $($job.Computer) timed out after $HostTimeoutSec seconds" $results.Summary.Failed++ $results.Summary.TimedOut++ $results.FailedSystems += [PSCustomObject]@{ ComputerName = $job.Computer Reason = "Timeout after $HostTimeoutSec seconds" } $results.SystemResults += [PSCustomObject]@{ ComputerName = $job.Computer ScanDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" Status = 'Timeout' SecurityScore = 0 Findings = @() Details = @{ Error = "Host execution exceeded the $HostTimeoutSec second limit" } } $completed++ } else { $stillPending += $job } } if ($totalJobs -gt 0) { Write-Progress -Activity "Enterprise Security Scan" -Status "Completed: $completed / $totalJobs" -PercentComplete (($completed / $totalJobs) * 100) } if ($stillPending.Count -gt 0) { Start-Sleep -Milliseconds 500 } $pendingJobs = $stillPending } Write-Progress -Activity "Enterprise Security Scan" -Completed # Runspace pool is closed/disposed in the finally block below } catch { Write-Error "Error during enterprise scan: $_" throw } finally { # Guarantee cleanup of async pipelines and the runspace pool, even on failure foreach ($job in @($jobs) + @($probeJobs)) { if ($null -ne $job.Pipeline) { $job.Pipeline.Dispose() } } if ($null -ne $runspacePool) { if ($runspacePool.RunspacePoolStateInfo.State -eq 'Opened') { $runspacePool.Close() } $runspacePool.Dispose() } if (-not [string]::IsNullOrEmpty($zipPath) -and (Test-Path -Path $zipPath -PathType Leaf)) { Remove-Item -Path $zipPath -Force -ErrorAction SilentlyContinue } } } end { # Generate summary report Write-Host "`n=== SCAN COMPLETE ===" -ForegroundColor Green Write-Host "Total Systems: $($results.TotalSystems)" -ForegroundColor Cyan Write-Host "Successful: $($results.Summary.Successful)" -ForegroundColor Green Write-Host "Failed: $($results.Summary.Failed)" -ForegroundColor Red if ($results.Summary.TimedOut -gt 0) { Write-Host "Timed Out: $($results.Summary.TimedOut)" -ForegroundColor Red } Write-Host "`nFindings Summary:" -ForegroundColor Cyan Write-Host " Critical: $($results.Summary.CriticalFindings)" -ForegroundColor Red Write-Host " High: $($results.Summary.HighFindings)" -ForegroundColor Yellow Write-Host " Medium: $($results.Summary.MediumFindings)" -ForegroundColor Yellow Write-Host " Low: $($results.Summary.LowFindings)" -ForegroundColor White # Save results if ($PSCmdlet.ShouldProcess($OutputPath, 'Save enterprise scan results (JSON/CSV)')) { $results | ConvertTo-Json -Depth 10 | Out-File (Join-Path $OutputPath "EnterpriseScan_Results.json") -Encoding UTF8 # Create CSV summary $results.SystemResults | Select-Object ComputerName, Status, SecurityScore, @{N='FindingsCount';E={$_.Findings.Count}} | Export-Csv (Join-Path $OutputPath "EnterpriseScan_Summary.csv") -NoTypeInformation # Export detailed findings $allFindings = @($results.SystemResults | ForEach-Object { $computer = $_.ComputerName $_.Findings | Select-Object @{N='Computer';E={$computer}}, Severity, Category, Finding }) if ($allFindings.Count -gt 0) { $allFindings | Export-Csv (Join-Path $OutputPath "EnterpriseScan_Findings.csv") -NoTypeInformation } Write-Host "`nResults saved to: $OutputPath" -ForegroundColor Cyan } return $results } } |