Private/Core.Output.ps1
|
function Write-OIBanner { [CmdletBinding()] param() # Draw a square box: the top/bottom borders and every content row are the # same width, so the right-hand '*' column always lines up regardless of the # text length. Content rows are centered within the inner width. $width = 66 $innerWidth = $width - 3 # leading ' *' prefix plus the trailing '*' $centerLine = { param([string]$Text) if ($Text.Length -ge $innerWidth) { return $Text.Substring(0, $innerWidth) } $pad = $innerWidth - $Text.Length $left = [int][Math]::Floor($pad / 2) (' ' * $left) + $Text + (' ' * ($pad - $left)) }.GetNewClosure() Write-Host ('/' + ('*' * ($width - 1))) -ForegroundColor DarkCyan Write-Host (' *' + (& $centerLine 'OffsetInspect') + '*') -ForegroundColor DarkCyan Write-Host (' *' + (& $centerLine 'Offset, Context, Diff & Threat Boundary Inspector') + '*') -ForegroundColor DarkCyan Write-Host (' *' + (& $centerLine 'DreadHost Research') + '*') -ForegroundColor DarkCyan Write-Host (' ' + ('*' * ($width - 2)) + '/') -ForegroundColor DarkCyan Write-Host '' # Read the running module's version so the banner never drifts from the # manifest. Fall back to a literal if the module context is unavailable # (e.g. a dot-sourced test harness), guarded for Set-StrictMode. $bannerVersion = '3.0.0' $bannerModule = $MyInvocation.MyCommand.Module if ($null -ne $bannerModule -and $null -ne $bannerModule.Version) { $bannerVersion = $bannerModule.Version.ToString() } Write-Host " Version: $bannerVersion" -ForegroundColor Cyan Write-Host ' Author: Jared Perry (Velkris)' -ForegroundColor Cyan Write-Host ' GitHub: https://github.com/warpedatom/OffsetInspect' -ForegroundColor Cyan Write-Host '' } function Export-OIProbeLog { <# Writes a threat-scan ProbeLog to a UTF-8 JSON file as a report-ready provider audit transcript. Always emits a valid JSON array (including for an empty log) and stays Windows PowerShell 5.1 safe by not relying on ConvertTo-Json -AsArray (which is PowerShell 7 only). #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [AllowNull()] [AllowEmptyCollection()] [object[]]$ProbeLog, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$Path ) $records = @(@($ProbeLog) | Where-Object { $null -ne $_ }) if ($records.Count -eq 0) { $json = '[]' } else { $elements = @($records | ForEach-Object { ConvertTo-Json -InputObject $_ -Depth 5 }) $json = '[' + [Environment]::NewLine + ($elements -join (',' + [Environment]::NewLine)) + [Environment]::NewLine + ']' } # Resolve a possibly-relative, not-yet-existing path against the current # filesystem location, and write BOM-less UTF-8 so the JSON round-trips # through Windows PowerShell 5.1's ConvertFrom-Json. $fullPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) [System.IO.File]::WriteAllText($fullPath, $json, (New-Object System.Text.UTF8Encoding($false))) return $fullPath } function Get-OIResultProperty { # Set-StrictMode-safe property read: returns $Default when the object is null # or does not expose the named property, instead of throwing. [CmdletBinding()] param( [Parameter(Mandatory = $true)] [AllowNull()] $InputObject, [Parameter(Mandatory = $true)] [string]$Name, [AllowNull()] $Default = $null ) if ($null -ne $InputObject -and $null -ne $InputObject.PSObject.Properties[$Name]) { return $InputObject.$Name } return $Default } function Get-OIThreatReportSummaryField { # Ordered name/value summary shared by the Markdown and HTML report builders, # so both render the same fields in the same order. [CmdletBinding()] param( [Parameter(Mandatory = $true)] [AllowNull()] $Result ) $probeLog = @(Get-OIResultProperty -InputObject $Result -Name 'ProbeLog' -Default @()) return [ordered]@{ 'File' = Get-OIResultProperty -InputObject $Result -Name 'File' 'Success' = Get-OIResultProperty -InputObject $Result -Name 'Success' 'SHA-256' = Get-OIResultProperty -InputObject $Result -Name 'FileSha256' 'Scan timestamp (UTC)' = Get-OIResultProperty -InputObject $Result -Name 'ScanTimestampUtc' 'Engine / scan mode' = "$(Get-OIResultProperty -InputObject $Result -Name 'Engine') / $(Get-OIResultProperty -InputObject $Result -Name 'ScanMode')" 'Initial status' = Get-OIResultProperty -InputObject $Result -Name 'InitialStatus' 'Detection prefix length' = Get-OIResultProperty -InputObject $Result -Name 'DetectionPrefixLength' 'Boundary offset' = "$(Get-OIResultProperty -InputObject $Result -Name 'DetectionBoundaryOffset') ($(Get-OIResultProperty -InputObject $Result -Name 'DetectionBoundaryHex'))" 'Known-clean prefix' = Get-OIResultProperty -InputObject $Result -Name 'KnownCleanPrefixLength' 'Stable / confidence' = "$(Get-OIResultProperty -InputObject $Result -Name 'Stable') / $(Get-OIResultProperty -InputObject $Result -Name 'Confidence')" 'Signature' = Get-OIResultProperty -InputObject $Result -Name 'SignatureName' 'Provider probes' = $probeLog.Count 'Duration (ms)' = Get-OIResultProperty -InputObject $Result -Name 'DurationMs' 'Error' = Get-OIResultProperty -InputObject $Result -Name 'Error' } } function ConvertTo-OIThreatReportMarkdown { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [object[]]$Result, [string]$Title = 'OffsetInspect Detection-Boundary Report' ) $lines = New-Object 'System.Collections.Generic.List[string]' $lines.Add("# $Title") $lines.Add('') $lines.Add("_Generated $([DateTime]::UtcNow.ToString('o')) - $($Result.Count) scan record(s)._") $lines.Add('') $index = 0 foreach ($record in $Result) { $index++ $file = [string](Get-OIResultProperty -InputObject $record -Name 'File' -Default '(unknown)') $lines.Add("## $index. $(Split-Path -Leaf $file)") $lines.Add('') foreach ($field in (Get-OIThreatReportSummaryField -Result $record).GetEnumerator()) { if ($field.Key -eq 'Error' -and [string]::IsNullOrWhiteSpace([string]$field.Value)) { continue } $lines.Add("- **$($field.Key):** $($field.Value)") } $lines.Add('') $metadata = Get-OIResultProperty -InputObject $record -Name 'ProviderMetadata' if ($null -ne $metadata) { $lines.Add('### Provider metadata') $lines.Add('') foreach ($property in $metadata.PSObject.Properties) { $lines.Add("- **$($property.Name):** $($property.Value)") } $lines.Add('') } $ioc = Get-OIResultProperty -InputObject $record -Name 'IocPanel' if ($null -ne $ioc) { $lines.Add('### Indicators') $lines.Add('') $lines.Add("- **MD5:** $($ioc.MD5)") $lines.Add("- **SHA-1:** $($ioc.SHA1)") $lines.Add("- **SHA-256:** $($ioc.SHA256)") $lines.Add("- **Overall entropy:** $($ioc.OverallEntropy) (high windows: $($ioc.HighEntropyWindows))") $lines.Add("- **Printable strings:** $($ioc.PrintableStringCount)") if ($ioc.IsPE) { $lines.Add("- **PE machine:** $($ioc.Machine)") $lines.Add("- **Imphash:** $($ioc.ImpHash)") $lines.Add("- **Imported DLLs:** $($ioc.ImportedDllCount)") $lines.Add("- **Overlay:** $($ioc.HasOverlay) (size $($ioc.OverlaySize))") } $lines.Add('') } $probeLog = @(Get-OIResultProperty -InputObject $record -Name 'ProbeLog' -Default @()) if ($probeLog.Count -gt 0) { $lines.Add('### Probe log') $lines.Add('') $lines.Add('| # | Prefix | Status | Elapsed (ms) | Signature |') $lines.Add('|---:|---:|---|---:|---|') foreach ($probe in $probeLog) { $seq = Get-OIResultProperty -InputObject $probe -Name 'Sequence' $prefix = Get-OIResultProperty -InputObject $probe -Name 'PrefixLength' $status = Get-OIResultProperty -InputObject $probe -Name 'Status' $elapsed = Get-OIResultProperty -InputObject $probe -Name 'ElapsedMs' $signature = Get-OIResultProperty -InputObject $probe -Name 'SignatureName' $lines.Add("| $seq | $prefix | $status | $elapsed | $signature |") } $lines.Add('') } $warnings = @(Get-OIResultProperty -InputObject $record -Name 'Warnings' -Default @()) if ($warnings.Count -gt 0) { $lines.Add('### Warnings') $lines.Add('') foreach ($warning in $warnings) { $lines.Add("- $warning") } $lines.Add('') } } return ($lines -join [Environment]::NewLine) } function ConvertTo-OIThreatReportHtml { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [object[]]$Result, [string]$Title = 'OffsetInspect Detection-Boundary Report' ) $encode = { param($value) [System.Net.WebUtility]::HtmlEncode([string]$value) } $builder = New-Object 'System.Text.StringBuilder' [void]$builder.AppendLine('<!DOCTYPE html>') [void]$builder.AppendLine('<html lang="en"><head><meta charset="utf-8">') [void]$builder.AppendLine("<title>$(& $encode $Title)</title>") [void]$builder.AppendLine('<style>body{font-family:Segoe UI,Arial,sans-serif;margin:2rem;color:#1b1b1b}h1{border-bottom:2px solid #444}h2{margin-top:2rem}table{border-collapse:collapse;margin:.5rem 0}th,td{border:1px solid #ccc;padding:.25rem .5rem;font-size:.9rem;text-align:left}code{background:#f2f2f2;padding:0 .25rem}</style>') [void]$builder.AppendLine('</head><body>') [void]$builder.AppendLine("<h1>$(& $encode $Title)</h1>") [void]$builder.AppendLine("<p><em>Generated $(& $encode ([DateTime]::UtcNow.ToString('o'))) - $($Result.Count) scan record(s).</em></p>") $index = 0 foreach ($record in $Result) { $index++ $file = [string](Get-OIResultProperty -InputObject $record -Name 'File' -Default '(unknown)') [void]$builder.AppendLine("<h2>$index. $(& $encode (Split-Path -Leaf $file))</h2>") [void]$builder.AppendLine('<table>') foreach ($field in (Get-OIThreatReportSummaryField -Result $record).GetEnumerator()) { if ($field.Key -eq 'Error' -and [string]::IsNullOrWhiteSpace([string]$field.Value)) { continue } [void]$builder.AppendLine("<tr><th>$(& $encode $field.Key)</th><td>$(& $encode $field.Value)</td></tr>") } [void]$builder.AppendLine('</table>') $metadata = Get-OIResultProperty -InputObject $record -Name 'ProviderMetadata' if ($null -ne $metadata) { [void]$builder.AppendLine('<h3>Provider metadata</h3><table>') foreach ($property in $metadata.PSObject.Properties) { [void]$builder.AppendLine("<tr><th>$(& $encode $property.Name)</th><td>$(& $encode $property.Value)</td></tr>") } [void]$builder.AppendLine('</table>') } $ioc = Get-OIResultProperty -InputObject $record -Name 'IocPanel' if ($null -ne $ioc) { [void]$builder.AppendLine('<h3>Indicators</h3><table>') [void]$builder.AppendLine("<tr><th>MD5</th><td>$(& $encode $ioc.MD5)</td></tr>") [void]$builder.AppendLine("<tr><th>SHA-1</th><td>$(& $encode $ioc.SHA1)</td></tr>") [void]$builder.AppendLine("<tr><th>SHA-256</th><td>$(& $encode $ioc.SHA256)</td></tr>") [void]$builder.AppendLine("<tr><th>Overall entropy</th><td>$(& $encode $ioc.OverallEntropy) (high windows: $(& $encode $ioc.HighEntropyWindows))</td></tr>") [void]$builder.AppendLine("<tr><th>Printable strings</th><td>$(& $encode $ioc.PrintableStringCount)</td></tr>") if ($ioc.IsPE) { [void]$builder.AppendLine("<tr><th>PE machine</th><td>$(& $encode $ioc.Machine)</td></tr>") [void]$builder.AppendLine("<tr><th>Imphash</th><td>$(& $encode $ioc.ImpHash)</td></tr>") [void]$builder.AppendLine("<tr><th>Imported DLLs</th><td>$(& $encode $ioc.ImportedDllCount)</td></tr>") [void]$builder.AppendLine("<tr><th>Overlay</th><td>$(& $encode $ioc.HasOverlay) (size $(& $encode $ioc.OverlaySize))</td></tr>") } [void]$builder.AppendLine('</table>') } $probeLog = @(Get-OIResultProperty -InputObject $record -Name 'ProbeLog' -Default @()) if ($probeLog.Count -gt 0) { [void]$builder.AppendLine('<h3>Probe log</h3><table><tr><th>#</th><th>Prefix</th><th>Status</th><th>Elapsed (ms)</th><th>Signature</th></tr>') foreach ($probe in $probeLog) { $seq = & $encode (Get-OIResultProperty -InputObject $probe -Name 'Sequence') $prefix = & $encode (Get-OIResultProperty -InputObject $probe -Name 'PrefixLength') $status = & $encode (Get-OIResultProperty -InputObject $probe -Name 'Status') $elapsed = & $encode (Get-OIResultProperty -InputObject $probe -Name 'ElapsedMs') $signature = & $encode (Get-OIResultProperty -InputObject $probe -Name 'SignatureName') [void]$builder.AppendLine("<tr><td>$seq</td><td>$prefix</td><td>$status</td><td>$elapsed</td><td>$signature</td></tr>") } [void]$builder.AppendLine('</table>') } $warnings = @(Get-OIResultProperty -InputObject $record -Name 'Warnings' -Default @()) if ($warnings.Count -gt 0) { [void]$builder.AppendLine('<h3>Warnings</h3><ul>') foreach ($warning in $warnings) { [void]$builder.AppendLine("<li>$(& $encode $warning)</li>") } [void]$builder.AppendLine('</ul>') } } [void]$builder.AppendLine('</body></html>') return $builder.ToString() } function ConvertTo-OIFlatInspectionResult { [CmdletBinding()] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [object]$Result ) process { [pscustomobject]@{ Success = $Result.Success File = $Result.File OffsetInput = $Result.OffsetInput OffsetDecimal = $Result.OffsetDecimal OffsetHex = $Result.OffsetHex FileSize = $Result.FileSize EncodingRequested = $Result.EncodingRequested EncodingDetected = $Result.EncodingDetected LineNumber = $Result.LineNumber CharacterPosition = $Result.CharacterPosition PreviewCharacterPosition = $Result.PreviewCharacterPosition BytePositionInLine = $Result.BytePositionInLine TargetByteHex = $Result.TargetByteHex TargetByteDecimal = $Result.TargetByteDecimal CompareFile = $Result.CompareFile CompareByteHex = $Result.CompareByteHex CompareByteDecimal = $Result.CompareByteDecimal BytesDiffer = $Result.BytesDiffer WindowStartOffset = $Result.WindowStartOffset WindowEndOffset = $Result.WindowEndOffset LineText = $Result.LineText LineTextTruncated = $Result.LineTextTruncated DurationMs = $Result.DurationMs Warnings = @($Result.Warnings) -join '; ' Error = $Result.Error } } } function Write-OIHumanInspectionResult { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [object]$Result, [Parameter(Mandatory = $true)] [int]$Index, [Parameter(Mandatory = $true)] [int]$Total ) Write-Host '' Write-Host ('=' * 100) -ForegroundColor DarkYellow Write-Host "Result ($Index/$Total): $($Result.File)" -ForegroundColor Green Write-Host "Offset input: $($Result.OffsetInput)" -ForegroundColor Green Write-Host "Offset decimal: $($Result.OffsetDecimal)" -ForegroundColor Green Write-Host "Offset hex: $($Result.OffsetHex)" -ForegroundColor Green Write-Host "File size: $($Result.FileSize) bytes" -ForegroundColor Green Write-Host "Encoding: $($Result.EncodingDetected) (requested: $($Result.EncodingRequested))" -ForegroundColor Green if (-not $Result.Success) { Write-Host "Error: $($Result.Error)" -ForegroundColor Red Write-Host ('=' * 100) -ForegroundColor DarkCyan return } Write-Host "Line number: $($Result.LineNumber)" -ForegroundColor Green Write-Host "Byte in line: $($Result.BytePositionInLine)" -ForegroundColor Green if ($null -ne $Result.CharacterPosition) { Write-Host "Character index: $($Result.CharacterPosition)" -ForegroundColor Green } Write-Host "Target byte: $($Result.TargetByteHex) ($($Result.TargetByteDecimal))" -ForegroundColor Green if ($Result.CompareFile) { Write-Host "Compare file: $($Result.CompareFile)" -ForegroundColor Cyan Write-Host "Compare byte: $($Result.CompareByteHex) ($($Result.CompareByteDecimal))" -ForegroundColor Cyan Write-Host "Bytes differ: $($Result.BytesDiffer)" -ForegroundColor Cyan } if (@($Result.ContextLines).Count -gt 0) { Write-Host '' Write-Host ('-' * 40 + ' Source Context ' + '-' * 44) -ForegroundColor DarkYellow foreach ($line in $Result.ContextLines) { $prefix = ('{0,8} | ' -f $line.LineNumber) if ($line.IsTarget) { Write-Host -NoNewline $prefix -ForegroundColor Yellow Write-Host $line.Text -ForegroundColor Green if ($null -ne $Result.PreviewCharacterPosition) { $caretPadding = $prefix.Length + [int]$Result.PreviewCharacterPosition Write-Host ((' ' * $caretPadding) + '^') -ForegroundColor Yellow } } else { Write-Host -NoNewline $prefix -ForegroundColor DarkGray Write-Host $line.Text -ForegroundColor Gray } } } Write-Host '' Write-Host ('-' * 43 + ' Hex Dump ' + '-' * 47) -ForegroundColor DarkYellow foreach ($row in $Result.HexDump) { Write-Host -NoNewline "$($row.Offset) " -ForegroundColor Green foreach ($part in $row.HexParts) { if ($part.IsHighlight) { Write-Host -NoNewline "$($part.Text) " -ForegroundColor Yellow } else { Write-Host -NoNewline "$($part.Text) " -ForegroundColor Green } } $padding = 3 * (16 - @($row.HexParts).Count) if ($padding -gt 0) { Write-Host -NoNewline (' ' * $padding) } Write-Host " $($row.Ascii)" -ForegroundColor Green } foreach ($warning in @($Result.Warnings)) { Write-Host "Warning: $warning" -ForegroundColor Yellow } Write-Host ('=' * 100) -ForegroundColor DarkCyan } function Write-OIInspectionOutput { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [object[]]$Results, [Parameter(Mandatory = $true)] [ValidateSet('Human', 'Object', 'Json', 'Csv', 'CsvFile')] [string]$Mode, [AllowNull()] [string]$CsvPath ) switch ($Mode) { 'Object' { return $Results } 'Json' { return (ConvertTo-Json -InputObject @($Results) -Depth 12) } 'Csv' { return @($Results | ConvertTo-OIFlatInspectionResult | ConvertTo-Csv -NoTypeInformation) } 'CsvFile' { $parent = Split-Path -Parent $CsvPath if ($parent -and -not (Test-Path -LiteralPath $parent)) { $null = New-Item -ItemType Directory -Path $parent -Force } $Results | ConvertTo-OIFlatInspectionResult | Export-Csv -LiteralPath $CsvPath -NoTypeInformation -Encoding UTF8 return (Get-Item -LiteralPath $CsvPath) } default { Write-OIBanner for ($index = 0; $index -lt $Results.Count; $index++) { Write-OIHumanInspectionResult -Result $Results[$index] -Index ($index + 1) -Total $Results.Count } } } } |