Reading transcripts as an object

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

Transcript-as-object-into-ogv

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"

Transcript-as-object-output

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.