Forensics/Get-UserActivityArtifacts.ps1

function Get-UserActivityArtifacts {
    <#
    .SYNOPSIS
        Collects user activity forensic artifacts from Windows user profiles
    .DESCRIPTION
        Gathers per-user activity evidence for DFIR investigations:
        - Recycle Bin $I metadata files (header version 2 / Windows 10+): decodes
          original path, file size, and deletion time (FILETIME UTC). Version 1
          headers are decoded best-effort. Unknown versions or malformed headers
          are skipped with Verbose output.
        - Recent + Desktop LNK shortcuts: resolves target path, arguments, and
          working directory via WScript.Shell COM (per-file try/catch; targets
          are reported as unresolved off-Windows or when COM is unavailable).
        - JumpLists: enumerates AutomaticDestinations files with timestamps only.
          Embedded targets are NOT parsed - this is enumeration, labeled as such.
        - Windows Timeline (ActivitiesCache.db): copies the database with a raw
          FileStream (FileShare.ReadWrite fallback for locked files) and reports
          size/mtime only. No SQLite parsing is performed (no dependency).
        MITRE ATT&CK references are heuristic mappings, not assertions.
    .PARAMETER UserProfile
        One or more user profile paths (default: all profiles under C:\Users)
    .PARAMETER RecycleBinRoot
        Root of the Recycle Bin to scan for $I files (default: C:\$Recycle.Bin)
    .PARAMETER OutputPath
        Optional directory for CSV export (RecycleBin.csv, RecentLinks.csv,
        JumpLists.csv, Timeline.csv) and Timeline database copies
    .EXAMPLE
        Get-UserActivityArtifacts
        Get-UserActivityArtifacts -UserProfile "C:\Users\jdoe" -OutputPath "E:\Evidence\UserActivity"
    .OUTPUTS
        PSCustomObject with RecycleBin, RecentLinks, JumpLists, Timeline and Summary
    #>


    [CmdletBinding()]
    param(
        [Parameter()]
        [string[]]$UserProfile,

        [Parameter()]
        [string]$RecycleBinRoot = 'C:\$Recycle.Bin',

        [Parameter()]
        [string]$OutputPath
    )

    begin {
        Write-Host "=== USER ACTIVITY ARTIFACTS ===" -ForegroundColor Cyan
        Write-Host "Scan Date: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Yellow

        $results = [PSCustomObject]@{
            ScanDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
            ComputerName = $env:COMPUTERNAME
            ProfilesAnalyzed = @()
            RecycleBin = @()
            RecentLinks = @()
            JumpLists = @()
            Timeline = @()
            Summary = @{
                ProfilesAnalyzed = 0
                RecycleBinEntries = 0
                RecentLinks = 0
                ResolvedLinks = 0
                JumpListFiles = 0
                TimelineDatabases = 0
                Errors = 0
            }
        }

        # Resolve target profiles
        $profiles = @()
        if ($UserProfile) {
            foreach ($profile in $UserProfile) {
                if (Test-Path $profile -PathType Container) {
                    $profiles += (Get-Item $profile).FullName
                }
                else {
                    Write-Warning "User profile not found: $profile"
                }
            }
        }
        else {
            if (Test-Path 'C:\Users' -PathType Container) {
                $profiles = @(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue |
                    Select-Object -ExpandProperty FullName)
            }
            else {
                Write-Verbose "Default profile root C:\Users not found - no profiles to analyze"
            }
        }

        # WScript.Shell COM for LNK resolution (Windows only)
        $shellCom = $null
        $lnkResolutionAvailable = $false
        try {
            $shellCom = New-Object -ComObject WScript.Shell -ErrorAction Stop
            $lnkResolutionAvailable = $true
        }
        catch {
            Write-Verbose "WScript.Shell COM unavailable (non-Windows host or restricted) - LNK targets will be reported as unresolved"
        }

        function Read-RecycleBinMetadata {
            param([string]$Path)

            $stream = $null
            $reader = $null
            try {
                $stream = New-Object System.IO.FileStream($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
                if ($stream.Length -lt 24) {
                    Write-Verbose "Skipping $Path - file too small for an `$I header ($($stream.Length) bytes)"
                    return
                }

                $reader = New-Object System.IO.BinaryReader($stream)
                $headerVersion = $reader.ReadInt64()
                $fileSizeBytes = $reader.ReadInt64()
                $deletionFileTime = $reader.ReadInt64()

                $originalPath = $null
                if ($headerVersion -eq 2) {
                    if ($stream.Length -lt 28) {
                        Write-Verbose "Skipping $Path - truncated version 2 header"
                        return
                    }
                    $pathCharCount = $reader.ReadInt32()
                    if ($pathCharCount -le 0 -or $pathCharCount -gt 32767) {
                        Write-Verbose "Skipping $Path - implausible path length $pathCharCount in version 2 header"
                        return
                    }
                    $pathBytes = $reader.ReadBytes($pathCharCount * 2)
                    if ($pathBytes.Length -lt ($pathCharCount * 2)) {
                        Write-Verbose "Skipping $Path - truncated path data in version 2 header"
                        return
                    }
                    $originalPath = [System.Text.Encoding]::Unicode.GetString($pathBytes).TrimEnd([char]0)
                }
                elseif ($headerVersion -eq 1) {
                    if ($stream.Length -lt 544) {
                        Write-Verbose "Skipping $Path - truncated version 1 header"
                        return
                    }
                    $pathBytes = $reader.ReadBytes(520)
                    $rawPath = [System.Text.Encoding]::Unicode.GetString($pathBytes)
                    $nullIndex = $rawPath.IndexOf([char]0)
                    if ($nullIndex -ge 0) {
                        $originalPath = $rawPath.Substring(0, $nullIndex)
                    }
                    else {
                        $originalPath = $rawPath
                    }
                }
                else {
                    Write-Verbose "Skipping $Path - unsupported `$I header version $headerVersion"
                    return
                }

                $deletionTimeUtc = $null
                try {
                    $deletionTimeUtc = [DateTime]::FromFileTimeUtc($deletionFileTime)
                }
                catch {
                    Write-Verbose "Skipping deletion time in $Path - invalid FILETIME value $deletionFileTime"
                }

                return [PSCustomObject]@{
                    OriginalPath = $originalPath
                    FileSizeBytes = $fileSizeBytes
                    DeletionTimeUtc = $deletionTimeUtc
                    HeaderVersion = $headerVersion
                }
            }
            catch {
                Write-Verbose "Could not parse $Path - $_"
                return
            }
            finally {
                if ($null -ne $reader) { $reader.Dispose() }
                if ($null -ne $stream) { $stream.Dispose() }
            }
        }
    }

    process {
        try {
            # 1. RECYCLE BIN $I FILES (system-wide, tagged by owner SID folder)
            Write-Host "`n[1/4] Parsing Recycle Bin `$I metadata files..." -ForegroundColor Cyan

            try {
                if (Test-Path $RecycleBinRoot -PathType Container) {
                    $sidFolders = @(Get-ChildItem -Path $RecycleBinRoot -Directory -Force -ErrorAction SilentlyContinue)
                    $iFiles = @()
                    foreach ($sidFolder in $sidFolders) {
                        $iFiles += @(Get-ChildItem -Path $sidFolder.FullName -Filter '$I*' -File -Force -ErrorAction SilentlyContinue |
                            ForEach-Object { [PSCustomObject]@{ File = $_; OwnerSid = $sidFolder.Name } })
                    }

                    foreach ($entry in $iFiles) {
                        $metadata = Read-RecycleBinMetadata -Path $entry.File.FullName
                        if ($null -eq $metadata) {
                            continue
                        }

                        $results.RecycleBin += [PSCustomObject]@{
                            MetadataFile = $entry.File.FullName
                            OwnerSid = $entry.OwnerSid
                            OriginalPath = $metadata.OriginalPath
                            FileSizeBytes = $metadata.FileSizeBytes
                            DeletionTimeUtc = $metadata.DeletionTimeUtc
                            HeaderVersion = $metadata.HeaderVersion
                            MitreAttack = 'T1074.001 (heuristic - locally staged/deleted data)'
                        }
                    }

                    Write-Host " Decoded $(@($results.RecycleBin).Count) `$I files ($($iFiles.Count) found)" -ForegroundColor Green
                }
                else {
                    Write-Host " Recycle Bin root not found: $RecycleBinRoot" -ForegroundColor Gray
                }
            }
            catch {
                Write-Warning "Error parsing Recycle Bin artifacts - $_"
                $results.Summary.Errors++
            }

            # 2. RECENT + DESKTOP LNK SHORTCUTS
            Write-Host "`n[2/4] Analyzing Recent and Desktop shortcuts..." -ForegroundColor Cyan

            foreach ($profile in $profiles) {
                try {
                    $lnkLocations = @(
                        (Join-Path $profile 'AppData\Roaming\Microsoft\Windows\Recent'),
                        (Join-Path $profile 'Desktop')
                    )

                    foreach ($location in $lnkLocations) {
                        if (-not (Test-Path $location -PathType Container)) {
                            continue
                        }

                        $lnkFiles = @(Get-ChildItem -Path $location -Filter '*.lnk' -File -Force -ErrorAction SilentlyContinue)

                        foreach ($lnkFile in $lnkFiles) {
                            $targetPath = $null
                            $targetArguments = $null
                            $workingDirectory = $null
                            $resolved = $false

                            if ($lnkResolutionAvailable) {
                                try {
                                    $shortcut = $shellCom.CreateShortcut($lnkFile.FullName)
                                    if ($null -ne $shortcut) {
                                        if ($shortcut.PSObject.Properties['TargetPath']) { $targetPath = $shortcut.TargetPath }
                                        if ($shortcut.PSObject.Properties['Arguments']) { $targetArguments = $shortcut.Arguments }
                                        if ($shortcut.PSObject.Properties['WorkingDirectory']) { $workingDirectory = $shortcut.WorkingDirectory }
                                    }
                                    if (-not [string]::IsNullOrEmpty($targetPath)) {
                                        $resolved = $true
                                    }
                                }
                                catch {
                                    Write-Verbose "Could not resolve shortcut $($lnkFile.FullName) - $_"
                                }
                            }

                            $results.RecentLinks += [PSCustomObject]@{
                                Profile = $profile
                                Location = $(if ($location -like '*\Recent') { 'Recent' } else { 'Desktop' })
                                LinkFile = $lnkFile.FullName
                                LinkLastWriteTime = $lnkFile.LastWriteTime
                                TargetPath = $targetPath
                                TargetArguments = $targetArguments
                                WorkingDirectory = $workingDirectory
                                Resolved = $resolved
                                ResolutionMethod = $(if ($lnkResolutionAvailable) { 'WScript.Shell COM' } else { 'Unavailable (COM not present)' })
                                MitreAttack = 'T1547 (heuristic - shortcut-based persistence indicator)'
                            }
                        }
                    }
                }
                catch {
                    Write-Warning "Error analyzing shortcuts for profile $profile - $_"
                    $results.Summary.Errors++
                }
            }

            $results.Summary.RecentLinks = @($results.RecentLinks).Count
            $results.Summary.ResolvedLinks = @($results.RecentLinks | Where-Object { $_.Resolved }).Count
            Write-Host " Processed $($results.Summary.RecentLinks) shortcuts ($($results.Summary.ResolvedLinks) resolved)" -ForegroundColor Green

            # 3. JUMPLISTS (AutomaticDestinations enumeration only)
            Write-Host "`n[3/4] Enumerating JumpList files..." -ForegroundColor Cyan

            foreach ($profile in $profiles) {
                try {
                    $jumpListPath = Join-Path $profile 'AppData\Roaming\Microsoft\Windows\Recent\AutomaticDestinations'

                    if (Test-Path $jumpListPath -PathType Container) {
                        $jumpListFiles = @(Get-ChildItem -Path $jumpListPath -Filter '*.automaticDestinations-ms' -File -Force -ErrorAction SilentlyContinue)

                        foreach ($jumpListFile in $jumpListFiles) {
                            $results.JumpLists += [PSCustomObject]@{
                                Profile = $profile
                                JumpListFile = $jumpListFile.FullName
                                AppId = $jumpListFile.BaseName
                                SizeBytes = $jumpListFile.Length
                                LastWriteTime = $jumpListFile.LastWriteTime
                                Note = 'Enumeration only - embedded JumpList targets not parsed'
                                MitreAttack = 'T1074.001 (heuristic - user file interaction evidence)'
                            }
                        }
                    }
                }
                catch {
                    Write-Warning "Error enumerating JumpLists for profile $profile - $_"
                    $results.Summary.Errors++
                }
            }

            $results.Summary.JumpListFiles = @($results.JumpLists).Count
            Write-Host " Found $($results.Summary.JumpListFiles) JumpList files" -ForegroundColor Green

            # 4. WINDOWS TIMELINE (ActivitiesCache.db copy, size/mtime only)
            Write-Host "`n[4/4] Collecting Windows Timeline databases..." -ForegroundColor Cyan

            foreach ($profile in $profiles) {
                try {
                    $timelineRoot = Join-Path $profile 'AppData\Local\ConnectedDevicesPlatform'

                    if (Test-Path $timelineRoot -PathType Container) {
                        $timelineDbs = @(Get-ChildItem -Path $timelineRoot -Filter 'ActivitiesCache.db' -File -Recurse -Force -ErrorAction SilentlyContinue)

                        foreach ($db in $timelineDbs) {
                            $copiedTo = $null
                            $copySucceeded = $false
                            $copyNote = 'Not copied (no OutputPath specified)'

                            if ($OutputPath) {
                                $timelineCopyDir = Join-Path $OutputPath 'TimelineCopies'
                                if (-not (Test-Path $timelineCopyDir -PathType Container)) {
                                    New-Item -Path $timelineCopyDir -ItemType Directory -Force | Out-Null
                                }

                                $profileName = Split-Path $profile -Leaf
                                $destFile = Join-Path $timelineCopyDir "$($profileName)_$($db.Directory.Name)_ActivitiesCache.db"

                                $sourceStream = $null
                                $destStream = $null
                                try {
                                    # Raw FileStream copy with ReadWrite share to handle locked database
                                    $sourceStream = New-Object System.IO.FileStream($db.FullName, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
                                    $destStream = New-Object System.IO.FileStream($destFile, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
                                    $sourceStream.CopyTo($destStream)
                                    $copiedTo = $destFile
                                    $copySucceeded = $true
                                    $copyNote = 'Copied via raw FileStream (FileShare.ReadWrite)'
                                }
                                catch {
                                    Write-Warning "ActivitiesCache.db locked or unreadable - skipping copy: $($db.FullName)"
                                    $copyNote = "Copy failed (locked or unreadable): $_"
                                }
                                finally {
                                    if ($null -ne $destStream) { $destStream.Dispose() }
                                    if ($null -ne $sourceStream) { $sourceStream.Dispose() }
                                }
                            }

                            $results.Timeline += [PSCustomObject]@{
                                Profile = $profile
                                DatabasePath = $db.FullName
                                SizeBytes = $db.Length
                                LastWriteTime = $db.LastWriteTime
                                CopiedTo = $copiedTo
                                CopySucceeded = $copySucceeded
                                Note = "Raw copy and metadata only - SQLite parsing not performed (no dependency). $copyNote"
                                MitreAttack = 'T1074.001 (heuristic - user activity timeline evidence)'
                            }
                        }
                    }
                }
                catch {
                    Write-Warning "Error collecting Timeline for profile $profile - $_"
                    $results.Summary.Errors++
                }
            }

            $results.Summary.TimelineDatabases = @($results.Timeline).Count
            Write-Host " Found $($results.Summary.TimelineDatabases) Timeline databases" -ForegroundColor Green
        }
        catch {
            Write-Error "Error during user activity artifact collection: $_"
            throw
        }
    }

    end {
        $results.ProfilesAnalyzed = @($profiles)
        $results.Summary.ProfilesAnalyzed = @($profiles).Count
        $results.Summary.RecycleBinEntries = @($results.RecycleBin).Count

        # CSV export
        if ($OutputPath) {
            try {
                if (-not (Test-Path $OutputPath -PathType Container)) {
                    New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null
                }

                if (@($results.RecycleBin).Count -gt 0) {
                    $results.RecycleBin | Export-Csv -Path (Join-Path $OutputPath 'RecycleBin.csv') -NoTypeInformation -Encoding UTF8
                }
                if (@($results.RecentLinks).Count -gt 0) {
                    $results.RecentLinks | Export-Csv -Path (Join-Path $OutputPath 'RecentLinks.csv') -NoTypeInformation -Encoding UTF8
                }
                if (@($results.JumpLists).Count -gt 0) {
                    $results.JumpLists | Export-Csv -Path (Join-Path $OutputPath 'JumpLists.csv') -NoTypeInformation -Encoding UTF8
                }
                if (@($results.Timeline).Count -gt 0) {
                    $results.Timeline | Export-Csv -Path (Join-Path $OutputPath 'Timeline.csv') -NoTypeInformation -Encoding UTF8
                }

                Write-Host "`nCSV exports written to: $OutputPath" -ForegroundColor Green
            }
            catch {
                Write-Warning "Could not export CSV files to $OutputPath - $_"
            }
        }

        Write-Host "`n========================================" -ForegroundColor Green
        Write-Host " USER ACTIVITY ANALYSIS COMPLETE" -ForegroundColor Green
        Write-Host "========================================" -ForegroundColor Green
        Write-Host " Profiles analyzed: $($results.Summary.ProfilesAnalyzed)" -ForegroundColor White
        Write-Host " Recycle Bin entries: $($results.Summary.RecycleBinEntries)" -ForegroundColor White
        Write-Host " Recent/Desktop links: $($results.Summary.RecentLinks) ($($results.Summary.ResolvedLinks) resolved)" -ForegroundColor White
        Write-Host " JumpList files: $($results.Summary.JumpListFiles)" -ForegroundColor White
        Write-Host " Timeline databases: $($results.Summary.TimelineDatabases)" -ForegroundColor White
        Write-Host " Errors: $($results.Summary.Errors)" -ForegroundColor $(if($results.Summary.Errors -gt 0){'Yellow'}else{'White'})

        return $results
    }
}