#
.Synopsis
Snaffler output file parser
.Description
Split, sort and beautify the Snaffler output.
Adds explorer++ integration for easy file and share browsing (runas /netonly support)
.Parameter outformat
Output options:
- all : write txt, csv, html and json
- txt : write txt
- csv : write csv
- json : write json
- html : write html
- default : write txt, csv, html
.Parameter in
Input file (full path or file name)
Defaults to snafflerout.txt
.Parameter sort
Field to sort output:
- modified: File modified date (default)
- keyword: Snaffler keyword
- unc: File UNC Path
- rule: Snaffler rule name
.Parameter split
Will create splitted (by severity black, red, yellow, green) export files
.Parameter gridview
Analyze the file and display in PS gridview
.Parameter gridviewload
Switch to load an existing PS gridview output (CSV)
.Parameter gridin
Input file (full path or filename)
Defaults to snafflerout.txt_loot_gridview.csv
.Parameter pte
pte (pass to explorer) exports the shares to Explorer++ as bookmarks (grouped by host)
Explorer++ must be configured to be in Portable mode (settings saved in xml file) and only one instance is allowed.
.Parameter snaffel
Run Snaffler and execute parser with default settings.
.Example
.\snafflerparser.ps1
(will try to load snafflerout.txt and output in HTML, CSV and TXT format)
.Example
.\snafflerparser.ps1 -in mysnaffleroutput.tvs
(will try to load mysnaffleroutput.tvs in HTML, CSV and TXT format)
.Example
.\snafflerparser.ps1 outformat csv -split
(will store results as CSV and split the files by severity)
.Example
.\snafflerparser.ps1 -sort unc
(will sort by the column unc)
.Example
.\snafflerparser.ps1 -gridview
(Will additionally show the output in PS Gridview and save the gridview for later use)
.Example
.\snafflerparser.ps1 -gridviewload
(Load a existing gridview (defaults to snafflerout.txt_loot_gridview.csv))
.Example
.\snafflerparser.ps1 -gridviewload -gridin mygridviewfile.csv
(Load specific gridview file)
.Example
.\snafflerparser.ps1 -pte
(Add Shares as Bookmarks to explorer++)
.LINK
https://github.com/zh54321/snaffler_parser
#>
Param (
[String]
$in = 'snafflerout.txt',
[ValidateSet("modified", "keyword", "rule", "unc")]
[String]
$sort = "modified",
[ValidateSet("all", "csv", "txt", "json","html","default")]
[String]
$outformat = "default",
[switch]
$gridview,
[switch]
$gridviewload,
[switch]
$split,
[String]
$gridin = 'snafflerout.txt_loot_gridview.csv',
[String]
$exlorerpp = '.\Explorer++.exe',
[switch]
$pte,
[switch]
$snaffel,
[switch]
$help
)
# Resolve input file path
if ([System.IO.Path]::IsPathRooted($in)) {
# Absolute path provided
$inPath = $in
} else {
# Relative path or filename only → current directory
$inPath = Join-Path -Path (Get-Location) -ChildPath $in
}
# Normalize (removes .\, ..\, etc.)
$inPath = [System.IO.Path]::GetFullPath($inPath)
# Function section-----------------------------------------------------------------------------------
function Format-TimePrettyUtc {
param([object]$value)
if ($null -eq $value -or [string]::IsNullOrWhiteSpace([string]$value)) { return "" }
try {
switch ($value.GetType().FullName) {
'System.DateTimeOffset' { return $value.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'") }
'System.DateTime' { return ([DateTime]$value).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'") }
default {
# Try to parse string as DateTimeOffset first (handles Z nicely)
$dto = [DateTimeOffset]::Parse(
[string]$value,
[System.Globalization.CultureInfo]::InvariantCulture,
[System.Globalization.DateTimeStyles]::AssumeUniversal
)
return $dto.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'")
}
}
} catch {
# If parsing fails, just return original string
return [string]$value
}
}
function Format-DurationPretty {
param([Nullable[TimeSpan]]$ts)
if ($null -eq $ts) { return "" }
$parts = @()
if ($ts.Days -gt 0) { $parts += "$($ts.Days)d" }
if ($ts.Hours -gt 0) { $parts += "$($ts.Hours)h" }
if ($ts.Minutes -gt 0) { $parts += "$($ts.Minutes)m" }
# Round to whole seconds (0.5s rounds up)
$totalSecondsRounded = [int][math]::Round($ts.TotalSeconds, 0, [MidpointRounding]::AwayFromZero)
if ($parts.Count -gt 0) {
# show remaining seconds within the minute (also whole)
$secWithinMinute = $totalSecondsRounded % 60
$parts += ("{0}s" -f $secWithinMinute)
} else {
# only seconds (whole)
$parts += ("{0}s" -f $totalSecondsRounded)
}
return ($parts -join " ")
}
function gridview($action){
if ($action -eq "load") {
write-host "[*] Loading stored Gridview file: $($gridin)"
if (!(Test-Path -LiteralPath $inpath -PathType Leaf)) {
write-host "[-] Input file not found $($gridin) use -gridin to specify the file csv"
exit
}
write-host "[*] Starting Gridview (opens in background)"
$passthruobjec = Import-Csv -Path "$($gridin)" | Out-GridView -Title "FullView" -PassThru
} elseif ($action -eq "start") {
write-host "[*] Writing Gridview output file for further use"
$fulloutput | select-object severity,rule,keyword,modified,extension,unc,content | Export-Csv -Path "$($outputname)_loot_gridview.csv" -NoTypeInformation
write-host "[*] Starting Gridview (opens in background)"
$passthruobjec = $fulloutput | select-object severity,rule,keyword,modified,extension,unc,content | Out-GridView -Title "FullView" -PassThru
}
$countpassthruobjec = $passthruobjec | Measure-Object -Line -Property unc
if ($countpassthruobjec.lines -ge 1) {
if (!(Test-Path -Path $exlorerpp -PathType Leaf)) {
write-host "[-] Explorer++ not found at $exlorerpp use -explorerpp to specify the exe file"
exit
} else {
write-host "[-] Explorer++ found at $exlorerpp"
write-host "[*] Found $($countpassthruobjec.lines) object. Trying to open them in Explorer++ "
write-host "[i] Start the script in console window runas ... /netonly to access the files as different user"
write-host "[i] Disables the 'Allow multiple instance' in Explorer++ to open multiple location in tabs "
foreach ($path in $passthruobjec.unc) {
$pathtoopen = (Split-Path -Path $path -Parent)
# Danger danger Invoke-Expression
& $exlorerpp $pathtoopen
Start-Sleep -Milliseconds 500
}
}
} else {
write-host "[!] No PassThru object found"
}
write-host "[*] Exiting"
exit
}
function explorerpp($objects) {
$explorerppfolder = Split-Path $exlorerpp
$configPath = Join-Path $explorerppfolder "config.xml"
# If exlorerpp is ".\Explorer++.exe", Split-Path returns "."
if ($explorerppfolder -eq ".") {
$configPath = Join-Path $pwd "config.xml"
}
# -----------------------------
# Default config.xml template
# -----------------------------
$defaultConfig = @'
yes
no
yes
yes
no
yes
no
no
30090
no
yes
no
no
no
no
no
no
0
yes
9
no
0
yes
no
::{20D04FE0-3AEA-1069-A2D8-08002B30309D}
no
500
yes
yes
1
yes
yes
yes
yes
yes
yes
yes
yes
no
yes
no
yes
yes
yes
no
no
yes
no
yes
yes
no
1
yes
1
yes
no
no
0
no
208
1
'@
# -----------------------------
# Load or create config.xml
# -----------------------------
if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) {
Write-Host "[*] Explorer++ config.xml not found. Creating default at: $configPath"
$defaultConfig | Out-File -FilePath $configPath -Encoding UTF8
} else {
Write-Host "[*] Found Explorer++ config.xml: $configPath"
}
# Load XML
try {
$xmlfile = [xml](Get-Content -LiteralPath $configPath)
} catch {
Write-Host "[-] Failed to read XML at $configPath"
Write-Host " $($_.Exception.Message)"
exit
}
# -----------------------------
# Ensure Settings/ShowBookmarksToolbar=yes
# -----------------------------
$settingsNode = $xmlfile.SelectSingleNode("/ExplorerPlusPlus/Settings")
if (-not $settingsNode) {
$settingsNode = $xmlfile.CreateElement("Settings")
[void]$xmlfile.ExplorerPlusPlus.AppendChild($settingsNode)
}
$showBm = $xmlfile.SelectSingleNode("/ExplorerPlusPlus/Settings/Setting[@name='ShowBookmarksToolbar']")
if (-not $showBm) {
$showBm = $xmlfile.CreateElement("Setting")
[void]$showBm.SetAttribute("name", "ShowBookmarksToolbar")
$showBm.InnerText = "yes"
[void]$settingsNode.AppendChild($showBm)
Write-Host "[*] Added Setting ShowBookmarksToolbar=yes"
} else {
if ($showBm.InnerText -ne "yes") {
$showBm.InnerText = "yes"
Write-Host "[*] Updated Setting ShowBookmarksToolbar=yes"
}
}
# -----------------------------
# Ensure Bookmarksv2 + BookmarksToolbar node exists
# -----------------------------
$bmRoot = $xmlfile.SelectSingleNode("/ExplorerPlusPlus/Bookmarksv2")
if (-not $bmRoot) {
$bmRoot = $xmlfile.CreateElement("Bookmarksv2")
[void]$xmlfile.ExplorerPlusPlus.AppendChild($bmRoot)
}
$toolbarNode = $xmlfile.SelectSingleNode("/ExplorerPlusPlus/Bookmarksv2/PermanentItem[@name='BookmarksToolbar']")
if (-not $toolbarNode) {
$toolbarNode = $xmlfile.CreateElement("PermanentItem")
[void]$toolbarNode.SetAttribute("name", "BookmarksToolbar")
# Minimal timestamps (Explorer++ seems fine with any ints; keeping your style)
[void]$toolbarNode.SetAttribute("DateCreatedLow", "3561811627")
[void]$toolbarNode.SetAttribute("DateCreatedHigh", "3561811627")
[void]$toolbarNode.SetAttribute("DateModifiedLow", "3561811627")
[void]$toolbarNode.SetAttribute("DateModifiedHigh", "3561811627")
[void]$bmRoot.AppendChild($toolbarNode)
Write-Host "[*] Created BookmarksToolbar container"
}
# -----------------------------
# Delete existing bookmarks ONLY under BookmarksToolbar
# -----------------------------
Write-Host "[*] Deleting existing bookmarks in BookmarksToolbar"
$existing = $toolbarNode.SelectNodes("./Bookmark")
foreach ($node in @($existing)) {
[void]$toolbarNode.RemoveChild($node)
}
# -----------------------------
# Add new bookmarks grouped by host
# -----------------------------
$counteruncstats = 0
$counterhosts = 0
# We'll keep folder "name" indexes stable per host, and bookmark "name" indexes per folder
$hostFolders = @{} # server => folderNode
$hostCounters = @{} # server => nextBookmarkIndex
foreach ($element in $objects.unc) {
if ([string]::IsNullOrWhiteSpace($element)) { continue }
# Isolate Server: \\server\share\...
$server = $null
if ($element -match '^\\\\([^\\]+)\\') {
$server = $Matches[1]
} else {
# If it's not a UNC, just bucket it under "(local/other)"
$server = "(other)"
}
if (-not $hostFolders.ContainsKey($server)) {
# Create folder bookmark (Type=0) under BookmarksToolbar
$folder = $xmlfile.CreateElement("Bookmark")
[void]$folder.SetAttribute("name", [string]$counterhosts)
[void]$folder.SetAttribute("Type", "0")
[void]$folder.SetAttribute("GUID", ([guid]::NewGuid().ToString()))
[void]$folder.SetAttribute("ItemName", $server)
[void]$folder.SetAttribute("DateCreatedLow", "3561811627")
[void]$folder.SetAttribute("DateCreatedHigh", "3561811627")
[void]$folder.SetAttribute("DateModifiedLow", "3561811627")
[void]$folder.SetAttribute("DateModifiedHigh", "3561811627")
[void]$toolbarNode.AppendChild($folder)
$hostFolders[$server] = $folder
$hostCounters[$server] = 0
$counterhosts++
}
# Add the actual bookmark (Type=1) inside the server folder
$folderNode = $hostFolders[$server]
$idx = [int]$hostCounters[$server]
$bm = $xmlfile.CreateElement("Bookmark")
[void]$bm.SetAttribute("name", [string]$idx)
[void]$bm.SetAttribute("Type", "1")
[void]$bm.SetAttribute("GUID", ([guid]::NewGuid().ToString()))
[void]$bm.SetAttribute("ItemName", $element)
[void]$bm.SetAttribute("Location", $element)
[void]$bm.SetAttribute("DateCreatedLow", "3561811627")
[void]$bm.SetAttribute("DateCreatedHigh", "3561811627")
[void]$bm.SetAttribute("DateModifiedLow", "3561811627")
[void]$bm.SetAttribute("DateModifiedHigh", "3561811627")
[void]$folderNode.AppendChild($bm)
$hostCounters[$server] = $idx + 1
$counteruncstats++
}
# -----------------------------
# Save
# -----------------------------
try {
$xmlfile.Save($configPath)
Write-Host "[+] Added $counterhosts bookmark-folders with $counteruncstats bookmarks"
Write-Host "[+] Saved: $configPath"
} catch {
Write-Host "[-] Failed to save XML: $($_.Exception.Message)"
exit
}
}
# Function to export as CSV
function exportcsv($object ,$name){
write-host "[*] Storing: $($outputname)_loot_$($name).csv"
$object | select-object severity,rule,keyword,modified,extension,unc,content | Export-Csv -Path "$($outputname)_loot_$($name).csv" -NoTypeInformation
}
# Function to export as TXT
function exporttxt($object ,$name){
write-host "[*] Storing: $($outputname)_loot_$($name).txt"
$object | Format-Table severity,rule,keyword,modified,extension,unc,content -AutoSize | Out-String -Width 10000 | Out-File -FilePath "$($outputname)_loot_$($name).txt"
}
# Function to export as JSON
function exportjson($object ,$name){
write-host "[*] Storing: $($outputname)_loot_$($name).json"
$object | select-object severity,rule,keyword,modified,extension,unc,content | ConvertTo-Json -depth 50 | Out-File -FilePath "$($outputname)_loot_$($name).json"
}
# Function to export as HTML
function exporthtml($object ,$name){
# ---------------- JS: data-driven table with pagination ----------------
$Header = @'
'@
# ---------------- CSS ----------------
$css = @"
"@
# ---------------- HTML skeleton (NO huge table) ----------------
$titleAndTable = @"
| ★ |
✓ |
severity |
rule |
keyword |
modified |
unc |
extension |
actions |
content |
"@
# ---------------- Build JSON blob from the PS objects ----------------
$rowsForJson = $object | ForEach-Object {
[pscustomobject]@{
severity = $_.severity
rule = $_.rule
keyword = $_.keyword
modified = $_.modified
unc = $_.unc
extension = $_.extension
content = $_.content
check = $false
done = $false
}
}
$json = $rowsForJson | ConvertTo-Json -Depth 6 -Compress
$json = $json -replace '$json"
# ---------------- Compose & write final HTML ----------------
write-host "[*] Storing: $($outputname)_loot_$($name).html"
# ---- Parse Snaffler Finished + Duration into objects (so we can pretty print consistently) ----
$finishedObj = $null
if ($baseInfo.Snaffler_EndTime) {
try {
# "21/01/2025 07:30:59" (assumed local machine time)
$finishedObj = [DateTime]::ParseExact(
$baseInfo.Snaffler_EndTime,
'dd/MM/yyyy HH:mm:ss',
[System.Globalization.CultureInfo]::InvariantCulture
)
} catch {
$finishedObj = $baseInfo.Snaffler_EndTime # fallback string
}
}
$durationObj = $null
if ($baseInfo.Snaffler_Duration) {
try { $durationObj = [TimeSpan]::Parse($baseInfo.Snaffler_Duration) } catch { $durationObj = $null }
}
# ---- Ordered modal content (controls display order) ----
$baseInfoForModal = [pscustomobject]@{
'Input file' = $baseInfo.Snaffler_File
'SHA256' = $baseInfo.SHA256
'Computer' = $baseInfo.Snaffler_ComputerName
'User' = $baseInfo.Snaffler_User
'Started' = (Format-TimePrettyUtc $baseInfo.Snaffler_StartTime)
'Finished' = if ($null -ne $finishedObj) { Format-TimePrettyUtc $finishedObj } else { '-' }
'Snaffler duration' = if ($null -ne $durationObj) { Format-DurationPretty $durationObj } else { '-' }
'Report generated' = (Format-TimePrettyUtc $baseInfo.Report_GeneratedUtc)
'Parser duration' = (Format-DurationPretty $baseInfo.Parser_Duration)
}
$inputInfoInner = $baseInfoForModal | ConvertTo-Html -As List -Fragment
$inputInfo = @"
"@
# Put inputInfo + skeleton table + json blob into body
$body = "$inputInfo $titleAndTable $dataBlob"
$htmlOutput = ConvertTo-Html -Head $css,$Header -Body $body
$htmlOutput | Out-File -FilePath "$($outputname)_loot_$($name).html" -Encoding UTF8
}
# Script section-----------------------------------------------------------------------------------
$banner = @"
____ __ __ _ ____
/ ___| _ __ __ _ / _|/ _| | ___ _ __ | _ \ __ _ _ __ ___ ___ _ __
\___ \| _ \ / _ | |_| |_| |/ _ \ '__| | |_) / _ | '__/ __|/ _ \ '__|
___) | | | | (_| | _| _| | __/ | | __/ (_| | | \__ \ __/ |
|____/|_| |_|\__,_|_| |_| |_|\___|_| |_| \__,_|_| |___/\___|_|
"@
Write-Host $banner -ForegroundColor Cyan
$parserStart = [DateTimeOffset]::UtcNow
# Check if snaffler should be executed
if ($help) {
get-help $MyInvocation.MyCommand.Definition -full
exit
}
if ($snaffel) {
.\Snaffler.exe -o snafflerout.txt -s -y
}
# Check if gridviewfile should be loaded
if ($gridviewload) {
gridview load
}
# Check snaffler input file and load it
write-host "[*] Checking input file $inpath"
if (!(Test-Path -LiteralPath $inpath -PathType Leaf)) {
write-host "[-] Input file not found $inpath"
exit
} else {
write-host "[+] Input file exists"
#Check if file size is at least 300 bytes
$FileSize = (Get-ChildItem $inpath).Length / 1014
$FileSizeRound = [math]::Round($FileSize,2)
if ($FileSizeRound -ge 0.3) {
write-host "[+] Input file is $FileSizeRound KB"
write-host "[*] Importing data from file"
$outputname = (Get-Item $inpath).BaseName
# Streaming containers
$files = [System.Collections.Generic.List[object]]::new()
$sharesSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$baseInfo = [PsCustomObject]@{
Snaffler_File = Split-Path $inpath -Leaf
SHA256 = $(Get-FileHash $inpath).Hash
Snaffler_EndTime = $null
Snaffler_Duration = $null
}
$firstLine = Get-Content $inpath -TotalCount 1
# Define the regular expression pattern to extract Computername, User and timestamp
$pattern = '\[(?.*?)\\(?.*?)@.*?\]\s+(?\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}Z)'
if ($firstLine -match $pattern) {
$baseInfo | Add-Member -NotePropertyName Snaffler_ComputerName -NotePropertyValue $matches['machine']
$baseInfo | Add-Member -NotePropertyName Snaffler_User -NotePropertyValue $matches['user']
$baseInfo | Add-Member -NotePropertyName Snaffler_StartTime -NotePropertyValue $matches['timestamp']
}
} else {
write-host "[!] Input file is less than 0.3 KB"
exit
}
}
write-host "[*] Streaming parse of input file"
# We already read first line for baseInfo, now stream everything.
# Use .NET StreamReader for speed and low memory.
$sr = [System.IO.StreamReader]::new($inpath)
try {
while (-not $sr.EndOfStream) {
$raw = $sr.ReadLine()
if ([string]::IsNullOrWhiteSpace($raw)) { continue }
# Split on tab; keep empties (important because the snaffler output file has blank columns)
$cols = $raw.Split("`t", [System.StringSplitOptions]::None)
# Need at least 3 columns for Type check: [0]=user, [1]=timestamp, [2]=typ
if ($cols.Length -lt 3) { continue }
$typ = $cols[2]
# ---- Job end/duration info (cheap: only runs for [Info] lines) ----
if ($typ -eq "[Info]" -and $cols.Length -ge 4) {
$msg = $cols[3]
# Example: "Finished at 21/01/2025 07:30:59"
if ($msg -like "Finished at*") {
if ($msg -match '^Finished at\s+(?\d{1,2}/\d{1,2}/\d{4}\s+\d{2}:\d{2}:\d{2})') {
$baseInfo.Snaffler_EndTime = $Matches.dt
} else {
# fallback: store full message if format differs
$baseInfo.Snaffler_EndTime = $msg
}
continue
}
# Example: "Snafflin' took 00:05:00.0467798"
if ($msg -like "Snafflin'*took*") {
if ($msg -match "took\s+(?\d{2}:\d{2}:\d{2}(?:\.\d+)?)") {
$baseInfo.Snaffler_Duration = $Matches.dur
} else {
$baseInfo.Snaffler_Duration = $msg
}
continue
}
}
if ($typ -eq "[Share]") {
# In current format, UNC is column index 4 (0-based): user, time, typ, color, unc, rights
if ($cols.Length -gt 4) {
$shareUnc = $cols[4]
if (-not [string]::IsNullOrWhiteSpace($shareUnc)) {
[void]$sharesSet.Add($shareUnc)
}
}
continue
}
if ($typ -eq "[File]") {
# A [File] line is tab-separated. Columns by index:
# cols[0] user@host
# cols[1] timestamp
# cols[2] [File]
# cols[3] severity (Black/Red/Yellow/Green)
# cols[4] rule name
# cols[5] readable (R or empty)
# cols[6] writable (W or empty)
# cols[7] modifiable (M or empty)
# cols[8] matched keyword/string
# cols[9] file size (bytes)
# cols[10] last-modified timestamp
# cols[11] UNC path
#
# Snaffler added a new column after the UNC in release 1.0.244:
# OLD (13 cols): cols[12] = content (matched text snippet)
# NEW (14 cols): cols[12] = original filename (for SCCM content-lib files; usually empty)
# cols[13] = content
if ($cols.Length -lt 12) { continue }
$unc = $cols[11]
if ([string]::IsNullOrWhiteSpace($unc)) { continue }
# Support both old (13-col) and new (14-col) Snaffler output formats.
# The new format added alt_filename at cols[12]; content shifted to cols[13].
$content = if ($cols.Length -gt 13) { $cols[13] } elseif ($cols.Length -gt 12) { $cols[12] } else { '' }
# UNC sanitize for GetExtension
$uncSafe = $unc -replace '[\x00-\x1F]', ''
if ($IsWindows -or $PSVersionTable.PSVersion.Major -le 5) {
$uncSafe = $uncSafe -replace '[<>:"|?*]', ''
}
$ext = ''
try { $ext = [System.IO.Path]::GetExtension($uncSafe) } catch { $ext = '' }
$files.Add([PsCustomObject]@{
check = $false
done = $false
severity = if ($cols.Length -gt 3) { $cols[3] } else { '' }
rule = if ($cols.Length -gt 4) { $cols[4] } else { '' }
keyword = if ($cols.Length -gt 8) { $cols[8] } else { '' }
modified = if ($cols.Length -gt 10) { $cols[10] } else { '' }
unc = $unc
extension = $ext
content = $content
})
}
}
}
finally {
if ($null -ne $sr) { $sr.Dispose() }
}
write-host "[*] Processing shares"
$shares = $sharesSet |
ForEach-Object { [PsCustomObject]@{ unc = $_ } } |
Sort-Object -Property unc
# Check share count and write to file
$sharescount = $shares | Measure-Object -Line -Property unc
if ($sharescount.lines -ge 1) {
write-host "[+] Shares identified: $($sharescount.lines)"
write-host "[*] Writing share output file"
$shares | Format-Table -AutoSize | Out-File -FilePath "$($outputname)_shares.txt"
} else {
write-host "[!] Shares identified: 0"
write-host "[?] Was Snaffler executed with parameter -y ?"
}
# Define fixed severity order
$severityRank = @{
Black = 0
Red = 1
Yellow = 2
Green = 3
}
# Whether to sort descending
$sortDescending = ($sort -eq 'modified')
# Sort once:
# 1) by severity rank so Black/Red/Yellow/Green always stay grouped + ordered
# 2) then by chosen $sort column (descending only for modified)
$fulloutput = $files | Sort-Object `
@{ Expression = { $severityRank[$_.severity] } ; Ascending = $true } ,
@{ Expression = { $_.$sort } ; Descending = $sortDescending }
# Group once into a hashtable: keys = "Black"/"Red"/...
$bySeverity = $fulloutput | Group-Object -Property severity -AsHashTable -AsString
# Pull groups out (always define them as arrays, even if empty)
$blacks = @($bySeverity['Black'])
$reds = @($bySeverity['Red'])
$yellows = @($bySeverity['Yellow'])
$greens = @($bySeverity['Green'])
# Counts
$blackscount = $blacks.Count
$redscount = $reds.Count
$yellowscount = $yellows.Count
$greenscount = $greens.Count
$filesum = $blackscount + $redscount + $yellowscount + $greenscount
if ($filesum -ge 1) {
write-host "[+] Files total: $filesum "
write-host "[+] Files with severity BLACK: $blackscount"
write-host "[+] Files with severity RED: $redscount"
write-host "[+] Files with severity YELLOW: $yellowscount"
write-host "[+] Files with severity GREEN: $greenscount"
$reportGeneratedUtc = [DateTimeOffset]::UtcNow
$parserDuration = $reportGeneratedUtc - $parserStart
$baseInfo | Add-Member -NotePropertyName Report_GeneratedUtc -NotePropertyValue $reportGeneratedUtc -Force
$baseInfo | Add-Member -NotePropertyName Parser_Duration -NotePropertyValue $parserDuration -Force
#Write outputs depening on desired format
if ($outformat -eq "all"){
write-host "[*] Exporting full CSV + TXT + JSON + HTML"
exporttxt $fulloutput full
exportcsv $fulloutput full
exportjson $fulloutput full
exporthtml $fulloutput full
if ($split) {
write-host "[*] Exporting splitted CSV + TXT"
if ($blackscount -ge 1) {exportcsv $blacks blacks}
if ($redscount -ge 1) {exportcsv $reds reds}
if ($yellowscount -ge 1) {exportcsv $yellows yellows}
if ($greenscount -ge 1) {exportcsv $greens greens}
if ($blackscount -ge 1) {exporttxt $blacks blacks}
if ($redscount -ge 1) {exporttxt $reds reds}
if ($yellowscount -ge 1) {exporttxt $yellows yellows}
if ($greenscount -ge 1) {exporttxt $greens greens}
if ($blackscount -ge 1) {exportjson $blacks blacks}
if ($redscount -ge 1) {exportjson $reds reds}
if ($yellowscount -ge 1) {exportjson $yellows yellows}
if ($greenscount -ge 1) {exportjson $greens greens}
if ($blackscount -ge 1) {exporthtml $blacks blacks}
if ($redscount -ge 1) {exporthtml $reds reds}
if ($yellowscount -ge 1) {exporthtml $yellows yellows}
if ($greenscount -ge 1) {exporthtml $greens greens}
}
} elseif ($outformat -eq "default") {
write-host "[*] Exporting full CSV + TXT + HTML"
exporttxt $fulloutput full
exportcsv $fulloutput full
exporthtml $fulloutput full
if ($split) {
write-host "[*] Exporting splitted CSV + TXT"
if ($blackscount -ge 1) {exportcsv $blacks blacks}
if ($redscount -ge 1) {exportcsv $reds reds}
if ($yellowscount -ge 1) {exportcsv $yellows yellows}
if ($greenscount -ge 1) {exportcsv $greens greens}
if ($blackscount -ge 1) {exporttxt $blacks blacks}
if ($redscount -ge 1) {exporttxt $reds reds}
if ($yellowscount -ge 1) {exporttxt $yellows yellows}
if ($greenscount -ge 1) {exporttxt $greens greens}
if ($blackscount -ge 1) {exporthtml $blacks blacks}
if ($redscount -ge 1) {exporthtml $reds reds}
if ($yellowscount -ge 1) {exporthtml $yellows yellows}
if ($greenscount -ge 1) {exporthtml $greens greens}
}
} elseif ($outformat -eq "txt") {
write-host "[*] Exporting full TXT"
exporttxt $fulloutput full
if ($split) {
write-host "[*] Exporting splitted TXT"
if ($blackscount -ge 1) {exporttxt $blacks blacks}
if ($redscount -ge 1) {exporttxt $reds reds}
if ($yellowscount -ge 1) {exporttxt $yellows yellows}
if ($greenscount -ge 1) {exporttxt $greens greens}
}
} elseif ($outformat -eq "csv") {
write-host "[*] Exporting full CSV"
exportcsv $fulloutput full
if ($split) {
write-host "[*] Exporting splitted CSV"
if ($blackscount -ge 1) {exportcsv $blacks blacks}
if ($redscount -ge 1) {exportcsv $reds reds}
if ($yellowscount -ge 1) {exportcsv $yellows yellows}
if ($greenscount -ge 1) {exportcsv $greens greens}
}
} elseif ($outformat -eq "json") {
write-host "[*] Exporting full JSON"
exportjson $fulloutput full
if ($split) {
write-host "[*] Exporting splitted JSON"
if ($blackscount -ge 1) {exportjson $blacks blacks}
if ($redscount -ge 1) {exportjson $reds reds}
if ($yellowscount -ge 1) {exportjson $yellows yellows}
if ($greenscount -ge 1) {exportjson $greens greens}
}
} elseif ($outformat -eq "html") {
write-host "[*] Exporting full HTML"
exporthtml $fulloutput full
if ($split) {
write-host "[*] Exporting splitted HTML"
if ($blackscount -ge 1) {exporthtml $blacks blacks}
if ($redscount -ge 1) {exporthtml $reds reds}
if ($yellowscount -ge 1) {exporthtml $yellows yellows}
if ($greenscount -ge 1) {exporthtml $greens greens}
}
}
} else {
# Error handling if no files detected
write-host "[!] Something is wrong. Number of files identified: $filesum"
write-host "[?] Was Snaffler executed with parameter -y ?"
exit
}
# Start grid view if desired
if ($gridview) {
gridview start
}
# Check if shares should be exported as bookmarks to Explorer++
if ($pte) {
write-host "[*] Will export $($sharescount.lines) shares to explorer"
explorerpp($shares)
}