diff --git a/lib/windows/Common.ps1 b/lib/windows/Common.ps1 index 0af5da7..05d1719 100644 --- a/lib/windows/Common.ps1 +++ b/lib/windows/Common.ps1 @@ -70,6 +70,125 @@ function Save-OpsForgeSummary { Set-Content -Encoding UTF8 -Path (Join-Path $OutputDirectory 'summary.txt') -Value $lines } +function Save-OpsForgeReport { + param( + [Parameter(Mandatory = $true)][string]$OutputDirectory, + [Parameter(Mandatory = $true)][string]$Title, + [AllowEmptyCollection()][object[]]$Findings = @(), + [object]$Stats = $null, + [object[]]$EvidenceFiles = @(), + [object[]]$Limitations = @(), + [object[]]$NextSteps = @(), + [string]$CollectionMode = 'read-only' + ) + + $findingList = @($Findings) + $statMap = @{} + if ($Stats -is [hashtable]) { + $statMap = $Stats + } + $severityOrder = @('critical','high','medium','low','info') + $severityRank = @{ + critical = 0 + high = 1 + medium = 2 + low = 3 + info = 4 + } + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $lines = @() + + $lines += "# $Title" + $lines += '' + $lines += "- Host: $(Get-OpsForgeHostName)" + $lines += "- Generated: $timestamp" + $lines += "- Collection mode: $CollectionMode" + $lines += "- Output: $OutputDirectory" + $lines += '' + $lines += '## Finding Count' + $lines += '' + $lines += "- Total: $($findingList.Count)" + foreach ($severity in $severityOrder) { + $count = @($findingList | Where-Object { $_.severity -eq $severity }).Count + $lines += "- ${severity}: $count" + } + + if ($statMap.Count -gt 0) { + $lines += '' + $lines += '## Collected' + $lines += '' + foreach ($key in ($statMap.Keys | Sort-Object)) { + $lines += "- ${key}: $($statMap[$key])" + } + } + + $lines += '' + $lines += '## Top Findings' + $lines += '' + $rankedFindings = foreach ($finding in $findingList) { + $severity = [string]$finding.severity + $rank = 99 + if ($severity -and $severityRank.ContainsKey($severity)) { + $rank = $severityRank[$severity] + } + [pscustomobject]@{ + Rank = $rank + Severity = $severity + Title = [string]$finding.title + Evidence = [string]$finding.evidence + } + } + $topFindings = @($rankedFindings | Sort-Object Rank, Title | Select-Object -First 10) + if (@($topFindings).Count -eq 0) { + $lines += 'No findings recorded.' + } else { + foreach ($finding in $topFindings) { + $severity = ([string]$finding.Severity).ToUpperInvariant() + if (-not $severity) { $severity = 'INFO' } + $lines += "- [$severity] $($finding.Title) - $($finding.Evidence)" + } + } + + $lines += '' + $lines += '## Evidence Files' + $lines += '' + if (@($EvidenceFiles).Count -eq 0) { + $lines += '- raw\' + $lines += '- findings.json' + $lines += '- summary.txt' + } else { + foreach ($file in $EvidenceFiles) { + $lines += "- $file" + } + } + + $lines += '' + $lines += '## Collection Limitations' + $lines += '' + if (@($Limitations).Count -eq 0) { + $lines += 'No explicit limitations recorded. Some data can still be partial without admin rights.' + } else { + foreach ($limitation in $Limitations) { + $lines += "- $limitation" + } + } + + $lines += '' + $lines += '## Next Steps' + $lines += '' + if (@($NextSteps).Count -eq 0) { + $lines += '- Review high and critical findings first.' + $lines += '- Check raw evidence before making changes.' + $lines += '- Treat missing data as partial collection, not proof of absence.' + } else { + foreach ($step in $NextSteps) { + $lines += "- $step" + } + } + + Set-Content -Encoding UTF8 -Path (Join-Path $OutputDirectory 'report.md') -Value $lines +} + function Test-OpsForgeUserWritablePath { param([AllowNull()][string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return $false } @@ -81,6 +200,31 @@ function Get-OpsForgeSafeFileName { return ($Name -replace '[\\/:*?"<>| ]', '_') } +function ConvertTo-OpsForgeText { + param([AllowNull()][object]$Value) + + if ($null -eq $Value) { + return '' + } + + if ($Value -is [array]) { + return (@($Value) | ForEach-Object { [string]$_ }) -join '; ' + } + + return [string]$Value +} + +function Get-OpsForgeIdSeed { + param([AllowNull()][object]$Value) + + $text = ConvertTo-OpsForgeText $Value + $hash = [int64]$text.GetHashCode() + if ($hash -lt 0) { + $hash = -$hash + } + return $hash +} + function Get-OpsForgeTaskActionText { param([object]$Action) if ($null -eq $Action) { return '' } diff --git a/scripts/windows/endpoint/Invoke-WinTriage.ps1 b/scripts/windows/endpoint/Invoke-WinTriage.ps1 index 392a1bf..f285c08 100644 --- a/scripts/windows/endpoint/Invoke-WinTriage.ps1 +++ b/scripts/windows/endpoint/Invoke-WinTriage.ps1 @@ -50,7 +50,14 @@ $historyPaths = @( ) | Select-Object -Unique $historyPaths | Where-Object { Test-Path $_ } | Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'raw\powershell-history-paths.txt') -Get-Process | ForEach-Object { +$runningProcesses = Get-Process +$services = Get-CimInstance Win32_Service +$scheduledTasks = Get-ScheduledTask +$firewallProfiles = Get-NetFirewallProfile +$rdpListeners = Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | + Where-Object { $_.LocalPort -eq 3389 -and $_.LocalAddress -in '0.0.0.0','::' } + +$runningProcesses | ForEach-Object { $path = $null try { $path = $_.Path } catch { } if (Test-OpsForgeUserWritablePath $path) { @@ -63,13 +70,13 @@ Get-Process | ForEach-Object { } } -Get-CimInstance Win32_Service | ForEach-Object { +$services | ForEach-Object { if ($_.PathName -match '(?i)\\Users\\|\\AppData\\|\\Temp\\|\\Windows\\Temp\\|powershell.*(-enc|-encodedcommand)') { $findings.Add((New-OpsForgeFinding "WIN-TRIAGE-SERVICE-$([Math]::Abs($_.Name.GetHashCode()))" 'Service binary path is suspicious' 'high' 'endpoint' "$($_.Name) $($_.PathName)" 'Validate service creation source and binary signature.')) } } -Get-ScheduledTask | ForEach-Object { +$scheduledTasks | ForEach-Object { $action = ($_.Actions | ForEach-Object { Get-OpsForgeTaskActionText $_ }) -join '; ' if ($action -match '(?i)powershell.*(-enc|-encodedcommand)|\\AppData\\|\\Temp\\|\\Users\\Public\\') { $findings.Add((New-OpsForgeFinding "WIN-TRIAGE-TASK-$([Math]::Abs(($_.TaskPath + $_.TaskName).GetHashCode()))" 'Suspicious scheduled task action' 'high' 'endpoint' "$($_.TaskPath)$($_.TaskName): $action" 'Export task XML and verify task author, action, and trigger.')) @@ -83,16 +90,38 @@ try { } } catch { } -Get-NetFirewallProfile | Where-Object { -not $_.Enabled } | ForEach-Object { +$firewallProfiles | Where-Object { -not $_.Enabled } | ForEach-Object { $findings.Add((New-OpsForgeFinding "WIN-TRIAGE-FW-$($_.Name)" 'Windows firewall profile is disabled' 'high' 'network' "$($_.Name) profile disabled" 'Re-enable firewall profile or document compensating controls.')) } -Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { $_.LocalPort -eq 3389 -and $_.LocalAddress -in '0.0.0.0','::' } | ForEach-Object { +$rdpListeners | ForEach-Object { $findings.Add((New-OpsForgeFinding 'WIN-TRIAGE-RDP-EXPOSED' 'RDP listens on all interfaces' 'high' 'network' "$($_.LocalAddress):$($_.LocalPort)" 'Restrict RDP exposure with firewall policy and validate remote access requirements.')) } Save-OpsForgeFindings -Findings $findings.ToArray() -OutputDirectory $OutDir -$reportLines = @('# Windows Triage Collector', '', "- Host: $env:COMPUTERNAME", "- Findings: $($findings.Count)", '', 'Raw evidence is stored under `raw\`.') -Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'report.md') -Value $reportLines +Save-OpsForgeReport ` + -OutputDirectory $OutDir ` + -Title 'Windows Triage Collector' ` + -Findings $findings.ToArray() ` + -Stats @{ + Processes = @($runningProcesses).Count + Services = @($services).Count + ScheduledTasks = @($scheduledTasks).Count + } ` + -EvidenceFiles @( + 'raw\processes.json', + 'raw\services.json', + 'raw\scheduled-tasks.json', + 'raw\network-connections.json', + 'raw\firewall-profiles.json' + ) ` + -Limitations @( + 'Some process paths and signatures may be unavailable without admin rights.', + 'Event log and Defender data depend on local policy and installed components.' + ) ` + -NextSteps @( + 'Review suspicious process, service, task, Defender, firewall, and RDP findings.', + 'Use the raw JSON files to confirm command lines, paths, and owners.' + ) Save-OpsForgeSummary -OutputDirectory $OutDir -Title 'Windows triage collector' -FindingCount $findings.Count Write-OpsForgeInfo -Message "Output written to $OutDir" -Quiet:$Quiet diff --git a/scripts/windows/endpoint/Test-WinServiceAnomaly.ps1 b/scripts/windows/endpoint/Test-WinServiceAnomaly.ps1 index 51d4f2b..61b469f 100644 --- a/scripts/windows/endpoint/Test-WinServiceAnomaly.ps1 +++ b/scripts/windows/endpoint/Test-WinServiceAnomaly.ps1 @@ -36,7 +36,18 @@ foreach ($svc in $services) { } Save-OpsForgeFindings -Findings $findings.ToArray() -OutputDirectory $OutDir -@('# Windows Service Anomaly Auditor','',"Services collected: $(@($services).Count)","Findings: $($findings.Count)") | Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'report.md') +Save-OpsForgeReport ` + -OutputDirectory $OutDir ` + -Title 'Windows Service Anomaly Auditor' ` + -Findings $findings.ToArray() ` + -Stats @{ Services = @($services).Count } ` + -EvidenceFiles @('raw\services.json') ` + -Limitations @( + 'Service creation time and file ACL review are not always available from Win32_Service alone.' + ) ` + -NextSteps @( + 'Review high severity service paths first.', + 'Check binary signatures and directory permissions for flagged services.' + ) Save-OpsForgeSummary -OutputDirectory $OutDir -Title 'Windows service anomaly auditor' -FindingCount $findings.Count Write-OpsForgeInfo -Message "Output written to $OutDir" -Quiet:$Quiet - diff --git a/scripts/windows/forensic/New-WinEventTimeline.ps1 b/scripts/windows/forensic/New-WinEventTimeline.ps1 index d52bbf8..e96acf9 100644 --- a/scripts/windows/forensic/New-WinEventTimeline.ps1 +++ b/scripts/windows/forensic/New-WinEventTimeline.ps1 @@ -54,7 +54,28 @@ $ordered | ConvertTo-Json -Depth 5 | Set-Content -Encoding UTF8 -Path (Join-Path @('# Windows Event Timeline','','| timestamp | source | event_type | severity | summary |','|---|---|---:|---|---|') + ( $ordered | Select-Object -First 500 | ForEach-Object { "| $($_.timestamp) | $($_.source) | $($_.event_type) | $($_.severity) | $($_.summary -replace '\|','/') |" } ) | Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'timeline.md') -Copy-Item -Force -Path (Join-Path $OutDir 'timeline.md') -Destination (Join-Path $OutDir 'report.md') Save-OpsForgeFindings -Findings $findings.ToArray() -OutputDirectory $OutDir +Save-OpsForgeReport ` + -OutputDirectory $OutDir ` + -Title 'Windows Event Timeline Builder' ` + -Findings $findings.ToArray() ` + -Stats @{ + TimelineEvents = @($ordered).Count + LogsRequested = @($logs).Count + } ` + -EvidenceFiles @( + 'timeline.csv', + 'timeline.md', + 'raw\timeline.json', + 'raw\event-read-errors.txt' + ) ` + -Limitations @( + 'Some event logs may be missing, disabled, or unreadable without enough privilege.', + 'Process creation and script block events depend on audit policy being enabled.' + ) ` + -NextSteps @( + 'Review high severity account, service install, task creation, and log-clear events.', + 'Use timeline.csv for sorting and timeline.md for quick reading.' + ) Save-OpsForgeSummary -OutputDirectory $OutDir -Title 'Windows event timeline builder' -FindingCount $findings.Count Write-OpsForgeInfo -Message "Output written to $OutDir" -Quiet:$Quiet diff --git a/scripts/windows/forensic/Test-WinLogTampering.ps1 b/scripts/windows/forensic/Test-WinLogTampering.ps1 index 05c9714..1846a16 100644 --- a/scripts/windows/forensic/Test-WinLogTampering.ps1 +++ b/scripts/windows/forensic/Test-WinLogTampering.ps1 @@ -65,6 +65,27 @@ try { } catch { } Save-OpsForgeFindings -Findings $findings.ToArray() -OutputDirectory $OutDir -@('# Windows Log Tampering Detector','',"Lookback days: $LookbackDays","Findings: $($findings.Count)") | Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'report.md') +Save-OpsForgeReport ` + -OutputDirectory $OutDir ` + -Title 'Windows Log Tampering Detector' ` + -Findings $findings.ToArray() ` + -Stats @{ + LookbackDays = $LookbackDays + EventsCollected = @($events).Count + } ` + -EvidenceFiles @( + 'raw\tampering-events.json', + 'raw\audit-policy.txt', + 'raw\audit-policy-error.txt', + 'raw\security-services.json' + ) ` + -Limitations @( + 'Large event gaps need deeper review than this first-pass check.', + 'Audit policy and event log access can be restricted by local privilege.' + ) ` + -NextSteps @( + 'Review log clear, audit policy, Defender config, and service stop findings.', + 'Correlate timestamps with admin activity and endpoint telemetry.' + ) Save-OpsForgeSummary -OutputDirectory $OutDir -Title 'Windows log tampering detector' -FindingCount $findings.Count Write-OpsForgeInfo -Message "Output written to $OutDir" -Quiet:$Quiet diff --git a/scripts/windows/hardening/Test-WinDefenderStatus.ps1 b/scripts/windows/hardening/Test-WinDefenderStatus.ps1 index acb14e7..1388c5e 100644 --- a/scripts/windows/hardening/Test-WinDefenderStatus.ps1 +++ b/scripts/windows/hardening/Test-WinDefenderStatus.ps1 @@ -43,6 +43,23 @@ try { } Save-OpsForgeFindings -Findings $findings.ToArray() -OutputDirectory $OutDir -@('# Windows Defender Status Auditor','',"Findings: $($findings.Count)") | Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'report.md') +Save-OpsForgeReport ` + -OutputDirectory $OutDir ` + -Title 'Windows Defender Status Auditor' ` + -Findings $findings.ToArray() ` + -EvidenceFiles @( + 'raw\defender-status.json', + 'raw\defender-preferences.json', + 'raw\threat-history.json', + 'raw\defender-error.txt' + ) ` + -Limitations @( + 'Defender cmdlets may be unavailable when Defender is removed or managed differently.', + 'Some settings require admin rights or current Defender platform support.' + ) ` + -NextSteps @( + 'Review disabled protection, stale signatures, and user-writable exclusions first.', + 'Confirm whether any exclusion is expected before removing it.' + ) Save-OpsForgeSummary -OutputDirectory $OutDir -Title 'Windows Defender status auditor' -FindingCount $findings.Count Write-OpsForgeInfo -Message "Output written to $OutDir" -Quiet:$Quiet diff --git a/scripts/windows/hardening/Test-WinPrivilegeSurface.ps1 b/scripts/windows/hardening/Test-WinPrivilegeSurface.ps1 index 716f8d4..a04aa16 100644 --- a/scripts/windows/hardening/Test-WinPrivilegeSurface.ps1 +++ b/scripts/windows/hardening/Test-WinPrivilegeSurface.ps1 @@ -66,7 +66,32 @@ try { Get-Service WinRM,TermService -ErrorAction SilentlyContinue | ConvertTo-Json -Depth 4 | Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'raw\remote-services.json') Save-OpsForgeFindings -Findings $findings.ToArray() -OutputDirectory $OutDir -@('# Windows Local Privilege Surface Audit','',"Findings: $($findings.Count)",'Raw evidence is in `raw\`.') | Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'privilege-surface-report.md') -Copy-Item -Force -Path (Join-Path $OutDir 'privilege-surface-report.md') -Destination (Join-Path $OutDir 'report.md') +Save-OpsForgeReport ` + -OutputDirectory $OutDir ` + -Title 'Windows Local Privilege Surface Audit' ` + -Findings $findings.ToArray() ` + -Stats @{ + LocalAdministrators = @($admins).Count + Services = @($services).Count + ScheduledTasks = @($tasks).Count + } ` + -EvidenceFiles @( + 'raw\local-admins.json', + 'raw\rdp-users.json', + 'raw\backup-operators.json', + 'raw\services.json', + 'raw\scheduled-tasks.json', + 'raw\uac.json', + 'raw\remote-services.json' + ) ` + -Limitations @( + 'Group membership visibility can differ on domain-joined or policy-managed hosts.', + 'Privilege risk depends on local ACLs and domain policy that this script does not change.' + ) ` + -NextSteps @( + 'Review local admins, Backup Operators, RDP users, UAC state, and privileged task findings.', + 'Confirm privileged service and task paths before changing memberships or configs.' + ) +Copy-Item -Force -Path (Join-Path $OutDir 'report.md') -Destination (Join-Path $OutDir 'privilege-surface-report.md') Save-OpsForgeSummary -OutputDirectory $OutDir -Title 'Windows local privilege surface audit' -FindingCount $findings.Count Write-OpsForgeInfo -Message "Output written to $OutDir" -Quiet:$Quiet diff --git a/scripts/windows/network/Get-WinNetworkExposure.ps1 b/scripts/windows/network/Get-WinNetworkExposure.ps1 index ba506dc..cb17501 100644 --- a/scripts/windows/network/Get-WinNetworkExposure.ps1 +++ b/scripts/windows/network/Get-WinNetworkExposure.ps1 @@ -58,7 +58,28 @@ foreach ($record in $records) { } Save-OpsForgeFindings -Findings $findings.ToArray() -OutputDirectory $OutDir -$reportLines = @('# Windows Network Exposure Mapper', '', "- Host: $env:COMPUTERNAME", "- Listening sockets: $(@($records).Count)", "- Findings: $($findings.Count)", '', 'Raw data is stored under `raw\`.') -Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'report.md') -Value $reportLines +Save-OpsForgeReport ` + -OutputDirectory $OutDir ` + -Title 'Windows Network Exposure Mapper' ` + -Findings $findings.ToArray() ` + -Stats @{ + ListeningSockets = @($records).Count + TcpConnections = @($connections).Count + } ` + -EvidenceFiles @( + 'raw\listening-tcp.json', + 'raw\tcp-connections.json', + 'raw\dns-cache.json', + 'raw\net-adapters.json', + 'raw\routes.json' + ) ` + -Limitations @( + 'Process paths can be missing for protected processes without enough privilege.', + 'Firewall rule mapping is handled by the firewall auditor, not this mapper.' + ) ` + -NextSteps @( + 'Review admin ports listening on all interfaces.', + 'Check unknown process paths and user-writable listener binaries.' + ) Save-OpsForgeSummary -OutputDirectory $OutDir -Title 'Windows network exposure mapper' -FindingCount $findings.Count Write-OpsForgeInfo -Message "Output written to $OutDir" -Quiet:$Quiet diff --git a/scripts/windows/network/Test-WinFirewallExposure.ps1 b/scripts/windows/network/Test-WinFirewallExposure.ps1 index b862bbc..c300788 100644 --- a/scripts/windows/network/Test-WinFirewallExposure.ps1 +++ b/scripts/windows/network/Test-WinFirewallExposure.ps1 @@ -50,7 +50,26 @@ foreach ($rule in $filters) { Save-OpsForgeFindings -Findings $findings.ToArray() -OutputDirectory $OutDir Copy-Item -Force -Path (Join-Path $OutDir 'findings.json') -Destination (Join-Path $OutDir 'firewall-findings.json') -@('# Windows Firewall Exposure Auditor','',"Findings: $($findings.Count)",'Raw firewall data is in `raw\`.') | Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'firewall-report.md') -Copy-Item -Force -Path (Join-Path $OutDir 'firewall-report.md') -Destination (Join-Path $OutDir 'report.md') +Save-OpsForgeReport ` + -OutputDirectory $OutDir ` + -Title 'Windows Firewall Exposure Auditor' ` + -Findings $findings.ToArray() ` + -Stats @{ + FirewallProfiles = @($profiles).Count + InboundAllowRules = @($filters).Count + } ` + -EvidenceFiles @( + 'raw\firewall-profiles.json', + 'raw\inbound-allow-rules.json', + 'firewall-findings.json' + ) ` + -Limitations @( + 'This catches broad exposure patterns; it does not fully prove rule shadowing.' + ) ` + -NextSteps @( + 'Restrict broad inbound rules for RDP, SMB, WinRM, SSH, and Any/Any allows.', + 'Compare exposed ports with business need and source ranges.' + ) +Copy-Item -Force -Path (Join-Path $OutDir 'report.md') -Destination (Join-Path $OutDir 'firewall-report.md') Save-OpsForgeSummary -OutputDirectory $OutDir -Title 'Windows firewall exposure auditor' -FindingCount $findings.Count Write-OpsForgeInfo -Message "Output written to $OutDir" -Quiet:$Quiet diff --git a/scripts/windows/persistence/Find-WinPersistence.ps1 b/scripts/windows/persistence/Find-WinPersistence.ps1 index 535d2b4..4c2567f 100644 --- a/scripts/windows/persistence/Find-WinPersistence.ps1 +++ b/scripts/windows/persistence/Find-WinPersistence.ps1 @@ -18,16 +18,26 @@ $findings = New-Object System.Collections.Generic.List[object] $autoruns = New-Object System.Collections.Generic.List[object] function Add-CommandFinding { - param([string]$Source, [string]$Name, [string]$Command) - $seed = [Math]::Abs(("$Source-$Name-$Command").GetHashCode()) - if ($Command -match '(?i)\\AppData\\|\\Temp\\|\\Users\\Public\\|\\Windows\\Temp\\') { - $findings.Add((New-OpsForgeFinding "WIN-PERSIST-USERPATH-$seed" 'Persistence entry points to user-writable path' 'high' 'persistence' "$Source $Name $Command" 'Validate the referenced binary and remove unauthorized autorun entries.')) + param( + [AllowNull()][object]$Source, + [AllowNull()][object]$Name, + [AllowNull()][object]$Command + ) + + $sourceText = ConvertTo-OpsForgeText $Source + $nameText = ConvertTo-OpsForgeText $Name + $commandText = ConvertTo-OpsForgeText $Command + $evidence = "$sourceText $nameText $commandText".Trim() + $seed = Get-OpsForgeIdSeed $evidence + + if ($commandText -match '(?i)\\AppData\\|\\Temp\\|\\Users\\Public\\|\\Windows\\Temp\\') { + $findings.Add((New-OpsForgeFinding "WIN-PERSIST-USERPATH-$seed" 'Persistence entry points to user-writable path' 'high' 'persistence' $evidence 'Validate the referenced binary and remove unauthorized autorun entries.')) } - if ($Command -match '(?i)powershell.*(-enc|-encodedcommand)') { - $findings.Add((New-OpsForgeFinding "WIN-PERSIST-ENC-$seed" 'Persistence entry uses encoded PowerShell' 'high' 'persistence' "$Source $Name $Command" 'Decode the command, preserve evidence, and disable unauthorized persistence.')) + if ($commandText -match '(?i)powershell.*(-enc|-encodedcommand)') { + $findings.Add((New-OpsForgeFinding "WIN-PERSIST-ENC-$seed" 'Persistence entry uses encoded PowerShell' 'high' 'persistence' $evidence 'Decode the command, preserve evidence, and disable unauthorized persistence.')) } - if ($Command -match '(?i)(rundll32|regsvr32|mshta|wscript|cscript|certutil|bitsadmin|wmic)\.exe') { - $findings.Add((New-OpsForgeFinding "WIN-PERSIST-LOLBIN-$seed" 'Persistence entry uses a living-off-the-land binary' 'medium' 'persistence' "$Source $Name $Command" 'Confirm expected business use and inspect arguments for remote payload loading.')) + if ($commandText -match '(?i)(rundll32|regsvr32|mshta|wscript|cscript|certutil|bitsadmin|wmic)\.exe') { + $findings.Add((New-OpsForgeFinding "WIN-PERSIST-LOLBIN-$seed" 'Persistence entry uses a living-off-the-land binary' 'medium' 'persistence' $evidence 'Confirm expected business use and inspect arguments for remote payload loading.')) } } @@ -41,7 +51,7 @@ foreach ($key in $runKeys) { if (Test-Path $key) { $props = Get-ItemProperty -Path $key foreach ($prop in $props.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' }) { - $record = [pscustomobject]@{ Source = $key; Name = $prop.Name; Command = [string]$prop.Value } + $record = [pscustomobject]@{ Source = $key; Name = $prop.Name; Command = (ConvertTo-OpsForgeText $prop.Value) } $autoruns.Add($record) Add-CommandFinding -Source $record.Source -Name $record.Name -Command $record.Command } @@ -50,20 +60,23 @@ foreach ($key in $runKeys) { $services = Get-CimInstance Win32_Service foreach ($svc in $services) { - $autoruns.Add([pscustomobject]@{ Source = 'Service'; Name = $svc.Name; Command = $svc.PathName }) - if ($svc.PathName -match '(?i)\\AppData\\|\\Temp\\|\\Users\\Public\\|powershell.*(-enc|-encodedcommand)') { - Add-CommandFinding -Source 'Service' -Name $svc.Name -Command $svc.PathName - $findings.Add((New-OpsForgeFinding "WIN-PERSIST-SERVICE-$([Math]::Abs($svc.Name.GetHashCode()))" 'Service has suspicious persistence path or arguments' 'high' 'persistence' "$($svc.Name) $($svc.PathName)" 'Validate service creation time, binary signature, and owner.')) + $serviceName = ConvertTo-OpsForgeText $svc.Name + $servicePath = ConvertTo-OpsForgeText $svc.PathName + $autoruns.Add([pscustomobject]@{ Source = 'Service'; Name = $serviceName; Command = $servicePath }) + if ($servicePath -match '(?i)\\AppData\\|\\Temp\\|\\Users\\Public\\|powershell.*(-enc|-encodedcommand)') { + Add-CommandFinding -Source 'Service' -Name $serviceName -Command $servicePath + $findings.Add((New-OpsForgeFinding "WIN-PERSIST-SERVICE-$(Get-OpsForgeIdSeed $serviceName)" 'Service has suspicious persistence path or arguments' 'high' 'persistence' "$serviceName $servicePath" 'Validate service creation time, binary signature, and owner.')) } } Get-ScheduledTask | ForEach-Object { $action = ($_.Actions | ForEach-Object { Get-OpsForgeTaskActionText $_ }) -join '; ' - $autoruns.Add([pscustomobject]@{ Source = 'ScheduledTask'; Name = "$($_.TaskPath)$($_.TaskName)"; Command = $action }) + $taskName = "$(ConvertTo-OpsForgeText $_.TaskPath)$(ConvertTo-OpsForgeText $_.TaskName)" + $autoruns.Add([pscustomobject]@{ Source = 'ScheduledTask'; Name = $taskName; Command = $action }) if ($_.Settings.Hidden) { - $findings.Add((New-OpsForgeFinding "WIN-PERSIST-HIDDEN-TASK-$([Math]::Abs(($_.TaskPath + $_.TaskName).GetHashCode()))" 'Hidden scheduled task' 'medium' 'persistence' "$($_.TaskPath)$($_.TaskName)" 'Confirm task legitimacy and export XML for review.')) + $findings.Add((New-OpsForgeFinding "WIN-PERSIST-HIDDEN-TASK-$(Get-OpsForgeIdSeed $taskName)" 'Hidden scheduled task' 'medium' 'persistence' $taskName 'Confirm task legitimacy and export XML for review.')) } - Add-CommandFinding -Source 'ScheduledTask' -Name "$($_.TaskPath)$($_.TaskName)" -Command $action + Add-CommandFinding -Source 'ScheduledTask' -Name $taskName -Command $action } $startupFolders = @( @@ -109,21 +122,90 @@ foreach ($key in $specialKeys) { Get-ChildItem -Path $key -ErrorAction SilentlyContinue | ForEach-Object { $props = Get-ItemProperty -Path $_.PSPath -ErrorAction SilentlyContinue $props.PSObject.Properties | Where-Object { $_.Name -match 'Debugger|Shell|Userinit|AppInit_DLLs' } | ForEach-Object { - $autoruns.Add([pscustomobject]@{ Source = $_.Name; Name = $key; Command = [string]$_.Value }) - Add-CommandFinding -Source $_.Name -Name $key -Command ([string]$_.Value) + $value = ConvertTo-OpsForgeText $_.Value + $autoruns.Add([pscustomobject]@{ Source = $_.Name; Name = $key; Command = $value }) + Add-CommandFinding -Source $_.Name -Name $key -Command $value } } } } try { - Get-CimInstance -Namespace root\subscription -ClassName __EventConsumer | ConvertTo-Json -Depth 5 | Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'raw\wmi-event-consumers.json') + Get-CimInstance -Namespace root\subscription -ClassName __EventConsumer | + Select-Object Name, CreatorSID, CommandLineTemplate, ExecutablePath, ScriptingEngine, ScriptText | + ConvertTo-Json -Depth 3 | + Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'raw\wmi-event-consumers.json') } catch { "Unable to read WMI event consumers: $($_.Exception.Message)" | Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'raw\wmi-event-consumers.error.txt') } -$autoruns.ToArray() | ConvertTo-Json -Depth 5 | Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'raw\autoruns.json') -Save-OpsForgeFindings -Findings $findings.ToArray() -OutputDirectory $OutDir -@('# Windows Persistence Hunter','','Persistence records are stored in `raw\autoruns.json`.',"Findings: $($findings.Count)") | Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'report.md') +try { + $autoruns.ToArray() | + Select-Object Source, Name, Command | + ConvertTo-Json -Depth 3 | + Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'raw\autoruns.json') +} catch { + "Unable to serialize autoruns: $($_.Exception.Message)" | Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'raw\autoruns.error.txt') + @($autoruns.ToArray() | ForEach-Object { "$($_.Source)`t$($_.Name)`t$($_.Command)" }) | + Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'raw\autoruns.tsv') +} + +try { + Save-OpsForgeFindings -Findings $findings.ToArray() -OutputDirectory $OutDir +} catch { + $message = "Unable to write findings cleanly: $($_.Exception.Message)" + $message | Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'raw\findings-write-error.txt') + '[]' | Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'findings.json') + '[]' | Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'normalized\findings.json') +} + +try { + Save-OpsForgeReport ` + -OutputDirectory $OutDir ` + -Title 'Windows Persistence Hunter' ` + -Findings $findings.ToArray() ` + -Stats @{ + AutorunRecords = @($autoruns).Count + PowerShellProfiles = @($profiles).Count + } ` + -EvidenceFiles @( + 'raw\autoruns.json', + 'raw\wmi-event-consumers.json', + 'raw\wmi-event-consumers.error.txt' + ) ` + -Limitations @( + 'Registry, WMI, and scheduled task visibility can be partial without admin rights.', + 'PowerShell profile paths vary between Windows PowerShell and PowerShell 7.' + ) ` + -NextSteps @( + 'Start with AppData, Temp, encoded PowerShell, hidden task, and LOLBin findings.', + 'Export suspicious scheduled tasks and preserve referenced binaries before cleanup.' + ) +} catch { + $message = "Unable to write full report: $($_.Exception.Message)" + $message | Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'raw\report-write-error.txt') + @( + '# Windows Persistence Hunter', + '', + "- Host: $env:COMPUTERNAME", + "- Findings: $($findings.Count)", + "- Autorun records: $(@($autoruns).Count)", + '', + '## Evidence Files', + '', + '- raw\autoruns.json', + '- findings.json', + '', + '## Collection Limitations', + '', + "- $message", + '- Registry, WMI, and scheduled task visibility can be partial without admin rights.', + '', + '## Next Steps', + '', + '- Review findings.json and raw\autoruns.json.', + '- Preserve suspicious referenced files before cleanup.' + ) | Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'report.md') +} Save-OpsForgeSummary -OutputDirectory $OutDir -Title 'Windows persistence hunter' -FindingCount $findings.Count Write-OpsForgeInfo -Message "Output written to $OutDir" -Quiet:$Quiet diff --git a/scripts/windows/persistence/Test-WinScheduledTasks.ps1 b/scripts/windows/persistence/Test-WinScheduledTasks.ps1 index 3b11fe2..827ee80 100644 --- a/scripts/windows/persistence/Test-WinScheduledTasks.ps1 +++ b/scripts/windows/persistence/Test-WinScheduledTasks.ps1 @@ -69,7 +69,19 @@ foreach ($task in $tasks) { } Save-OpsForgeFindings -Findings $findings.ToArray() -OutputDirectory $OutDir -$reportLines = @('# Windows Scheduled Task Auditor', '', "- Host: $env:COMPUTERNAME", "- Tasks collected: $(@($tasks).Count)", "- Findings: $($findings.Count)", '', 'Raw task data: `raw\scheduled-tasks.json`') -Set-Content -Encoding UTF8 -Path (Join-Path $OutDir 'report.md') -Value $reportLines +Save-OpsForgeReport ` + -OutputDirectory $OutDir ` + -Title 'Windows Scheduled Task Auditor' ` + -Findings $findings.ToArray() ` + -Stats @{ ScheduledTasks = @($tasks).Count } ` + -EvidenceFiles @('raw\scheduled-tasks.json') ` + -Limitations @( + 'Some task metadata may be missing when task info cannot be read.', + 'Task creation time is not exposed cleanly by every scheduled task API.' + ) ` + -NextSteps @( + 'Review encoded PowerShell, user-writable paths, hidden tasks, and logon triggers.', + 'Export suspicious task XML before disabling anything.' + ) Save-OpsForgeSummary -OutputDirectory $OutDir -Title 'Windows scheduled task auditor' -FindingCount $findings.Count Write-OpsForgeInfo -Message "Output written to $OutDir" -Quiet:$Quiet