Public/Invoke-IncidentCapsule.ps1

function Invoke-IncidentCapsule {
    <#
    .SYNOPSIS
    Collects a local Windows first-response evidence capsule.

    .DESCRIPTION
    Runs isolated read-only collectors, writes structured evidence and native artifacts,
    generates an offline HTML report, creates a SHA-256 manifest, and optionally creates
    and verifies a ZIP archive with a sidecar checksum and external verification receipt.

    Collection is local only. Individual collectors can complete partially when a source
    is unavailable or the current token lacks access; those limitations are recorded.

    .PARAMETER OutputPath
    Parent directory for the capsule directory, ZIP archive, sidecar checksum, and receipt.

    .PARAMETER CaseId
    External case or incident identifier stored in metadata and the folder name.

    .PARAMETER Operator
    Operator identifier. Defaults to the current Windows identity.

    .PARAMETER Profile
    Built-in Minimal, Standard, or Extended profile.

    .PARAMETER ConfigurationPath
    Optional PowerShell data file whose values override profile defaults.

    .PARAMETER Collectors
    Optional explicit collector list. Replaces the profile collector list.

    .PARAMETER ExcludeCollector
    Collector names removed after profile, configuration, and explicit selection are resolved.

    .PARAMETER SigningCertificate
    Optional code-signing certificate (X509Certificate2 object or thumbprint in
    Cert:\CurrentUser\My or Cert:\LocalMachine\My) with an accessible private key.
    When present, the checksum list metadata/manifest.sha256 receives a detached
    CMS signature metadata/manifest.sha256.p7s that travels inside the capsule
    and the archive, and archive verification requires that signature.

    .PARAMETER NoCompression
    Keep the evidence directory without creating a ZIP archive.

    .PARAMETER RemoveWorkingDirectory
    Remove the evidence directory only after the newly created archive has passed a full
    archive hash, safe extraction, checksum-list, and embedded-manifest verification.

    .EXAMPLE
    $result = Invoke-IncidentCapsule -OutputPath 'C:\IR\Cases' -CaseId 'IR-2026-0042' -Profile Standard
    Start-Process $result.ReportPath

    .EXAMPLE
    Invoke-IncidentCapsule -OutputPath 'E:\Evidence' -CaseId 'IR-2026-0042' -Profile Extended -RemoveWorkingDirectory

    .OUTPUTS
    IncidentCapsule.Result
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [string]$OutputPath = (Get-Location).Path,

        [string]$CaseId = ("IR-{0}" -f (Get-Date -Format 'yyyyMMdd-HHmmss')),

        [string]$Operator = (Get-ICCurrentUser),

        [ValidateSet('Minimal', 'Standard', 'Extended')]
        [string]$Profile = 'Standard',

        [string]$ConfigurationPath,

        [ValidateSet(
            'System', 'Storage', 'Processes', 'Services', 'Network', 'Sessions',
            'LocalAccounts', 'ScheduledTasks', 'Persistence', 'Defender', 'PowerShell',
            'SecurityConfiguration', 'Hotfixes', 'Drivers', 'EventLogs',
            'InstalledSoftware', 'Certificates', 'ExecutionArtifacts', 'Devices'
        )]
        [string[]]$Collectors,

        [ValidateSet(
            'System', 'Storage', 'Processes', 'Services', 'Network', 'Sessions',
            'LocalAccounts', 'ScheduledTasks', 'Persistence', 'Defender', 'PowerShell',
            'SecurityConfiguration', 'Hotfixes', 'Drivers', 'EventLogs',
            'InstalledSoftware', 'Certificates', 'ExecutionArtifacts', 'Devices'
        )]
        [string[]]$ExcludeCollector,

        [AllowNull()]
        [object]$SigningCertificate,

        [switch]$NoCompression,

        [switch]$RemoveWorkingDirectory
    )

    if (-not (Test-ICWindows)) {
        throw 'Incident Capsule can collect evidence only on Windows.'
    }
    if ($RemoveWorkingDirectory -and $NoCompression) {
        throw '-RemoveWorkingDirectory requires archive creation and cannot be combined with -NoCompression.'
    }
    if ([string]::IsNullOrWhiteSpace($CaseId)) {
        throw 'CaseId cannot be empty.'
    }
    if ([string]::IsNullOrWhiteSpace($Operator)) {
        throw 'Operator cannot be empty.'
    }

    $configurationParameters = @{ Profile = $Profile }
    if ($PSBoundParameters.ContainsKey('ConfigurationPath')) {
        $configurationParameters.ConfigurationPath = $ConfigurationPath
    }
    if ($PSBoundParameters.ContainsKey('Collectors')) {
        $configurationParameters.Collectors = $Collectors
    }
    if ($PSBoundParameters.ContainsKey('ExcludeCollector')) {
        $configurationParameters.ExcludeCollector = $ExcludeCollector
    }

    $resolvedSigningCertificate = $null
    if ($PSBoundParameters.ContainsKey('SigningCertificate') -and $null -ne $SigningCertificate) {
        $resolvedSigningCertificate = Resolve-ICSigningCertificate -Certificate $SigningCertificate
    }

    $configuration = Resolve-ICConfiguration @configurationParameters
    $context = New-ICContext -OutputPath $OutputPath -CaseId $CaseId -Operator $Operator -Profile $Profile -Configuration $configuration
    $fatalException = $null
    $finalizationFailed = $false

    try {
        Write-ICLog -Context $context -Level INFO -Component Core -Message ("Incident Capsule {0} started for case '{1}' on host '{2}' using profile '{3}'." -f $script:ICVersion, $CaseId, $context.HostName, $Profile)
        if (-not $context.IsElevated) {
            Write-ICLog -Context $context -Level WARN -Component Core -Message 'Session is not elevated; protected evidence sources can be partial.'
        }

        [void](Invoke-ICCollectors -Context $context)
    }
    catch {
        $fatalException = $_
        $context.FatalError = "Collection orchestration failed: $($_.Exception.Message)"
        try { Write-ICLog -Context $context -Level ERROR -Component Core -Message $context.FatalError } catch { $null = $_ }
    }

    $context.CompletedAtUtc = [datetime]::UtcNow
    $context.CollectionStatus = Get-ICOverallStatus -CollectorResults @($context.CollectorResults) -FatalError $context.FatalError
    $context.Status = $context.CollectionStatus
    $context.FinalizationStatus = 'Preparing'

    try {
        [void](New-ICTimelineIndex -Context $context)
    }
    catch {
        if ($null -eq $fatalException) { $fatalException = $_ }
        $message = "Timeline finalization failed: $($_.Exception.Message)"
        $context.FatalError = if ([string]::IsNullOrWhiteSpace($context.FatalError)) { $message } else { "$($context.FatalError) | $message" }
        $context.Status = 'Failed'
        $finalizationFailed = $true
        try { Write-ICLog -Context $context -Level ERROR -Component Core -Message $message } catch { $null = $_ }
    }

    try {
        [void](Write-ICCoverageData -Context $context)
    }
    catch {
        if ($null -eq $fatalException) { $fatalException = $_ }
        $message = "Coverage finalization failed: $($_.Exception.Message)"
        $context.FatalError = if ([string]::IsNullOrWhiteSpace($context.FatalError)) { $message } else { "$($context.FatalError) | $message" }
        $context.Status = 'Failed'
        $finalizationFailed = $true
        try { Write-ICLog -Context $context -Level ERROR -Component Core -Message $message } catch { $null = $_ }
    }

    try {
        [void](New-ICHtmlReport -Context $context)
    }
    catch {
        if ($null -eq $fatalException) { $fatalException = $_ }
        $message = "Report generation failed: $($_.Exception.Message)"
        $context.FatalError = if ([string]::IsNullOrWhiteSpace($context.FatalError)) { $message } else { "$($context.FatalError) | $message" }
        $context.Status = 'Failed'
        $finalizationFailed = $true
        try { Write-ICLog -Context $context -Level ERROR -Component Core -Message $message } catch { $null = $_ }
    }

    try {
        $budgetState = Get-ICCapsuleBudgetState -Context $context
        if (-not $budgetState.IsWithinBudget) {
            throw (New-Object System.IO.InvalidDataException(
                "Capsule size $($budgetState.CurrentBytes) bytes exceeds the configured MaximumCapsuleBytes limit of $($budgetState.MaximumBytes) bytes."
            ))
        }
    }
    catch {
        if ($null -eq $fatalException) { $fatalException = $_ }
        $message = "Capsule resource-budget finalization failed: $($_.Exception.Message)"
        $context.FatalError = if ([string]::IsNullOrWhiteSpace($context.FatalError)) { $message } else { "$($context.FatalError) | $message" }
        $context.Status = 'Failed'
        $finalizationFailed = $true
        try { Write-ICLog -Context $context -Level ERROR -Component Core -Message $message } catch { $null = $_ }
    }

    $context.FinalizationStatus = if ($finalizationFailed) { 'Failed' } else { 'Sealing' }
    try {
        $metadata = New-ICCapsuleMetadata -Context $context
        [void](Write-ICJsonFile -Path (Join-Path $context.MetadataPath 'capsule.json') -InputObject $metadata -Depth 30)
    }
    catch {
        if ($null -eq $fatalException) { $fatalException = $_ }
        $message = "Metadata finalization failed: $($_.Exception.Message)"
        $context.FatalError = if ([string]::IsNullOrWhiteSpace($context.FatalError)) { $message } else { "$($context.FatalError) | $message" }
        $context.Status = 'Failed'
        $context.FinalizationStatus = 'Failed'
        $finalizationFailed = $true
        try { Write-ICLog -Context $context -Level ERROR -Component Core -Message $message } catch { $null = $_ }
    }

    try {
        Write-ICLog -Context $context -Level INFO -Component Core -Message ("Collection frozen with acquisition state {0}. Manifest generation follows; no collected file will be modified." -f $context.CollectionStatus)
    }
    catch {
        if ($null -eq $fatalException) { $fatalException = $_ }
        $message = "Final collection log write failed: $($_.Exception.Message)"
        $context.FatalError = if ([string]::IsNullOrWhiteSpace($context.FatalError)) { $message } else { "$($context.FatalError) | $message" }
        $context.Status = 'Failed'
        $context.FinalizationStatus = 'Failed'
        $finalizationFailed = $true
    }
    $context.Frozen = $true

    $manifestResult = $null
    $archiveResult = $null
    $verification = $null
    $archiveVerification = $null
    $verificationReceiptPath = $null
    $manifestSignature = $null
    try {
        $manifestResult = New-ICManifest -CapsuleRoot $context.RootPath -CapsuleId $context.CapsuleId
        $context.ManifestPath = $manifestResult.ManifestPath
        $context.ManifestTextPath = $manifestResult.TextPath
        if ($null -ne $resolvedSigningCertificate) {
            $manifestSignature = New-ICManifestSignature -ManifestTextPath $manifestResult.TextPath -Certificate $resolvedSigningCertificate
        }
        $sealedBudgetState = Get-ICCapsuleBudgetState -Context $context
        if (-not $sealedBudgetState.IsWithinBudget) {
            throw (New-Object System.IO.InvalidDataException(
                "Sealed capsule size $($sealedBudgetState.CurrentBytes) bytes exceeds the configured MaximumCapsuleBytes limit of $($sealedBudgetState.MaximumBytes) bytes."
            ))
        }
        if (-not $finalizationFailed) { $context.FinalizationStatus = 'ManifestCreated' }
    }
    catch {
        if ($null -eq $fatalException) { $fatalException = $_ }
        $message = "Manifest finalization failed: $($_.Exception.Message)"
        $context.FatalError = if ([string]::IsNullOrWhiteSpace($context.FatalError)) { $message } else { "$($context.FatalError) | $message" }
        $context.Status = 'Failed'
        $context.FinalizationStatus = 'Failed'
        $finalizationFailed = $true
    }

    if ($null -ne $manifestResult) {
        try {
            $verification = Test-ICDirectoryIntegrity -CapsuleRoot $context.RootPath
            if (-not $verification.IsValid) {
                throw (New-Object System.IO.InvalidDataException(
                    "Directory manifest verification failed: $($verification.FilesMissing) missing, $($verification.FilesModified) modified, and $($verification.FilesUnexpected) unexpected file(s)."
                ))
            }
            if (-not $finalizationFailed) { $context.FinalizationStatus = 'DirectoryVerified' }
        }
        catch {
            if ($null -eq $fatalException) { $fatalException = $_ }
            $message = "Post-collection integrity verification failed: $($_.Exception.Message)"
            $context.FatalError = if ([string]::IsNullOrWhiteSpace($context.FatalError)) { $message } else { "$($context.FatalError) | $message" }
            $context.Status = 'Failed'
            $context.FinalizationStatus = 'Failed'
            $finalizationFailed = $true
        }
    }

    if (-not $NoCompression -and $null -ne $verification -and $verification.IsValid) {
        try {
            $archiveResult = New-ICArchive -CapsuleRoot $context.RootPath
            $context.ArchivePath = $archiveResult.ArchivePath
            $context.ArchiveHashPath = $archiveResult.SidecarPath
            if (-not $finalizationFailed) { $context.FinalizationStatus = 'Archived' }

            $archiveVerification = Test-IncidentCapsuleIntegrity `
                -Path $archiveResult.ArchivePath `
                -RequireSidecar `
                -RequireSignature:($null -ne $manifestSignature) `
                -MaximumArchiveEntries ([int]$configuration.MaximumArchiveEntries) `
                -MaximumArchiveEntryBytes ([int64]$configuration.MaximumArchiveEntryBytes) `
                -MaximumArchiveExpandedBytes ([int64]$configuration.MaximumArchiveExpandedBytes) `
                -MaximumArchiveCompressionRatio ([double]$configuration.MaximumArchiveCompressionRatio)
            $verificationReceiptPath = Write-ICVerificationReceipt -ArchivePath $archiveResult.ArchivePath -Verification $archiveVerification
            if (-not $archiveVerification.IsValid -or $archiveVerification.ArchiveHashValid -ne $true) {
                throw (New-Object System.IO.InvalidDataException('Archive verification did not validate both the sidecar checksum and the embedded manifest.'))
            }
        }
        catch {
            if ($null -eq $fatalException) { $fatalException = $_ }
            $message = "Archive finalization or verification failed: $($_.Exception.Message)"
            $context.FatalError = if ([string]::IsNullOrWhiteSpace($context.FatalError)) { $message } else { "$($context.FatalError) | $message" }
            $context.Status = 'Failed'
            $context.FinalizationStatus = 'Failed'
            $finalizationFailed = $true
        }
    }

    $integrityValid = $null -ne $verification -and [bool]$verification.IsValid
    if (-not $NoCompression) {
        $integrityValid = $integrityValid -and $null -ne $archiveVerification -and [bool]$archiveVerification.IsValid -and $archiveVerification.ArchiveHashValid -eq $true
    }
    if (-not $finalizationFailed -and $integrityValid) {
        $context.FinalizationStatus = 'Verified'
    }
    elseif ($finalizationFailed -or -not $integrityValid) {
        $context.FinalizationStatus = 'Failed'
    }

    if ($null -ne $fatalException) {
        $context.Status = 'Failed'
    }
    else {
        $context.Status = $context.CollectionStatus
    }

    $workingDirectory = $context.RootPath
    $reportPath = $context.ReportPath
    if ($RemoveWorkingDirectory -and $null -eq $fatalException -and $context.FinalizationStatus -eq 'Verified' -and $integrityValid) {
        try {
            $outputRoot = [System.IO.Path]::GetFullPath($context.OutputPath).TrimEnd([char]'\', [char]'/')
            $deletionTarget = [System.IO.Path]::GetFullPath($context.RootPath).TrimEnd([char]'\', [char]'/')
            $outputPrefix = $outputRoot + [System.IO.Path]::DirectorySeparatorChar
            if ($deletionTarget -eq $outputRoot -or -not $deletionTarget.StartsWith($outputPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
                throw "Refusing to remove working directory '$deletionTarget' because it is outside output root '$outputRoot'."
            }
            Remove-Item -LiteralPath $context.RootPath -Recurse -Force -ErrorAction Stop
            $workingDirectory = $null
            $reportPath = $null
        }
        catch {
            if ($null -eq $fatalException) { $fatalException = $_ }
            $message = "Verified archive was retained, but working-directory removal failed: $($_.Exception.Message)"
            $context.FatalError = if ([string]::IsNullOrWhiteSpace($context.FatalError)) { $message } else { "$($context.FatalError) | $message" }
            $context.Status = 'Failed'
            $context.FinalizationStatus = 'CleanupFailed'
            if (-not (Test-Path -LiteralPath $context.RootPath -PathType Container)) {
                $workingDirectory = $null
                $reportPath = $null
            }
        }
    }

    $context.CompletedAtUtc = [datetime]::UtcNow

    $result = [pscustomobject][ordered]@{
        PSTypeName              = 'IncidentCapsule.Result'
        CapsuleId              = $context.CapsuleId
        CaseId                 = $context.CaseId
        HostName               = $context.HostName
        Profile                = $context.Profile
        Status                 = $context.Status
        CollectionStatus       = $context.CollectionStatus
        FinalizationStatus     = $context.FinalizationStatus
        IsElevated             = $context.IsElevated
        StartedAtUtc           = $context.StartedAtUtc
        CompletedAtUtc         = $context.CompletedAtUtc
        DurationSeconds        = [math]::Round(($context.CompletedAtUtc - $context.StartedAtUtc).TotalSeconds, 3)
        WorkingDirectory       = $workingDirectory
        ReportPath             = $reportPath
        ManifestPath           = if ($null -ne $workingDirectory) { $context.ManifestPath } else { $null }
        ManifestSignaturePath  = if ($null -ne $manifestSignature -and $null -ne $workingDirectory) { $manifestSignature.SignaturePath } else { $null }
        SignerSubject          = if ($null -ne $manifestSignature) { $manifestSignature.SignerSubject } else { $null }
        SignerThumbprint       = if ($null -ne $manifestSignature) { $manifestSignature.SignerThumbprint } else { $null }
        ArchivePath            = $context.ArchivePath
        ArchiveChecksumPath    = $context.ArchiveHashPath
        ArchiveVerificationPath = $verificationReceiptPath
        ArchiveSHA256          = if ($null -ne $archiveResult) { $archiveResult.SHA256 } else { $null }
        IntegrityValid         = [bool]$integrityValid
        ArchiveIntegrityValid  = if ($NoCompression) { $null } else { [bool]($null -ne $archiveVerification -and $archiveVerification.IsValid -and $archiveVerification.ArchiveHashValid -eq $true) }
        CollectorResults       = @($context.CollectorResults)
        FatalError             = $context.FatalError
    }

    if ($null -ne $fatalException) {
        $partialPath = if ($null -ne $workingDirectory) { $workingDirectory } elseif ($null -ne $context.ArchivePath) { $context.ArchivePath } else { $context.RootPath }
        $exception = New-Object System.InvalidOperationException(
            "Incident Capsule completed with a fatal error. Partial output: '$partialPath'. $($context.FatalError)",
            $fatalException.Exception
        )
        $exception.Data['IncidentCapsuleResult'] = $result
        throw $exception
    }

    return $result
}