src/Public/Parse/Invoke-FotonExternalParse.ps1

function Invoke-FotonExternalParse {
    <#
    .SYNOPSIS
        If EZ Tools / Hayabusa / Chainsaw are available, run them on copied package files.
 
    .DESCRIPTION
        Offline workstation compose. Does not download tools and does not read the live host.
        Native ConvertFrom-Foton* remains the no-dependency fallback (Invoke-FotonParse).
 
        Resolution order per tool: -ToolPath override, then Foton tools.json (Install-FotonTool),
        then PATH. Never mutates PATH.
 
        When a tool is present, stdout/CSV lands under analysis/external/<tool>/ and is hashed.
        Missing tools are recorded as Info coverage rows, not parse failures.
        Hayabusa/Chainsaw hits are facts from those engines (Tier 2 unless they only
        report an Event ID / hash match). Name the tool in Artifact.
 
    .PARAMETER PackagePath
        Case folder or ZIP. If omitted, uses the session CaseRoot from Get-FotonPackage.
 
    .PARAMETER Path
        Same as PackagePath (Easy wrapper).
 
    .PARAMETER ToolRoot
        Foton tool install root (default %LOCALAPPDATA%\Foton\tools).
 
    .PARAMETER ToolPath
        Optional hashtable of ToolName -> executable path overrides.
 
    .EXAMPLE
        Get-FotonPackage -Path D:\Cases\Foton_HOSTNAME_20260823-011150Z
        Invoke-FotonExternalParse
 
        Runs PECmd/LECmd/JLECmd/EvtxECmd/AmcacheParser/RECmd/hayabusa/chainsaw if found.
 
    .OUTPUTS
        Inventory rows (Tool, Status, DestRelative, SHA256, Version, RulesVersion, Note).
    #>

    [CmdletBinding()]
    param(
        [string]$PackagePath,
        [string]$Path,
        [string]$ToolRoot,
        [hashtable]$ToolPath
    )

    if ($null -eq $script:FotonSession) { Initialize-FotonSession }
    $pkg = $PackagePath
    if (-not $pkg) { $pkg = $Path }
    if ($pkg) {
        Get-FotonPackage -Path $pkg | Out-Null
    }
    $root = $script:FotonSession.CaseRoot
    if (-not $root -or -not (Test-Path -LiteralPath $root)) {
        throw 'No package loaded. Pass -PackagePath or call Get-FotonPackage first.'
    }

    $extRoot = Join-Path $root 'analysis\external'
    New-Item -Path $extRoot -ItemType Directory -Force | Out-Null

    if (-not $ToolPath) { $ToolPath = @{} }

    $prefetch = Join-Path $root (Join-Path $script:FotonSession.Categories['Filesystem'] 'prefetch')
    $lnk = Join-Path $root (Join-Path $script:FotonSession.Categories['Filesystem'] 'lnk_jumplist')
    $evtx = Join-Path $root (Join-Path $script:FotonSession.Categories['EventLogs'] 'evtx')
    $am = Join-Path $root (Join-Path $script:FotonSession.Categories['Filesystem'] 'amcache')
    $hives = Join-Path $root (Join-Path $script:FotonSession.Categories['Filesystem'] 'hives')

    $jobs = @(
        @{ Tool = 'PECmd'; Names = @('PECmd.exe', 'PECmd'); NeedDir = $prefetch; Args = { param($exe, $in, $out) @('-d', $in, '--csv', $out, '--csvf', 'pecmd.csv') } }
        @{ Tool = 'LECmd'; Names = @('LECmd.exe', 'LECmd'); NeedDir = $lnk; Args = { param($exe, $in, $out) @('-d', $in, '--csv', $out, '--csvf', 'lecmd.csv') } }
        @{ Tool = 'JLECmd'; Names = @('JLECmd.exe', 'JLECmd'); NeedDir = $lnk; Args = { param($exe, $in, $out) @('-d', $in, '--csv', $out, '--csvf', 'jlecmd.csv') } }
        @{ Tool = 'EvtxECmd'; Names = @('EvtxECmd.exe', 'EvtxECmd'); NeedDir = $evtx; Args = { param($exe, $in, $out) @('-d', $in, '--csv', $out, '--csvf', 'evtxecmd.csv') } }
        @{ Tool = 'AmcacheParser'; Names = @('AmcacheParser.exe', 'AmcacheParser'); NeedDir = $am; Args = { param($exe, $in, $out)
                $hve = Join-Path $in 'Amcache.hve'
                @('-f', $hve, '--csv', $out, '--csvf', 'amcacheparser.csv')
            } }
        # RECmd exports nothing without a search switch; with only -d/--csv it logs a
        # warning and exits. A batch file is the whole point of running it here.
        @{ Tool = 'RECmd'; Names = @('RECmd.exe', 'RECmd'); NeedDir = $hives
            Pre = { param($exe)
                if (-not (Find-FotonRecmdBatch -RecmdPath $exe)) {
                    return [PSCustomObject]@{ Status = 'NoBatch'; Note = 'RECmd found but no BatchExamples\DFIRBatch.reb or Kroll_Batch.reb beside it. RECmd does nothing without a batch or search switch; reinstall EZ Tools with BatchExamples.' }
                }
                return $null
            }
            Args = { param($exe, $in, $out)
                @('-d', $in, '--bn', (Find-FotonRecmdBatch -RecmdPath $exe), '--csv', $out, '--csvf', 'recmd.csv')
            } }
        @{ Tool = 'Hayabusa'; Names = @('hayabusa.exe', 'hayabusa'); NeedDir = $evtx; Args = { param($exe, $in, $out)
                # v4 renamed csv-timeline to dfir-timeline. Probe the built-in help
                # rather than guessing, so both generations work.
                $sub = 'dfir-timeline'
                try {
                    $help = & $exe '--help' 2>&1 | Out-String
                    if ($help -match 'csv-timeline') { $sub = 'csv-timeline' }
                }
                catch { }
                # -w skips the interactive rule wizard; without it the process waits
                # forever for keyboard input and the compose step looks hung.
                # Rules resolve relative to the working directory, so point -r/-c at
                # the install tree next to the binary.
                $exeDir = Split-Path -Parent $exe
                # -U writes timestamps in UTC; the default is the workstation's local
                # zone, which is neither the host's zone nor the package's UTC convention.
                $a = @($sub, '-d', $in, '-o', (Join-Path $out 'hayabusa.csv'), '-w', '-C', '-U')
                $rules = Join-Path $exeDir 'rules'
                if (Test-Path -LiteralPath $rules) {
                    $a += @('-r', $rules)
                    $rcfg = Join-Path $rules 'config'
                    if (Test-Path -LiteralPath $rcfg) { $a += @('-c', $rcfg) }
                }
                $a
            } }
        # Hunt with Sigma/Chainsaw rules shipped beside the binary. Without rules there
        # is nothing to hunt; the old `dump` fallback re-serialised every EVTX record
        # (hundreds of MB) and duplicated EvtxECmd, so it is not run.
        @{ Tool = 'Chainsaw'; Names = @('chainsaw.exe', 'chainsaw'); NeedDir = $evtx
            Pre = { param($exe)
                $r = Find-FotonChainsawRules -ChainsawPath $exe
                if (-not $r.RulesPath -and -not $r.SigmaPath) {
                    return [PSCustomObject]@{ Status = 'NoRules'; Note = 'Chainsaw found but no rules\ or sigma\ (with mappings\sigma-event-logs-all.yml) beside it. Install the Chainsaw release that bundles rules, then compose again. EvtxECmd and Hayabusa cover the EVTX meanwhile.' }
                }
                return $null
            }
            Args = { param($exe, $in, $out)
                $r = Find-FotonChainsawRules -ChainsawPath $exe
                $a = @('hunt', $in)
                if ($r.RulesPath) { $a += @('-r', $r.RulesPath) }
                if ($r.SigmaPath) { $a += @('-s', $r.SigmaPath, '--mapping', $r.MappingPath) }
                $a + @('--csv', '--output', $out, '--skip-errors')
            } }
    )

    $rows = New-Object System.Collections.ArrayList
    $anyTool = $false
    foreach ($j in $jobs) {
        $override = $null
        if ($ToolPath.ContainsKey($j.Tool)) { $override = [string]$ToolPath[$j.Tool] }
        $resolved = Resolve-FotonExternalTool -ToolName $j.Tool -ExecutableNames $j.Names `
            -ToolPath $override -ToolRoot $ToolRoot
        $exe = $null
        $toolVer = ''
        $rulesVer = ''
        $resolveSrc = ''
        if ($resolved) {
            $exe = [string]$resolved.Path
            $toolVer = [string]$resolved.Version
            $rulesVer = [string]$resolved.RulesVersion
            $resolveSrc = [string]$resolved.Source
        }
        $toolDir = Join-Path $extRoot $j.Tool.ToLowerInvariant()
        New-Item -Path $toolDir -ItemType Directory -Force | Out-Null
        # When this run does not execute the tool, output from an earlier compose is
        # kept (it may be the only copy) but listed as Retained, so the inventory
        # still describes every file analyze will read.
        $addRetained = {
            param([string]$Why)
            foreach ($f in @(Get-ChildItem -LiteralPath $toolDir -File -Recurse -ErrorAction SilentlyContinue |
                        Where-Object { $_.Name -notin @('tool_stderr.txt', 'tool_stdout.txt') })) {
                $null = $rows.Add([PSCustomObject]@{
                        Tool         = $j.Tool
                        Status       = 'Retained'
                        DestRelative = $f.FullName.Substring($root.Length).TrimStart('\', '/')
                        SHA256       = (Get-FotonSafeHash -Path $f.FullName)
                        Version      = ''
                        RulesVersion = ''
                        Note         = ("Output from an earlier compose run; {0} did not run this time ({1}). Tool version for this file is not recorded here." -f $j.Tool, $Why)
                    })
            }
        }
        if (-not $exe) {
            $hint = 'If it is installed elsewhere, register it once: Install-FotonTool -Name EZTools -Path <folder> (or -Name Chainsaw). Native ConvertFrom-Foton* / Invoke-FotonParse is the fallback. This is a coverage gap, not a parse error.'
            if ($j.Tool -eq 'Hayabusa') {
                $hint = 'Run Install-FotonTool -Name Hayabusa (or Foton.cmd tools Hayabusa), then compose again. Native ConvertFrom-Foton* remains the air-gap fallback.'
            }
            $null = $rows.Add([PSCustomObject]@{
                    Tool         = $j.Tool
                    Status       = 'NotOnPath'
                    DestRelative = ''
                    SHA256       = ''
                    Version      = ''
                    RulesVersion = ''
                    Note         = ("{0} was not found via -ToolPath, Foton tools.json, or PATH. {1}" -f $j.Tool, $hint)
                })
            & $addRetained 'NotOnPath'
            continue
        }
        $anyTool = $true
        if (-not (Test-Path -LiteralPath $j.NeedDir)) {
            $null = $rows.Add([PSCustomObject]@{
                    Tool         = $j.Tool
                    Status       = 'NoInput'
                    DestRelative = ''
                    SHA256       = ''
                    Version      = $toolVer
                    RulesVersion = $rulesVer
                    Note         = ("Tool found ({0}) but input folder is missing: {1}. Re-collect elevated if the copy task was skipped." -f $resolveSrc, $j.NeedDir)
                })
            & $addRetained 'NoInput'
            continue
        }
        if ($j.Pre) {
            $skip = & $j.Pre $exe
            if ($skip) {
                $null = $rows.Add([PSCustomObject]@{
                        Tool         = $j.Tool
                        Status       = $skip.Status
                        DestRelative = ''
                        SHA256       = ''
                        Version      = $toolVer
                        RulesVersion = $rulesVer
                        Note         = $skip.Note
                    })
                & $addRetained $skip.Status
                continue
            }
        }
        # Run into a staging folder; earlier output is replaced only when this run
        # actually produces files, so a failed rerun cannot destroy the last good one.
        $stage = Join-Path $extRoot ('.{0}.staging-{1}' -f $j.Tool.ToLowerInvariant(), [guid]::NewGuid().ToString('N'))
        New-Item -Path $stage -ItemType Directory -Force | Out-Null
        $arg = & $j.Args $exe $j.NeedDir $stage
        $log = Join-Path $stage 'tool_stderr.txt'
        $stdout = Join-Path $stage 'tool_stdout.txt'
        $ok = $false
        # Run in the tool's own install tree, not the output folder. Hayabusa resolves
        # its config/ (profiles, level colours) relative to the working directory, so
        # running it from analysis/external/hayabusa made it report missing rules and
        # config even with -r and -c supplied. Every path passed in is absolute, so
        # this is safe for the EZ Tools too.
        $workDir = Split-Path -Parent $exe
        if (-not $workDir -or -not (Test-Path -LiteralPath $workDir)) { $workDir = $toolDir }
        try {
            # Start-Process joins an array with bare spaces, which splits any path that
            # contains a space (D:\My Cases\...). Pass one correctly quoted string.
            $argLine = ConvertTo-FotonCommandLine -Arguments @($arg)
            $p = Start-Process -FilePath $exe -ArgumentList $argLine -Wait -PassThru -NoNewWindow -RedirectStandardError $log -RedirectStandardOutput $stdout -WorkingDirectory $workDir
            if ($p.ExitCode -eq 0) { $ok = $true }
        }
        catch {
            Set-Content -LiteralPath $log -Value $_.Exception.Message -Encoding UTF8
        }
        $newOuts = @(Get-ChildItem -LiteralPath $stage -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Name -notin @('tool_stderr.txt', 'tool_stdout.txt') })
        if ($newOuts.Count -gt 0) {
            # Swap by directory rename (same volume): the earlier output is deleted only
            # after the new folder is in place, and is restored if that rename fails.
            $prev = Join-Path $extRoot ('.{0}.prev-{1}' -f $j.Tool.ToLowerInvariant(), [guid]::NewGuid().ToString('N'))
            $swapError = ''
            try {
                Move-Item -LiteralPath $toolDir -Destination $prev -ErrorAction Stop
                try { Move-Item -LiteralPath $stage -Destination $toolDir -ErrorAction Stop }
                catch {
                    Move-Item -LiteralPath $prev -Destination $toolDir -ErrorAction SilentlyContinue
                    throw
                }
                Remove-Item -LiteralPath $prev -Recurse -Force -ErrorAction SilentlyContinue
            }
            catch { $swapError = $_.Exception.Message }
            if ($swapError) {
                $null = $rows.Add([PSCustomObject]@{
                        Tool         = $j.Tool
                        Status       = 'SwapError'
                        DestRelative = $stage.Substring($root.Length).TrimStart('\', '/')
                        SHA256       = ''
                        Version      = $toolVer
                        RulesVersion = $rulesVer
                        Note         = ("{0} produced output, but it could not replace the earlier output ({1}); a file in that folder may be open. The new output was left in the staging folder." -f $j.Tool, $swapError)
                    })
                & $addRetained 'SwapError'
                continue
            }
        }
        else {
            # Nothing new: keep earlier output, but replace the logs so they describe this run.
            foreach ($leaf in @('tool_stderr.txt', 'tool_stdout.txt')) {
                $src = Join-Path $stage $leaf
                if (Test-Path -LiteralPath $src) { Move-Item -LiteralPath $src -Destination (Join-Path $toolDir $leaf) -Force }
            }
        }
        Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue
        $log = Join-Path $toolDir 'tool_stderr.txt'
        $stdout = Join-Path $toolDir 'tool_stdout.txt'
        # .NET 10 builds (AmcacheParser has no other) print this and exit when the
        # runtime is absent; without it the row read as a generic tool error.
        if ($newOuts.Count -eq 0 -and -not $ok -and (Test-FotonToolMissingRuntime -Paths @($log, $stdout))) {
            $null = $rows.Add([PSCustomObject]@{
                    Tool         = $j.Tool
                    Status       = 'MissingRuntime'
                    DestRelative = (Join-Path 'analysis\external' $j.Tool.ToLowerInvariant())
                    SHA256       = ''
                    Version      = $toolVer
                    RulesVersion = $rulesVer
                    Note         = ("{0} needs a .NET runtime that is not installed on this workstation (see tool_stdout.txt / tool_stderr.txt). Install the runtime it names, then compose again." -f $j.Tool)
                })
            & $addRetained 'MissingRuntime'
            continue
        }
        if ($newOuts.Count -eq 0) {
            $failStatus = $(if ($ok) { 'NoOutput' } else { 'ToolError' })
            $null = $rows.Add([PSCustomObject]@{
                    Tool         = $j.Tool
                    Status       = $failStatus
                    DestRelative = (Join-Path 'analysis\external' $j.Tool.ToLowerInvariant())
                    SHA256       = ''
                    Version      = $toolVer
                    RulesVersion = $rulesVer
                    Note         = ("{0} ran but produced no output files. See tool_stderr.txt. Native parse remains valid." -f $j.Tool)
                })
            & $addRetained $failStatus
            continue
        }
        $outs = @(Get-ChildItem -LiteralPath $toolDir -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Name -notin @('tool_stderr.txt', 'tool_stdout.txt') })
        foreach ($f in $outs) {
            $rel = $f.FullName.Substring($root.Length).TrimStart('\', '/')
            $null = $rows.Add([PSCustomObject]@{
                    Tool         = $j.Tool
                    Status       = 'Copied'
                    DestRelative = $rel
                    SHA256       = (Get-FotonSafeHash -Path $f.FullName)
                    Version      = $toolVer
                    RulesVersion = $rulesVer
                    Note         = ("Hashed output from {0} (resolved via {1}). Hits from this engine are observations named to the tool, not a Foton verdict." -f $j.Tool, $resolveSrc)
                })
        }
    }

    if (-not $anyTool) {
        $null = $rows.Add([PSCustomObject]@{
                Tool         = '*'
                Status       = 'NotOnPath'
                DestRelative = ''
                SHA256       = ''
                Version      = ''
                RulesVersion = ''
                Note         = 'No EZ Tools / Hayabusa / Chainsaw binaries were found. For Hayabusa: Install-FotonTool -Name Hayabusa. Use Invoke-FotonParse for native ConvertFrom-Foton* fallback.'
            })
    }

    $invPath = Join-Path $extRoot 'external_parse_inventory.csv'
    @($rows) | Export-Csv -Path $invPath -NoTypeInformation -Encoding UTF8
    return @($rows)
}