Private/Timeline.ps1

function Get-ICTimelineTitle {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$Record,

        [Parameter(Mandatory)]
        [string]$TimestampProperty
    )

    foreach ($name in @('Message', 'ThreatName', 'Name', 'TaskName', 'ProcessName', 'DisplayName', 'Title')) {
        $value = Get-ICPropertyValue -InputObject $Record -Name $name
        if (-not [string]::IsNullOrWhiteSpace([string]$value)) {
            $text = ([string]$value -replace "`r?`n", ' ').Trim()
            if ($text.Length -gt 240) {
                return $text.Substring(0, 237) + '...'
            }
            return $text
        }
    }

    return $TimestampProperty
}

function ConvertTo-ICTimelineIdentifier {
    [CmdletBinding()]
    param(
        [AllowNull()]
        [object]$Value
    )

    if ($null -eq $Value -or $Value -is [string]) {
        return $Value
    }

    if (
        $Value -is [byte] -or $Value -is [sbyte] -or
        $Value -is [int16] -or $Value -is [uint16] -or
        $Value -is [int32] -or $Value -is [uint32] -or
        $Value -is [int64] -or $Value -is [uint64]
    ) {
        return $Value
    }

    return [string]$Value
}

function ConvertTo-ICTimelineEntry {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$Record,

        [Parameter(Mandatory)]
        [string]$TimestampProperty,

        [Parameter(Mandatory)]
        [string]$Collector,

        [Parameter(Mandatory)]
        [string]$SourcePath,

        [Parameter(Mandatory)]
        [ValidateRange(0, 2147483647)]
        [int]$SourceIndex
    )

    $rawTimestamp = Get-ICPropertyValue -InputObject $Record -Name $TimestampProperty
    if ([string]::IsNullOrWhiteSpace([string]$rawTimestamp)) {
        return $null
    }

    $parsed = [datetimeoffset]::MinValue
    $styles = [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal
    if (-not [datetimeoffset]::TryParse(
        [string]$rawTimestamp,
        [System.Globalization.CultureInfo]::InvariantCulture,
        $styles,
        [ref]$parsed
    )) {
        return $null
    }

    $recordId = Get-ICPropertyValue -InputObject $Record -Name 'RecordId'
    if ($null -eq $recordId) {
        $recordId = Get-ICPropertyValue -InputObject $Record -Name 'DetectionID'
    }
    if ($null -eq $recordId) {
        $recordId = Get-ICPropertyValue -InputObject $Record -Name 'ProcessId'
    }

    $eventId = Get-ICPropertyValue -InputObject $Record -Name 'Id'
    $providerValue = Get-ICPropertyValue -InputObject $Record -Name 'ProviderName'
    $provider = if ($null -ne $providerValue) { [string]$providerValue } else { $null }
    return [pscustomobject][ordered]@{
        timestampUtc = $parsed.UtcDateTime.ToString('o', [System.Globalization.CultureInfo]::InvariantCulture)
        collector    = $Collector
        eventType    = $TimestampProperty
        title        = Get-ICTimelineTitle -Record $Record -TimestampProperty $TimestampProperty
        source       = $SourcePath
        sourceIndex  = $SourceIndex
        recordId     = ConvertTo-ICTimelineIdentifier -Value $recordId
        eventId      = ConvertTo-ICTimelineIdentifier -Value $eventId
        provider     = $provider
    }
}

