Public/Export-IncidentCapsuleData.ps1

function Export-IncidentCapsuleData {
    <#
    .SYNOPSIS
    Exports capsule evidence envelopes as line-delimited JSON (JSONL).

    .DESCRIPTION
    Reads the structured evidence envelopes of a collected capsule directory and
    writes one JSON object per evidence record, suitable for ingestion into SIEM
    and timeline tooling. Every line carries the capsule ID, host, collector,
    capture time, the capsule-relative source file, and the zero-based record
    index alongside the unmodified record.

    The export is written outside the capsule directory so the sealed evidence
    and its manifest remain untouched. Archives are not read directly: verify and
    extract them first with Test-IncidentCapsuleIntegrity.

    .PARAMETER Path
    Path to a collected capsule directory.

    .PARAMETER DestinationPath
    Optional output file. Defaults to '<capsule>.evidence.jsonl' beside the
    capsule directory. The destination must not already exist and must be
    outside the capsule directory.

    .PARAMETER Collector
    Optional collector names. Only envelopes written by these collectors are
    exported.

    .EXAMPLE
    Export-IncidentCapsuleData -Path $result.WorkingDirectory

    .EXAMPLE
    Export-IncidentCapsuleData -Path 'E:\Evidence\IC_IR-2026-0042_WS-042_20260712T184233Z_a1b2c3d4' -Collector Processes,Network -DestinationPath 'E:\Evidence\processes-network.jsonl'

    .OUTPUTS
    IncidentCapsule.ExportResult
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('FullName', 'WorkingDirectory')]
        [string]$Path,

        [string]$DestinationPath,

        [string[]]$Collector
    )

    process {
        $resolved = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path
        if (-not (Test-Path -LiteralPath $resolved -PathType Container)) {
            throw "Path '$resolved' is not a capsule directory. Verify and extract an archive with Test-IncidentCapsuleIntegrity before exporting."
        }

        $root = Find-ICManifestRoot -Path $resolved
        $destination = if ($PSBoundParameters.ContainsKey('DestinationPath') -and -not [string]::IsNullOrWhiteSpace($DestinationPath)) {
            [System.IO.Path]::GetFullPath($DestinationPath)
        }
        else {
            "$root.evidence.jsonl"
        }

        $rootPrefix = $root + [System.IO.Path]::DirectorySeparatorChar
        if ($destination -eq $root -or $destination.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
            throw "DestinationPath '$destination' is inside the capsule directory; the sealed capsule must not be modified."
        }
        if (Test-Path -LiteralPath $destination) {
            throw "DestinationPath '$destination' already exists."
        }

        $evidenceRoot = Join-Path $root 'evidence'
        if (-not (Test-Path -LiteralPath $evidenceRoot -PathType Container)) {
            throw "Capsule '$root' does not contain an evidence directory."
        }

        $destinationDirectory = Split-Path -Parent $destination
        if (-not [string]::IsNullOrWhiteSpace($destinationDirectory) -and -not (Test-Path -LiteralPath $destinationDirectory)) {
            New-Item -ItemType Directory -Path $destinationDirectory -Force -ErrorAction Stop | Out-Null
        }

        $envelopeCount = 0
        $recordCount = 0
        $skippedFiles = New-Object System.Collections.ArrayList
        $collectorsSeen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)

        $encoding = New-Object System.Text.UTF8Encoding($false)
        $stream = New-Object System.IO.FileStream($destination, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
        $writer = New-Object System.IO.StreamWriter($stream, $encoding)
        try {
            foreach ($file in Get-ChildItem -LiteralPath $evidenceRoot -Filter '*.json' -File -Recurse -ErrorAction Stop | Sort-Object FullName) {
                $relativePath = Get-ICRelativePath -BasePath $root -Path $file.FullName
                $envelope = $null
                try {
                    $envelope = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
                }
                catch {
                    [void]$skippedFiles.Add($relativePath)
                    continue
                }

                if (-not (Test-ICObjectProperty -InputObject $envelope -Name 'collector') -or -not (Test-ICObjectProperty -InputObject $envelope -Name 'data')) {
                    [void]$skippedFiles.Add($relativePath)
                    continue
                }

                $collectorName = [string]$envelope.collector
                if ($null -ne $Collector -and $Collector.Count -gt 0 -and $collectorName -notin $Collector) {
                    continue
                }

                $envelopeCount++
                [void]$collectorsSeen.Add($collectorName)

                $data = $envelope.data
                $records = if ($null -eq $data) { @() } elseif ($data -is [System.Array]) { @($data) } else { @($data) }
                $recordIndex = -1
                foreach ($record in $records) {
                    $recordIndex++
                    $line = [ordered]@{
                        capsuleId     = [string](Get-ICPropertyValue -InputObject $envelope -Name 'capsuleId')
                        host          = [string](Get-ICPropertyValue -InputObject $envelope -Name 'host')
                        collector     = $collectorName
                        capturedAtUtc = [string](Get-ICPropertyValue -InputObject $envelope -Name 'capturedAtUtc')
                        source        = $relativePath
                        recordIndex   = $recordIndex
                        record        = $record
                    }
                    $writer.WriteLine((ConvertTo-Json -InputObject $line -Compress -Depth 30))
                    $recordCount++
                }
            }
            $writer.Flush()
        }
        catch {
            $writer.Dispose()
            Remove-Item -LiteralPath $destination -Force -ErrorAction SilentlyContinue
            throw
        }
        finally {
            $writer.Dispose()
        }

        return [pscustomobject][ordered]@{
            PSTypeName    = 'IncidentCapsule.ExportResult'
            Path          = $destination
            CapsulePath   = $root
            EnvelopeCount = $envelopeCount
            RecordCount   = $recordCount
            SkippedFiles  = @($skippedFiles)
            Collectors    = @($collectorsSeen | Sort-Object)
        }
    }
}