Transcription now built-in the PowerShell 5.0 engine is a great feature for watching over-the-shoulder what happens.
Unfortunately, it dumps everything into text files and PowerShell is all about objects.
Instead of just using grep (or Select-String cmdlet) and to ease forensics investigations by providing context, I wrote the following function:
Function Get-TranscriptContent { | |
[CmdletBinding()] | |
Param( | |
[Parameter(Mandatory)] | |
[string]$FilePath | |
) | |
Begin { | |
if (-not(Test-Path -Path $FilePath -PathType Leaf )) { | |
Write-Warning -Message "Filepath isn't a file" | |
break | |
} | |
Write-Verbose -Message "Dealing with file $($FilePath)" | |
#region helper functions | |
Function Test-isTranscriptInvocationHeaderEnabled { | |
[CmdletBinding()] | |
Param([string]$FilePath) | |
Begin {} | |
Process { | |
$r = $false | |
Get-ChildItem -Path $FilePath | | |
Select-String -Pattern "^Command\sstart\stime:\s\d{14}$" -Context 0,1 | | |
ForEach-Object { | |
if (($_.Context.PostContext)[0] -match '^\*{22}$') { | |
$r = $true | |
} | |
} | |
$r | |
} | |
End{} | |
} | |
Function Test-isTranscriptEnded { | |
[CmdletBinding()] | |
Param([string]$FilePath) | |
Begin {} | |
Process { | |
$r = $false | |
Get-ChildItem -Path $FilePath | | |
Select-String -Pattern "^Windows\sPowerShell\stranscript\send$" -Context 0,1 | | |
ForEach-Object { | |
if (($_.Context.PostContext)[0] -match '^End\stime:\s\d{14}$') { | |
$r = $true | |
} | |
} | |
$r | |
} | |
End{} | |
} | |
Function Get-LastSeparatorLineNumber { | |
[CmdletBinding()] | |
Param([string]$FilePath) | |
Begin {} | |
Process { | |
Get-ChildItem -Path $FilePath | Select-String -Pattern "^\*{22}$" | | |
Select -Last 1 -Expand LineNumber | |
} | |
End{} | |
} | |
Function Get-LinesTotal { | |
[CmdletBinding()] | |
Param([string]$FilePath) | |
Begin {} | |
Process { | |
(Get-ChildItem -Path $FilePath | Get-Content -ReadCount 1 | Measure).Count | |
} | |
End{} | |
} | |
Function Test-HasCommand { | |
[CmdletBinding()] | |
Param([string]$FilePath) | |
Begin {} | |
Process { | |
-not((Get-LastSeparatorLineNumber -FilePath $FilePath) -eq (Get-LinesTotal -FilePath $FilePath)) | |
} | |
End{} | |
} | |
#endregion | |
$InvocationHeaderEnabled = Test-isTranscriptInvocationHeaderEnabled -FilePath $FilePath | |
Write-Verbose -Message "Invocation Header Enabled: $($InvocationHeaderEnabled)" | |
$TranscriptEnded = Test-isTranscriptEnded -FilePath $FilePath | |
Write-Verbose -Message "Transcript Ended: $($TranscriptEnded)" | |
if (-not$TranscriptEnded) { | |
$HasCommands = Test-HasCommand -FilePath $FilePath | |
Write-Verbose -Message "Has commands: $($HasCommands)" | |
$TotalLines = Get-LinesTotal -FilePath $FilePath | |
Write-Verbose -Message "Total lines: $($TotalLines)" | |
$LastSeparatorLineNumber = Get-LastSeparatorLineNumber -FilePath $FilePath | |
Write-Verbose -Message "Last Separator line number: $($LastSeparatorLineNumber)" | |
} | |
} | |
Process { | |
$CommandStartTime = $TranscriptStartTime = $UserName = $RunAsUser = $null | |
$ComputerName = $HostApplication = $PSVersion = $ProcessId = $null | |
$count = 0 | |
$sb = New-Object System.Text.StringBuilder | |
$LineCounter = 0 | |
Get-ChildItem -Path $FilePath | Get-Content -ReadCount 1 -Encoding UTF8 | | |
ForEach-Object -Process { | |
$Line = $_ | |
$LineCounter++ | |
Switch -Regex ($_) { | |
'^\*{22}$' { $count++ ; break } | |
'^Windows\sPowerShell\stranscript\sstart' { break } | |
'^Start\stime:\s(?<StartTime>\d{14})' { $TranscriptStartTime = $Matches['StartTime'] ; break } | |
'^Username:\s(?<UserName>.+)' { $UserName= $Matches['UserName'] ; break } | |
'^RunAs\sUser:\s(?<RunAsUser>.+)' { $RunAsUser= $Matches['RunAsUser'] ; break } | |
'^Machine:\s(?<ComputerName>.+)\s\(Microsoft\sWindows\sNT\s10\.0\.\d{5}.\d{1}\)' { | |
$ComputerName = $Matches['ComputerName'] ; break | |
} | |
'^Host\sApplication:\s(?<HostApplication>.+)' { $HostApplication = $Matches['HostApplication'] ; break } | |
'^Process\sID:\s(?<ProcessId>\d{1,})' { $ProcessId= $Matches['ProcessId'] ; break } | |
'^PSVersion:\s(?<PSVersion>.+)' { $PSVersion= $Matches['PSVersion'] ; break } | |
'^WSManStackVersion:\s.+' { break } | |
'^SerializationVersion:\s.+' { break } | |
'^CLRVersion:\s.+' { break } | |
'^BuildVersion:\s.+' { break } | |
'^PSCompatibleVersions:\s.+' { break } | |
'^PSRemotingProtocolVersion:\s.+' { break } | |
'^Command\sstart\stime:\s(?<StartTime>\d{14})'{ $CommandStartTime =$Matches['StartTime'] ; break } | |
'^Windows\sPowerShell\stranscript\send' { break } | |
'^End\stime:\s\d{14}$' { break } | |
default { | |
$null = $sb.AppendLine($_) | |
} | |
} | |
Switch ($count) { | |
0 { | |
# Write-Verbose "count is 0 and Line is $Line" | |
break | |
} | |
1 { | |
# Write-Verbose "count is 1 and Line is $Line" | |
if ([string]::Empty -eq $sb.toString()) { | |
# Write-Warning -Message 'string built is empty' | |
} else { | |
[PSCustomObject]@{ | |
TranscriptStartTime = $TranscriptStartTime | |
UserName = $UserName | |
RunAsUser = $RunAsUser | |
ComputerName = $ComputerName | |
HostApplication = $HostApplication | |
ProcessId = $ProcessId | |
PSVersion = $PSVersion | |
CommandStartTime = $CommandStartTime | |
CommandContext = $sb.ToString() | |
} | |
} | |
# Reset | |
$sb = New-Object System.Text.StringBuilder | |
break | |
} | |
2 { | |
$count = 0 | |
# Write-Verbose "count is 2 and Line is $Line" | |
$sb = New-Object System.Text.StringBuilder | |
break | |
} | |
default {} | |
} | |
# Get the last command | |
if ($LineCounter -eq $TotalLines) { | |
if (-not($TranscriptEnded)) { | |
[PSCustomObject]@{ | |
TranscriptStartTime = $TranscriptStartTime | |
UserName = $UserName | |
RunAsUser = $RunAsUser | |
ComputerName = $ComputerName | |
HostApplication = $HostApplication | |
ProcessId = $ProcessId | |
PSVersion = $PSVersion | |
CommandStartTime = $CommandStartTime | |
CommandContext = $sb.ToString() | |
} | |
} | |
} | |
} | |
} | |
End {} | |
} |
Yeah, less than 200 lines 😎 and tough job, here’s why:
- The function has the ability to read a transcript file that is still active (the shell or host application didn’t exit yet).
- It also reports when a shell (or host application) has been launched but nothing was typed-in.
- Sometimes, there’s a new header block appended in the middle of the transcript file, usually when an error occurs as far as I can tell.
- The function is able to parse both transcripts that have enabled “Invocation Header” or not.
- Transcripts timestamp commands if you’ve enabled “Invocation Header” and it adds additional separators.
More info about the behavior of the above function:
-I didn’t transform the datetime data parsed from the text file into a real datetime object as I didn’t know if they are always in the yyyyMMddHHmmss format.
-If “Invocation Header” aren’t enabled the CommandStartTime property returned by the function is null.
-The CommandContext property is also null when a shell (or host application) has been launched but nothing was typed-in.
-The CommandContext property will contain one or many commands as well as their output
To see the function in action, you just do:
Get-ChildItem -Path C:\Transcripts -Include *.txt -Recurse | ForEach-Object { Get-TranscriptContent -FilePath $_.FullName -Verbose }
You can even pipe the result into the Out-GridView cmdlet
Get-ChildItem -Path C:\Transcripts -Include *.txt -Recurse | ForEach-Object { Get-TranscriptContent -FilePath $_.FullName } | Out-GridView
And you can search for whatever you want using the properties of each object sent through the pipeline:
Get-ChildItem -Path C:\Transcripts -Include *.txt -Recurse | ForEach-Object { Get-TranscriptContent -FilePath $_.FullName -Verbose } | Where CommandContext -match "attack"