function Select-ICNewestTimelineEntry {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]]$Entries,

        [Parameter(Mandatory)]
        [ValidateRange(1, 5000000)]
        [int]$MaximumEntries
    )

    return @(
        $Entries |
            Sort-Object -Property `
                @{ Expression = { [string]$_.timestampUtc }; Descending = $true },
                @{ Expression = { [string]$_.source }; Ascending = $true },
                @{ Expression = { [string]$_.collector }; Ascending = $true },
                @{ Expression = { [string]$_.eventType }; Ascending = $true },
                @{ Expression = { [int]$_.sourceIndex }; Ascending = $true },
                @{ Expression = { [string]$_.recordId }; Ascending = $true } |
            Select-Object -First $MaximumEntries
    )
}

function Limit-ICTimelineBuffer {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [System.Collections.ArrayList]$Buffer,

        [Parameter(Mandatory)]
        [ValidateRange(1, 5000000)]
        [int]$MaximumEntries
    )

    if ($Buffer.Count -le $MaximumEntries) {
        return
    }

    $selected = @(Select-ICNewestTimelineEntry -Entries @($Buffer) -MaximumEntries $MaximumEntries)
    [void]$Buffer.Clear()
    foreach ($entry in $selected) {
        [void]$Buffer.Add($entry)
    }
}

function Get-ICTimelineEntriesFromEnvelope {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path,

        [Parameter(Mandatory)]
        [string]$CapsuleRoot,

        [ValidateRange(1, 5000000)]
        [int]$MaximumEntries = 2000
    )

    try {
        $relativePath = Get-ICRelativePath -BasePath $CapsuleRoot -Path $Path
        $envelope = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
    }
    catch {
        return [pscustomobject][ordered]@{
            ReadSucceeded        = $false
            CandidateCount       = 0L
            InvalidTimestampCount = 0L
            Entries              = @()
        }
    }

    $collector = [string](Get-ICPropertyValue -InputObject $envelope -Name 'collector' -Default 'Unknown')
    if ([string]::IsNullOrWhiteSpace($collector)) {
        $collector = 'Unknown'
    }
    $data = Get-ICPropertyValue -InputObject $envelope -Name 'data'
    if ($null -eq $data) {
        return [pscustomobject][ordered]@{
            ReadSucceeded        = $true
            CandidateCount       = 0L
            InvalidTimestampCount = 0L
            Entries              = @()
        }
    }

    $timestampProperties = @(
        'TimeCreatedUtc',
        'CreationDateUtc',
        'CreationTimeUtc',
        'InitialDetectionTimeUtc',
        'LastThreatStatusChangeTimeUtc',
        'RemediationTimeUtc',
        'LastRunTimeUtc',
        'NextRunTimeUtc',
        'InstallDateUtc',
        'DateUtc',
        'DriverDateUtc',
        'LastBootUpTimeUtc',
        'BootTimeUtc',
        'StartTimeUtc',
        'LastUseTimeUtc',
        'LastLogonUtc',
        'PasswordLastSetUtc',
        'PasswordExpiresUtc',
        'AccountExpiresUtc',
        'AntivirusSignatureLastUpdatedUtc',
        'AntispywareSignatureLastUpdatedUtc',
        'NISSignatureLastUpdatedUtc',
        'QuickScanEndTimeUtc',
        'FullScanEndTimeUtc',
        'CapturedAtUtc'
    )

    $entries = New-Object System.Collections.ArrayList
    $candidateCount = 0L
    $invalidTimestampCount = 0L
    $pruneThreshold = $MaximumEntries + [math]::Min($MaximumEntries, 1024)
    $sourceIndex = -1
    foreach ($record in @($data)) {
        $sourceIndex++
        if ($null -eq $record) {
            continue
        }
        foreach ($propertyName in $timestampProperties) {
            if ($null -eq $record.PSObject.Properties[$propertyName]) {
                continue
            }
            $entry = ConvertTo-ICTimelineEntry `
                -Record $record `
                -TimestampProperty $propertyName `
                -Collector $collector `
                -SourcePath $relativePath `
                -SourceIndex $sourceIndex
            if ($null -eq $entry) {
                $invalidTimestampCount++
                continue
            }

            $candidateCount++
            [void]$entries.Add($entry)
            if ($entries.Count -ge $pruneThreshold) {
                Limit-ICTimelineBuffer -Buffer $entries -MaximumEntries $MaximumEntries
            }
        }
    }
    Limit-ICTimelineBuffer -Buffer $entries -MaximumEntries $MaximumEntries

    return [pscustomobject][ordered]@{
        ReadSucceeded         = $true
        CandidateCount        = $candidateCount
        InvalidTimestampCount = $invalidTimestampCount
        Entries               = @($entries)
    }
}

