Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions lib/windows/Common.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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 '' }
Expand Down
43 changes: 36 additions & 7 deletions scripts/windows/endpoint/Invoke-WinTriage.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.'))
Expand All @@ -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
15 changes: 13 additions & 2 deletions scripts/windows/endpoint/Test-WinServiceAnomaly.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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

23 changes: 22 additions & 1 deletion scripts/windows/forensic/New-WinEventTimeline.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 22 additions & 1 deletion scripts/windows/forensic/Test-WinLogTampering.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 18 additions & 1 deletion scripts/windows/hardening/Test-WinDefenderStatus.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 27 additions & 2 deletions scripts/windows/hardening/Test-WinPrivilegeSurface.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading