Public/Export-OffsetThreatReport.ps1

function Export-OffsetThreatReport {
    <#
    .SYNOPSIS
        Renders one or more threat-scan results into a report-ready Markdown or HTML file.

    .DESCRIPTION
        Consumes the OffsetInspect.ThreatScanResult objects produced by
        Invoke-OffsetThreatScan -PassThru (a single result or a pipeline of many) and writes
        a self-contained detection-boundary report: per-file summary, provider/signature/engine
        metadata for reproducibility, the full ProbeLog audit trail, and any warnings. HTML
        output is self-contained and HTML-encodes every value. The command reads results only
        and never re-scans, so it is fully cross-platform.

    .PARAMETER Result
        One or more threat-scan result objects, typically piped from Invoke-OffsetThreatScan -PassThru.

    .PARAMETER Path
        Destination file path. Written as BOM-less UTF-8.

    .PARAMETER Format
        Markdown (default) or Html.

    .PARAMETER Title
        Report heading.

    .PARAMETER IncludeIoc
        Attach an IOC panel (hashes, entropy, imphash, overlay) to each record. Without
        -IocJsonPath the panel is computed live via Get-OffsetIOC (one PowerShell scan
        per file). With -IocJsonPath it is sourced from the JSON and this switch only
        enables a live fallback for files absent from the JSON.

    .PARAMETER IocJsonPath
        Path to a JSON dump produced by the native OffsetScan engine
        (`offsetscan ioc <corpus> > ioc.json`). IOC panels are matched to each record by
        canonical full path and are schema-identical to Get-OffsetIOC output - but for
        corpus-scale engagements the native engine is orders of magnitude faster than
        re-scanning every file in PowerShell. Supplying this implies IOC enrichment;
        add -IncludeIoc to also fall back to a live scan for unmatched files.

    .PARAMETER IncludeTrigger
        For each record with a detection boundary, attach a detection-trigger analysis: the
        PE section the boundary falls in, the entropy of the run up to it, and the extracted
        strings ending at/straddling it (the likely signature content), with a one-line
        interpretation. Read-only and cross-platform; records without a boundary are untouched.

    .EXAMPLE
        Invoke-OffsetThreatScan .\sample.ps1 -Engine AMSI -PassThru |
            Export-OffsetThreatReport -Path .\report.html -Format Html

    .EXAMPLE
        $results | Export-OffsetThreatReport -Path .\engagement.md

    .EXAMPLE
        # Pre-compute IOCs for a whole corpus with the native engine, then render
        # the report off that JSON instead of re-scanning each file in PowerShell.
        offsetscan ioc C:\Cases\corpus --recurse > C:\Cases\ioc.json
        $results | Export-OffsetThreatReport -Path .\engagement.md -IocJsonPath C:\Cases\ioc.json
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [pscustomobject[]]$Result,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Path,

        [ValidateSet('Markdown', 'Html')]
        [string]$Format = 'Markdown',

        [ValidateNotNullOrEmpty()]
        [string]$Title = 'OffsetInspect Detection-Boundary Report',

        [switch]$IncludeIoc,

        [ValidateNotNullOrEmpty()]
        [string]$IocJsonPath,

        [switch]$IncludeTrigger
    )

    begin {
        $collected = New-Object 'System.Collections.Generic.List[object]'
    }

    process {
        foreach ($item in $Result) {
            if ($null -ne $item) { $collected.Add($item) }
        }
    }

    end {
        if ($collected.Count -eq 0) {
            throw 'No threat-scan results were supplied to Export-OffsetThreatReport.'
        }

        $records = $collected.ToArray()

        # Optional IOC index sourced from a native OffsetScan `ioc` JSON dump
        # (offsetscan ioc <corpus> > ioc.json). Panels are schema-identical to
        # Get-OffsetIOC, so they render through the same path - but for corpus-scale
        # reports the native engine is orders of magnitude faster than re-scanning
        # each file in PowerShell. Keyed by canonical full path (case-insensitive).
        $iocIndex = $null
        if (-not [string]::IsNullOrWhiteSpace($IocJsonPath)) {
            $resolvedJson = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($IocJsonPath)
            if (-not (Test-Path -LiteralPath $resolvedJson)) {
                throw "IocJsonPath not found: $resolvedJson"
            }
            $rawJson = [System.IO.File]::ReadAllText($resolvedJson)
            # ConvertFrom-Json's return shape differs across editions: Windows PowerShell 5.1
            # in module scope can hand back a top-level JSON array as a single Object[] that
            # does not enumerate cleanly. Flatten one level so a JSON array (offsetscan's
            # normal output) and a lone object both yield a flat record list on every edition.
            $parsedJson = ConvertFrom-Json -InputObject $rawJson
            $iocRecords = New-Object 'System.Collections.Generic.List[object]'
            foreach ($candidate in @($parsedJson)) {
                # A JSON array deserializes to an enumerable (Object[]); a lone JSON object to a
                # PSCustomObject, which is NOT itself enumerable and must be added as one record.
                if ($candidate -is [System.Collections.IEnumerable] -and $candidate -isnot [string] -and $candidate -isnot [pscustomobject]) {
                    foreach ($inner in $candidate) { $iocRecords.Add($inner) }
                }
                else {
                    $iocRecords.Add($candidate)
                }
            }
            $iocIndex = New-Object 'System.Collections.Generic.Dictionary[string,object]' ([System.StringComparer]::OrdinalIgnoreCase)
            foreach ($entry in $iocRecords) {
                $entryFile = $entry.PSObject.Properties['File']
                if ($null -ne $entryFile -and -not [string]::IsNullOrWhiteSpace([string]$entry.File)) {
                    $key = try { [System.IO.Path]::GetFullPath([string]$entry.File) } catch { [string]$entry.File }
                    $iocIndex[$key] = $entry
                }
            }
            Write-Verbose "Loaded $($iocRecords.Count) IOC record(s) from $resolvedJson"
        }

        if ($IncludeIoc -or $null -ne $iocIndex) {
            foreach ($record in $records) {
                $fileProperty = $record.PSObject.Properties['File']
                if ($null -eq $fileProperty -or [string]::IsNullOrWhiteSpace([string]$record.File)) {
                    continue
                }

                $ioc = $null
                if ($null -ne $iocIndex) {
                    $key = try { [System.IO.Path]::GetFullPath([string]$record.File) } catch { [string]$record.File }
                    if ($iocIndex.ContainsKey($key)) { $ioc = $iocIndex[$key] }
                }

                # Live fallback: only when -IncludeIoc was requested (JSON miss or no JSON at all).
                if ($null -eq $ioc -and $IncludeIoc -and (Test-Path -LiteralPath $record.File)) {
                    try { $ioc = Get-OffsetIOC -FilePath $record.File }
                    catch { Write-Verbose "IOC enrichment failed for $($record.File): $($_.Exception.Message)" }
                }

                if ($null -ne $ioc) {
                    Add-Member -InputObject $record -NotePropertyName 'IocPanel' -NotePropertyValue $ioc -Force
                }
                elseif ($null -ne $iocIndex) {
                    Write-Verbose "No IOC match in JSON for $($record.File)"
                }
            }
        }

        # Optional detection-trigger correlation: for every record with a boundary, attach
        # the content that most likely tripped detection (section, entropy, candidate strings).
        # Read-only and cross-platform; records without a boundary are simply left alone.
        if ($IncludeTrigger) {
            foreach ($record in $records) {
                $fileProperty = $record.PSObject.Properties['File']
                $boundary = Get-OIResultProperty -InputObject $record -Name 'DetectionBoundaryOffset'
                if ($null -eq $fileProperty -or [string]::IsNullOrWhiteSpace([string]$record.File) -or $null -eq $boundary) {
                    continue
                }
                if (-not (Test-Path -LiteralPath $record.File)) {
                    Write-Verbose "Trigger analysis skipped; file not found: $($record.File)"
                    continue
                }
                try {
                    $trigger = $record | Get-OffsetDetectionTrigger
                    if ($null -ne $trigger) {
                        Add-Member -InputObject $record -NotePropertyName 'Trigger' -NotePropertyValue $trigger -Force
                    }
                }
                catch {
                    Write-Verbose "Trigger analysis failed for $($record.File): $($_.Exception.Message)"
                }
            }
        }

        $content = if ($Format -eq 'Html') {
            ConvertTo-OIThreatReportHtml -Result $records -Title $Title
        }
        else {
            ConvertTo-OIThreatReportMarkdown -Result $records -Title $Title
        }

        $fullPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
        [System.IO.File]::WriteAllText($fullPath, $content, (New-Object System.Text.UTF8Encoding($false)))
        Write-Verbose "Threat report ($Format, $($records.Count) record(s)) written to $fullPath"
        return $fullPath
    }
}