function New-ICTimelineIndex {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$Context
    )

    $configuration = Get-ICPropertyValue -InputObject $Context -Name 'Configuration'
    $maximumEntries = [int](Get-ICPropertyValue -InputObject $configuration -Name 'MaximumTimelineEntries' -Default 2000)
    if ($maximumEntries -lt 1 -or $maximumEntries -gt 5000000) {
        $maximumEntries = 2000
    }

    $candidates = New-Object System.Collections.ArrayList
    $jsonFiles = @(
        Get-ChildItem -LiteralPath $Context.EvidencePath -Filter '*.json' -Recurse -File -ErrorAction SilentlyContinue |
            Sort-Object FullName
    )
    $totalCandidates = 0L
    $invalidTimestampCount = 0L
    $sourceFilesRead = 0
    $sourceFilesFailed = 0
    $pruneThreshold = $maximumEntries + [math]::Min($maximumEntries, 1024)
    foreach ($file in $jsonFiles) {
        $fileResult = Get-ICTimelineEntriesFromEnvelope `
            -Path $file.FullName `
            -CapsuleRoot $Context.RootPath `
            -MaximumEntries $maximumEntries
        if (-not $fileResult.ReadSucceeded) {
            $sourceFilesFailed++
            continue
        }

        $sourceFilesRead++
        $totalCandidates += [int64]$fileResult.CandidateCount
        $invalidTimestampCount += [int64]$fileResult.InvalidTimestampCount
        foreach ($entry in @($fileResult.Entries)) {
            [void]$candidates.Add($entry)
            if ($candidates.Count -ge $pruneThreshold) {
                Limit-ICTimelineBuffer -Buffer $candidates -MaximumEntries $maximumEntries
            }
        }
    }
    Limit-ICTimelineBuffer -Buffer $candidates -MaximumEntries $maximumEntries

    $selected = @(
        $candidates |
            Sort-Object -Property `
                @{ Expression = { [string]$_.timestampUtc }; Ascending = $true },
                @{ Expression = { [string]$_.source }; Ascending = $true },
                @{ Expression = { [string]$_.collector }; Ascending = $true },
                @{ Expression = { [string]$_.eventType }; Ascending = $true },
                @{ Expression = { [int]$_.sourceIndex }; Ascending = $true },
                @{ Expression = { [string]$_.recordId }; Ascending = $true }
    )
    $truncated = $totalCandidates -gt $selected.Count

    $analysisDirectory = Join-Path $Context.RootPath 'analysis'
    if (-not (Test-Path -LiteralPath $analysisDirectory -PathType Container)) {
        New-Item -ItemType Directory -Path $analysisDirectory -Force | Out-Null
    }

    $jsonPath = Join-Path $analysisDirectory 'timeline.json'
    $csvPath = Join-Path $analysisDirectory 'timeline.csv'
    $timeline = [ordered]@{
        '$schema'             = $script:ICTimelineSchema
        schemaVersion         = $script:ICSchemaVersion
        capsuleId             = $Context.CapsuleId
        generatedAtUtc        = [datetime]::UtcNow.ToString('o')
        sourceFiles           = $jsonFiles.Count
        sourceFilesRead       = $sourceFilesRead
        sourceFilesFailed     = $sourceFilesFailed
        candidateCount        = $totalCandidates
        invalidTimestampCount = $invalidTimestampCount
        entryCount            = $selected.Count
        maximumEntries        = $maximumEntries
        truncated             = $truncated
        entries               = @($selected)
    }

    [void](Write-ICJsonFile -Path $jsonPath -InputObject $timeline -Depth 12)
    [void](Write-ICCsvFile -Path $csvPath -InputObject $selected -SpreadsheetSafe ([bool](Get-ICPropertyValue -InputObject $Context.Configuration -Name 'SpreadsheetSafeCsv' -Default $true)))

    $result = [pscustomobject][ordered]@{
        JsonPath              = $jsonPath
        CsvPath               = $csvPath
        SourceFiles           = $jsonFiles.Count
        SourceFilesRead       = $sourceFilesRead
        SourceFilesFailed     = $sourceFilesFailed
        CandidateCount        = $totalCandidates
        InvalidTimestampCount = $invalidTimestampCount
        EntryCount            = $selected.Count
        MaximumEntries        = $maximumEntries
        Truncated             = $truncated
        Entries               = @($selected)
    }
    $Context | Add-Member -NotePropertyName Timeline -NotePropertyValue $result -Force
    return $result
}