Skip to content
Merged
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
169 changes: 167 additions & 2 deletions lib/windows/Common.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,130 @@ function Save-OpsForgeSummary {
[string]$Title,
[int]$FindingCount
)
$lines = @($Title, "Output: $OutputDirectory", "Findings: $FindingCount")
Set-Content -Encoding UTF8 -Path (Join-Path $OutputDirectory 'summary.txt') -Value $lines
[string[]]$lines = @(
[string]$Title,
"Output: $OutputDirectory",
"Findings: $FindingCount"
)
Set-Content -Encoding UTF8 -LiteralPath (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')
$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 = 0
foreach ($finding in $findingList) {
$findingSeverity = Get-OpsForgeObjectField -InputObject $finding -Name 'severity'
if ($findingSeverity -eq $severity) {
$count++
}
}
$lines += "- ${severity}: $count"
}

if ($statMap.Count -gt 0) {
$lines += ''
$lines += '## Collected'
$lines += ''
foreach ($key in ($statMap.Keys | Sort-Object)) {
$lines += "- ${key}: $(ConvertTo-OpsForgeText $statMap[$key])"
}
}

$lines += ''
$lines += '## Top Findings'
$lines += ''
$topFindingLines = @()
foreach ($severity in $severityOrder) {
foreach ($finding in $findingList) {
if ($topFindingLines.Count -ge 10) {
break
}
$findingSeverity = Get-OpsForgeObjectField -InputObject $finding -Name 'severity'
if ($findingSeverity -ne $severity) {
continue
}
$title = Get-OpsForgeObjectField -InputObject $finding -Name 'title'
$evidence = Get-OpsForgeObjectField -InputObject $finding -Name 'evidence'
$topFindingLines += "- [$($severity.ToUpperInvariant())] $title - $evidence"
}
}
if ($topFindingLines.Count -eq 0) {
$lines += 'No findings recorded.'
} else {
foreach ($findingLine in $topFindingLines) {
$lines += $findingLine
}
}

$lines += ''
$lines += '## Evidence Files'
$lines += ''
if (@($EvidenceFiles).Count -eq 0) {
$lines += '- raw\'
$lines += '- findings.json'
$lines += '- summary.txt'
} else {
foreach ($file in $EvidenceFiles) {
$lines += "- $(ConvertTo-OpsForgeText $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 += "- $(ConvertTo-OpsForgeText $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 += "- $(ConvertTo-OpsForgeText $step)"
}
}

[string[]]$reportLines = @($lines | ForEach-Object { ConvertTo-OpsForgeText $_ })
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $OutputDirectory 'report.md') -Value $reportLines
}

function Test-OpsForgeUserWritablePath {
Expand All @@ -81,6 +203,49 @@ 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-OpsForgeObjectField {
param(
[AllowNull()][object]$InputObject,
[Parameter(Mandatory = $true)][string]$Name
)

if ($null -eq $InputObject) {
return ''
}

$property = $InputObject.PSObject.Properties[$Name]
if ($null -eq $property) {
return ''
}

return ConvertTo-OpsForgeText $property.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
79 changes: 77 additions & 2 deletions scripts/windows/forensic/Test-WinLogTampering.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,39 @@ function Add-EventFinding {
$findings.Add((New-OpsForgeFinding "$IdPrefix-$seed" $Title $Severity 'forensic' "$($Event.LogName) id=$($Event.Id) time=$($Event.TimeCreated) record=$($Event.RecordId)" 'Correlate with administrative activity, EDR telemetry, and change tickets.'))
}

function Save-LogTamperingFallbackReport {
param([string]$Message)

[string[]]$lines = @(
'# Windows Log Tampering Detector',
'',
"- Host: $env:COMPUTERNAME",
"- Findings: $([int]$findings.Count)",
"- Events collected: $([int]$events.Count)",
"- Lookback days: $LookbackDays",
'',
'## Evidence Files',
'',
'- raw\tampering-events.json',
'- raw\audit-policy.txt',
'- raw\audit-policy-error.txt',
'- raw\security-services.json',
'- findings.json',
'',
'## Collection Limitations',
'',
"- $Message",
'- Large event gaps need deeper review than this first-pass check.',
'- Audit policy and event log access can be restricted by local privilege.',
'',
'## Next Steps',
'',
'- Review log clear, audit policy, Defender config, and service stop findings.',
'- Correlate timestamps with admin activity and endpoint telemetry.'
)
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $OutDir 'report.md') -Value $lines
}

$events = New-Object System.Collections.Generic.List[object]
foreach ($query in @(
@{ LogName='Security'; Id=1102 },
Expand Down Expand Up @@ -65,6 +98,48 @@ 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-OpsForgeSummary -OutputDirectory $OutDir -Title 'Windows log tampering detector' -FindingCount $findings.Count
try {
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.'
)
} catch {
$message = "Unable to write full report: $($_.Exception.Message)"
[string[]]$details = @(
$message,
"Script stack: $($_.ScriptStackTrace)"
)
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $OutDir 'raw\report-write-error.txt') -Value $details
Save-LogTamperingFallbackReport -Message $message
}

try {
Save-OpsForgeSummary -OutputDirectory $OutDir -Title 'Windows log tampering detector' -FindingCount $findings.Count
} catch {
[string[]]$summary = @(
'Windows log tampering detector',
"Output: $OutDir",
"Findings: $([int]$findings.Count)",
"Summary writer failed: $($_.Exception.Message)"
)
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $OutDir 'summary.txt') -Value $summary
}
Write-OpsForgeInfo -Message "Output written to $OutDir" -Quiet:$Quiet
Loading
Loading