Private/Threat.Drift.ps1

function Get-OIDefenderSignatureStatus {
    <#
        Returns the local Microsoft Defender signature/engine versions so a drift entry can
        record what the provider knew at scan time. All fields are null when Defender or
        Get-MpComputerStatus is unavailable (non-Windows, no Defender), which keeps the
        journal cross-platform - the entry is still written, just without provider versions.
    #>

    [CmdletBinding()]
    param()

    $result = [pscustomobject]@{
        SignatureVersion     = $null
        SignatureLastUpdated = $null
        EngineVersion        = $null
    }

    $command = Get-Command Get-MpComputerStatus -ErrorAction SilentlyContinue
    if ($null -eq $command) { return $result }

    try {
        $status = Get-MpComputerStatus -ErrorAction Stop
        $result.SignatureVersion = $status.AntivirusSignatureVersion
        $lastUpdated = $status.AntivirusSignatureLastUpdated
        if ($null -ne $lastUpdated) { $result.SignatureLastUpdated = ([datetime]$lastUpdated).ToUniversalTime().ToString('o') }
        $result.EngineVersion = $status.AMEngineVersion
    }
    catch {
        Write-Verbose "Defender signature status unavailable: $($_.Exception.Message)"
    }

    return $result
}

function New-OIDriftRecord {
    <#
        Builds one drift snapshot record from already-collected fields. Pure and
        cross-platform; the public wrapper does the file I/O and Defender query.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$File,

        [AllowNull()]
        [string]$FileSha256,

        [AllowNull()]
        [object]$FileSize,

        [AllowNull()]
        [string]$Engine,

        [AllowNull()]
        [string]$Status,

        [AllowNull()]
        [object]$DetectionBoundaryOffset,

        [AllowNull()]
        [string]$SignatureName,

        [AllowNull()]
        $SignatureStatus,

        [AllowNull()]
        [string]$TimestampUtc
    )

    $detected = $false
    if (-not [string]::IsNullOrWhiteSpace($Status)) {
        $detected = ($Status -match '^(?i)detected$')
    }

    return [pscustomobject]@{
        PSTypeName              = 'OffsetInspect.DriftEntry'
        TimestampUtc            = if ([string]::IsNullOrWhiteSpace($TimestampUtc)) { [DateTime]::UtcNow.ToString('o') } else { $TimestampUtc }
        File                    = $File
        FileSha256              = $FileSha256
        FileSize                = $FileSize
        Engine                  = $Engine
        Status                  = $Status
        Detected                = $detected
        DetectionBoundaryOffset = $DetectionBoundaryOffset
        SignatureName           = $SignatureName
        SignatureVersion        = if ($null -ne $SignatureStatus) { $SignatureStatus.SignatureVersion } else { $null }
        EngineVersion           = if ($null -ne $SignatureStatus) { $SignatureStatus.EngineVersion } else { $null }
        Host                    = [System.Environment]::MachineName
    }
}

function Compare-OIDriftTimeline {
    <#
        Given the snapshots for a single file ordered oldest-first, produces a drift timeline
        and, for each consecutive pair, an explanation that disambiguates the crucial question:
        did detection change because the FILE changed, because Defender's SIGNATURES changed,
        or for neither reason (non-deterministic provider result)? Pure and cross-platform.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [object[]]$Snapshots
    )

    $get = { param($o, $n) Get-OIResultProperty -InputObject $o -Name $n }

    $transitions = New-Object 'System.Collections.Generic.List[object]'
    for ($i = 1; $i -lt $Snapshots.Count; $i++) {
        $from = $Snapshots[$i - 1]
        $to = $Snapshots[$i]

        $fromStatus = [string](& $get $from 'Status')
        $toStatus = [string](& $get $to 'Status')
        $fromVer = & $get $from 'SignatureVersion'
        $toVer = & $get $to 'SignatureVersion'

        $statusChanged = ($fromStatus -ne $toStatus)
        $hashChanged = ((& $get $from 'FileSha256') -ne (& $get $to 'FileSha256'))
        $signatureChanged = ((& $get $from 'SignatureName') -ne (& $get $to 'SignatureName'))
        $boundaryChanged = ((& $get $from 'DetectionBoundaryOffset') -ne (& $get $to 'DetectionBoundaryOffset'))
        $sigVerChanged = ($fromVer -ne $toVer)

        $explanation = if ($hashChanged) {
            'File content changed between snapshots (sample modified) - detection differences are not attributable to signatures.'
        }
        elseif ($statusChanged -and $sigVerChanged) {
            "Detection changed with the file unchanged while Defender signatures updated ($fromVer -> $toVer) - signature drift."
        }
        elseif ($statusChanged) {
            'Detection changed with the file unchanged and no signature update - non-deterministic or environmental provider result.'
        }
        elseif ($boundaryChanged) {
            'Detection boundary moved with the file unchanged - signature refinement or provider flux.'
        }
        elseif ($sigVerChanged) {
            "Signatures updated ($fromVer -> $toVer) with no change in detection."
        }
        elseif ($signatureChanged) {
            # By this point status, hash, boundary, and version are all unchanged.
            'Signature name changed with the file, status, and boundary unchanged - provider reclassification or rule re-labeling.'
        }
        else {
            'No change.'
        }

        $transitions.Add([pscustomobject]@{
            FromUtc                 = & $get $from 'TimestampUtc'
            ToUtc                   = & $get $to 'TimestampUtc'
            FromStatus              = $fromStatus
            ToStatus                = $toStatus
            StatusChanged           = $statusChanged
            HashChanged             = $hashChanged
            SignatureChanged        = $signatureChanged
            BoundaryChanged         = $boundaryChanged
            SignatureVersionChanged = $sigVerChanged
            FromSignatureVersion    = $fromVer
            ToSignatureVersion      = $toVer
            Explanation             = $explanation
        })
    }

    $distinctHashes = @($Snapshots | ForEach-Object { & $get $_ 'FileSha256' } | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | Select-Object -Unique)
    $everChanged = @($transitions | Where-Object { $_.StatusChanged -or $_.BoundaryChanged -or $_.HashChanged }).Count -gt 0
    $last = if ($Snapshots.Count -gt 0) { $Snapshots[$Snapshots.Count - 1] } else { $null }

    return [pscustomobject]@{
        PSTypeName     = 'OffsetInspect.DriftReport'
        File           = if ($null -ne $last) { & $get $last 'File' } else { $null }
        SnapshotCount  = $Snapshots.Count
        DistinctHashes = $distinctHashes.Count
        FirstSeenUtc   = if ($Snapshots.Count -gt 0) { & $get $Snapshots[0] 'TimestampUtc' } else { $null }
        LastSeenUtc    = if ($null -ne $last) { & $get $last 'TimestampUtc' } else { $null }
        CurrentStatus  = if ($null -ne $last) { & $get $last 'Status' } else { $null }
        EverChanged    = $everChanged
        Transitions    = $transitions.ToArray()
        Snapshots      = $Snapshots
    }
}