Public/Invoke-OffsetClamScan.ps1

function Invoke-OffsetClamScan {
    <#
    .SYNOPSIS
        Scans a file with the ClamAV on-demand engine and returns a normalized detection result.

    .DESCRIPTION
        Runs clamscan against a file and returns a result object with Status (Clean, Detected, or
        Error), the reported signature name, and the raw provider exit code. This adds a
        cross-platform, antivirus-independent detection lens alongside AMSI/Defender and YARA.
        Because clamscan loads its full signature database on every invocation, this is a
        single-file detector, not a boundary-search provider (that would require the clamd
        daemon). Requires ClamAV installed with signature databases (freshclam).

    .PARAMETER FilePath
        The file to scan.

    .PARAMETER ClamScanPath
        Optional explicit path to clamscan (otherwise resolved from PATH / default install).

    .PARAMETER DatabasePath
        Optional signature-database directory passed to clamscan --database.

    .PARAMETER TimeoutSeconds
        Maximum seconds to wait for the scan (default 120).

    .EXAMPLE
        Invoke-OffsetClamScan .\sample.bin
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string]$FilePath,

        [string]$ClamScanPath,

        [string]$DatabasePath,

        [ValidateRange(1, 3600)]
        [int]$TimeoutSeconds = 120
    )

    $resolvedFile = (Resolve-Path -LiteralPath $FilePath -ErrorAction Stop).Path
    $clamScan = Resolve-OIClamScanExecutable -ClamScanPath $ClamScanPath

    $arguments = New-Object 'System.Collections.Generic.List[string]'
    $arguments.Add('--no-summary')
    if (-not [string]::IsNullOrWhiteSpace($DatabasePath)) {
        $arguments.Add('--database=' + (Resolve-Path -LiteralPath $DatabasePath -ErrorAction Stop).Path)
    }
    $arguments.Add($resolvedFile)

    # Single quoted argument string (WinPS 5.1's ProcessStartInfo has no ArgumentList).
    $quoted = @($arguments | ForEach-Object {
        if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ }
    })

    $startInfo = New-Object System.Diagnostics.ProcessStartInfo
    $startInfo.FileName = $clamScan
    $startInfo.Arguments = ($quoted -join ' ')
    $startInfo.RedirectStandardOutput = $true
    $startInfo.RedirectStandardError = $true
    $startInfo.UseShellExecute = $false
    $startInfo.CreateNoWindow = $true

    $process = $null
    try {
        $process = [System.Diagnostics.Process]::Start($startInfo)
        $stdoutTask = $process.StandardOutput.ReadToEndAsync()
        $stderrTask = $process.StandardError.ReadToEndAsync()
        if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
            try { $process.Kill() } catch { Write-Verbose "clamscan kill failed: $($_.Exception.Message)" }
            throw "ClamAV scan timed out after $TimeoutSeconds seconds."
        }
        $stdout = $stdoutTask.Result
        $stderr = $stderrTask.Result
        $exitCode = $process.ExitCode
    }
    finally {
        if ($null -ne $process) { $process.Dispose() }
    }

    return (ConvertFrom-OIClamScanOutput -Output $stdout -ExitCode $exitCode -FilePath $resolvedFile -RawError $stderr)
}