ThreatHunting/Export-AttackNavigatorLayer.ps1
|
function Export-AttackNavigatorLayer { <# .SYNOPSIS Exports threat hunting findings as a MITRE ATT&CK Navigator layer .DESCRIPTION Converts findings from WindowsSecurityAudit threat hunting functions (such as Get-MITREAttackMapping or Find-APTIndicators) into the MITRE ATT&CK Navigator layer JSON format (v4.x) so SOC teams can visualize technique coverage at https://mitre-attack.github.io/attack-navigator/. Input can be supplied directly via the pipeline (-InputObject) or loaded from a previously saved JSON file (-InputPath). Technique scores are derived from finding severity and detection counts and rendered with a red-yellow gradient. The generated file is validated to ensure it parses as JSON and contains all keys required by ATT&CK Navigator. .PARAMETER InputObject Findings object(s), typically the output of Get-MITREAttackMapping, Find-APTIndicators, or any object exposing TechniqueID properties. Accepts pipeline input. .PARAMETER InputPath Path to a JSON file previously saved from a threat hunting function (e.g. Get-MITREAttackMapping -FullAnalysis | ConvertTo-Json -Depth 10 | Set-Content findings.json) .PARAMETER OutputPath Path of the ATT&CK Navigator layer file to create. If an existing directory is supplied, a timestamped .json file is created inside it. .PARAMETER LayerName Display name of the layer in ATT&CK Navigator. Defaults to 'WindowsSecurityAudit Findings'. .PARAMETER Score Fixed score (0-100) applied to every technique. When omitted, each technique is scored automatically from its highest severity and detection count. .EXAMPLE Get-MITREAttackMapping -FullAnalysis | Export-AttackNavigatorLayer -OutputPath C:\Reports\layer.json .EXAMPLE Export-AttackNavigatorLayer -InputPath C:\Reports\findings.json -OutputPath C:\Reports\layer.json -LayerName "Workstation Hunt" .EXAMPLE Find-APTIndicators -DeepScan | Export-AttackNavigatorLayer -OutputPath C:\Reports -Score 80 .OUTPUTS PSCustomObject with export details (OutputPath, TechniqueCount, DetectionCount) #> [CmdletBinding()] param( [Parameter(ParameterSetName = 'Pipeline', Position = 0, ValueFromPipeline = $true)] [AllowNull()] [object[]]$InputObject, [Parameter(ParameterSetName = 'File', Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$InputPath, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$OutputPath, [Parameter()] [ValidateNotNullOrEmpty()] [string]$LayerName = 'WindowsSecurityAudit Findings', [Parameter()] [ValidateRange(0, 100)] [int]$Score ) begin { Write-Host "=== ATT&CK NAVIGATOR LAYER EXPORT ===" -ForegroundColor Cyan Write-Host "Start Time: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Yellow Write-Host "Input Mode: $($PSCmdlet.ParameterSetName)" -ForegroundColor Yellow $useFixedScore = $PSBoundParameters.ContainsKey('Score') # Severity weights used to score techniques on a 0-100 scale $severityScores = @{ 'Critical' = 100 'High' = 75 'Medium' = 50 'Low' = 25 } $defaultSeverityScore = 10 # Map module tactic names to ATT&CK Navigator kebab-case tactic names $tacticNameMap = @{ 'InitialAccess' = 'initial-access' 'Execution' = 'execution' 'Persistence' = 'persistence' 'PrivilegeEscalation'= 'privilege-escalation' 'DefenseEvasion' = 'defense-evasion' 'CredentialAccess' = 'credential-access' 'Discovery' = 'discovery' 'LateralMovement' = 'lateral-movement' 'Collection' = 'collection' 'CommandAndControl' = 'command-and-control' 'Exfiltration' = 'exfiltration' 'Impact' = 'impact' } # Red-yellow gradient endpoint interpolation (yellow #FFE766 -> red #FF6666) $getGradientColor = { param([int]$Value) $clamped = [Math]::Min(100, [Math]::Max(0, $Value)) $ratio = $clamped / 100.0 $red = 255 $green = [int](231 + ((102 - 231) * $ratio)) $blue = [int](102 + ((102 - 102) * $ratio)) return ('#{0:X2}{1:X2}{2:X2}' -f $red, $green, $blue) } $detectionRecords = [System.Collections.ArrayList]@() function New-TechniqueRecord { param( [string]$Id, [string]$Name, [string]$Tactic, [string]$Severity, [string]$Evidence, [string]$Source ) return [PSCustomObject]@{ TechniqueID = $Id TechniqueName = $Name Tactic = $Tactic Severity = $Severity Evidence = $Evidence DetectionSource = $Source } } function Get-TechniqueRecord { param([AllowNull()][object]$Item) $records = @() if ($null -eq $Item) { return $records } # Plain strings: scan for ATT&CK technique ID references if ($Item -is [string]) { foreach ($match in [regex]::Matches($Item, 'T\d{4}(\.\d{3})?')) { $records += New-TechniqueRecord -Id $match.Value -Name '' -Tactic '' -Severity '' -Evidence 'Text reference' -Source 'Raw' } return $records } # Hashtable / dictionary containers if ($Item -is [System.Collections.IDictionary]) { if ($Item.Contains('DetectedTechniques') -and $null -ne $Item['DetectedTechniques']) { foreach ($subItem in @($Item['DetectedTechniques'])) { $records += @(Get-TechniqueRecord -Item $subItem) } return $records } if ($Item.Contains('TechniqueID') -and $null -ne $Item['TechniqueID']) { $dictId = [string]$Item['TechniqueID'] if ($dictId -match '^T\d{4}(\.\d{3})?$') { $records += New-TechniqueRecord -Id $dictId ` -Name $(if ($Item.Contains('TechniqueName')) { [string]$Item['TechniqueName'] } else { '' }) ` -Tactic $(if ($Item.Contains('Tactic')) { [string]$Item['Tactic'] } else { '' }) ` -Severity $(if ($Item.Contains('Severity')) { [string]$Item['Severity'] } else { '' }) ` -Evidence $(if ($Item.Contains('Evidence')) { [string]$Item['Evidence'] } else { '' }) ` -Source $(if ($Item.Contains('DetectionSource')) { [string]$Item['DetectionSource'] } else { '' }) } return $records } # Fallback: scan string values for technique ID references foreach ($key in @($Item.Keys)) { if ($Item[$key] -is [string]) { foreach ($match in [regex]::Matches($Item[$key], 'T\d{4}(\.\d{3})?')) { $records += New-TechniqueRecord -Id $match.Value -Name '' -Tactic '' -Severity '' -Evidence "$key reference" -Source 'Raw' } } } return $records } $props = $Item.PSObject.Properties # Get-MITREAttackMapping style container: recurse into DetectedTechniques if ($null -ne $props['DetectedTechniques'] -and $null -ne $Item.DetectedTechniques) { foreach ($subItem in @($Item.DetectedTechniques)) { $records += @(Get-TechniqueRecord -Item $subItem) } return $records } # Find-APTIndicators style container: recurse into Indicators members if ($null -ne $props['Indicators'] -and $null -ne $Item.Indicators) { $indicatorContainer = $Item.Indicators if ($indicatorContainer -is [System.Collections.IDictionary]) { foreach ($key in @($indicatorContainer.Keys)) { foreach ($subItem in @($indicatorContainer[$key])) { $records += @(Get-TechniqueRecord -Item $subItem) } } } else { foreach ($containerProp in @($indicatorContainer.PSObject.Properties)) { foreach ($subItem in @($containerProp.Value)) { $records += @(Get-TechniqueRecord -Item $subItem) } } } return $records } # Detection object exposing a TechniqueID property if ($null -ne $props['TechniqueID'] -and $null -ne $Item.TechniqueID) { $id = [string]$Item.TechniqueID if ($id -match '^T\d{4}(\.\d{3})?$') { $records += New-TechniqueRecord -Id $id ` -Name $(if ($null -ne $props['TechniqueName']) { [string]$Item.TechniqueName } else { '' }) ` -Tactic $(if ($null -ne $props['Tactic']) { [string]$Item.Tactic } else { '' }) ` -Severity $(if ($null -ne $props['Severity']) { [string]$Item.Severity } else { '' }) ` -Evidence $(if ($null -ne $props['Evidence']) { [string]$Item.Evidence } else { '' }) ` -Source $(if ($null -ne $props['DetectionSource']) { [string]$Item.DetectionSource } else { '' }) } return $records } # Fallback: scan string properties for technique ID references $itemSeverity = '' if ($null -ne $props['Severity'] -and $null -ne $Item.Severity) { $itemSeverity = [string]$Item.Severity } foreach ($prop in $props) { if ($prop.Value -is [string]) { foreach ($match in [regex]::Matches($prop.Value, 'T\d{4}(\.\d{3})?')) { $records += New-TechniqueRecord -Id $match.Value -Name '' -Tactic '' -Severity $itemSeverity -Evidence "$($prop.Name) reference" -Source 'Raw' } } } return $records } # Load findings from a previously saved JSON file if ($PSCmdlet.ParameterSetName -eq 'File') { if (-not (Test-Path -LiteralPath $InputPath -PathType Leaf)) { Write-Error "Input file not found: $InputPath" throw "Input file not found: $InputPath" } try { $rawJson = Get-Content -LiteralPath $InputPath -Raw -Encoding UTF8 -ErrorAction Stop $loadedFindings = $rawJson | ConvertFrom-Json -ErrorAction Stop foreach ($loadedItem in @($loadedFindings)) { foreach ($record in @(Get-TechniqueRecord -Item $loadedItem)) { [void]$detectionRecords.Add($record) } } Write-Host "Loaded findings from: $InputPath" -ForegroundColor Yellow } catch { Write-Error "Failed to read or parse input file '$InputPath': $_" throw } } } process { if ($null -ne $InputObject) { foreach ($item in $InputObject) { if ($null -eq $item) { continue } try { foreach ($record in @(Get-TechniqueRecord -Item $item)) { [void]$detectionRecords.Add($record) } } catch { Write-Warning "Error extracting technique data from input item: $_" } } } } end { try { Write-Host "`nBuilding ATT&CK Navigator layer..." -ForegroundColor Cyan # Aggregate detections per technique ID $techniqueMap = [ordered]@{} foreach ($record in $detectionRecords) { if ($null -eq $record) { continue } $id = $record.TechniqueID if ([string]::IsNullOrWhiteSpace($id)) { continue } if (-not $techniqueMap.Contains($id)) { $techniqueMap[$id] = @{ Detections = 0 BestSeverity = '' BestSeverityScore = 0 Names = @() Tactic = '' Sources = @() Evidences = @() } } $entry = $techniqueMap[$id] $entry.Detections++ if ($severityScores.ContainsKey($record.Severity)) { $recordSeverityScore = $severityScores[$record.Severity] } else { $recordSeverityScore = $defaultSeverityScore } if ($recordSeverityScore -gt $entry.BestSeverityScore) { $entry.BestSeverityScore = $recordSeverityScore $entry.BestSeverity = $record.Severity } if (-not [string]::IsNullOrWhiteSpace($record.TechniqueName) -and $entry.Names -notcontains $record.TechniqueName -and $entry.Names.Count -lt 3) { $entry.Names += $record.TechniqueName } if (-not [string]::IsNullOrWhiteSpace($record.Tactic) -and [string]::IsNullOrWhiteSpace($entry.Tactic)) { $entry.Tactic = $record.Tactic } if (-not [string]::IsNullOrWhiteSpace($record.DetectionSource) -and $entry.Sources -notcontains $record.DetectionSource) { $entry.Sources += $record.DetectionSource } if (-not [string]::IsNullOrWhiteSpace($record.Evidence) -and $entry.Evidences -notcontains $record.Evidence -and $entry.Evidences.Count -lt 3) { $evidence = $record.Evidence if ($evidence.Length -gt 120) { $evidence = $evidence.Substring(0, 117) + '...' } $entry.Evidences += $evidence } } # Build technique entries with severity-based gradient scores $techniques = @() $severitiesSeen = @{} foreach ($id in @($techniqueMap.Keys)) { $entry = $techniqueMap[$id] if ($useFixedScore) { $scoreValue = $Score } else { $scoreValue = [Math]::Min(100, $entry.BestSeverityScore + (($entry.Detections - 1) * 5)) } $commentParts = @("Detections: $($entry.Detections)") if (-not [string]::IsNullOrWhiteSpace($entry.BestSeverity)) { $commentParts += "Highest severity: $($entry.BestSeverity)" } if ($entry.Names.Count -gt 0) { $commentParts += ($entry.Names -join ' / ') } if (-not [string]::IsNullOrWhiteSpace($entry.Tactic)) { $commentParts += "Tactic: $($entry.Tactic)" } if ($entry.Sources.Count -gt 0) { $commentParts += "Sources: $($entry.Sources -join ', ')" } if ($entry.Evidences.Count -gt 0) { $commentParts += "Evidence: $($entry.Evidences -join '; ')" } $comment = $commentParts -join ' | ' if ($comment.Length -gt 500) { $comment = $comment.Substring(0, 497) + '...' } $techniqueEntry = [ordered]@{ techniqueID = $id score = $scoreValue color = (& $getGradientColor $scoreValue) comment = $comment enabled = $true links = @() metadata = @() } if (-not [string]::IsNullOrWhiteSpace($entry.Tactic) -and $tacticNameMap.ContainsKey($entry.Tactic)) { $techniqueEntry['tactic'] = $tacticNameMap[$entry.Tactic] } $techniques += $techniqueEntry if (-not [string]::IsNullOrWhiteSpace($entry.BestSeverity)) { $severitiesSeen[$entry.BestSeverity] = $true } } # Sort techniques by score (descending), then technique ID $techniques = @($techniques | Sort-Object -Property @{ Expression = 'score'; Descending = $true }, @{ Expression = 'techniqueID'; Descending = $false }) if ($techniques.Count -eq 0) { Write-Warning "No ATT&CK technique IDs found in the input. An empty layer will be exported." } # Legend items for observed severities $severityLegendColors = @{ 'Critical' = '#FF6666' 'High' = '#FFB366' 'Medium' = '#FFE766' 'Low' = '#FFFFB3' } $legendItems = @() foreach ($severityName in @('Critical', 'High', 'Medium', 'Low')) { if ($severitiesSeen.ContainsKey($severityName)) { $legendItems += [ordered]@{ label = "$severityName severity detections" color = $severityLegendColors[$severityName] } } } if ($useFixedScore) { $legendItems += [ordered]@{ label = "Fixed score applied: $Score"; color = '' } } else { $legendItems += [ordered]@{ label = 'Score = severity weight + detection count'; color = '' } } # ATT&CK Navigator layer format v4.x $layer = [ordered]@{ name = $LayerName versions = [ordered]@{ attack = '16' navigator = '5.1.0' layer = '4.5' } domain = 'enterprise-attack' description = "Generated by the WindowsSecurityAudit module on $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'). $($detectionRecords.Count) detection(s) across $($techniqueMap.Count) unique technique(s)." filters = [ordered]@{ platforms = @('Windows') } sorting = 3 layout = [ordered]@{ layout = 'side' aggregateFunction = 'average' showID = $false showName = $true showAggregateScores = $false countUnscored = $false } hideDisabled = $false techniques = $techniques gradient = [ordered]@{ colors = @('#FFE766', '#FF6666') minValue = 0 maxValue = 100 } legendItems = $legendItems metadata = @( [ordered]@{ name = 'Generated by'; value = 'WindowsSecurityAudit module' } [ordered]@{ name = 'Total detections'; value = "$($detectionRecords.Count)" } [ordered]@{ name = 'Unique techniques'; value = "$($techniqueMap.Count)" } ) links = @() showTacticRowBackground = $false tacticRowBackground = '#DDDDDD' selectTechniquesAcrossTactics = $true selectSubtechniquesWithParent = $false } # Resolve output path (accept a directory or a file path) $resolvedOutput = $OutputPath if (Test-Path -LiteralPath $resolvedOutput -PathType Container) { $resolvedOutput = Join-Path -Path $resolvedOutput -ChildPath ("AttackNavigatorLayer_{0}.json" -f (Get-Date -Format 'yyyyMMdd_HHmmss')) } if ([System.IO.Path]::GetExtension($resolvedOutput) -ne '.json') { $resolvedOutput += '.json' } if ([System.IO.Path]::IsPathRooted($resolvedOutput)) { $fullPath = $resolvedOutput } else { $fullPath = Join-Path -Path (Get-Location).Path -ChildPath $resolvedOutput } $parentDir = Split-Path -Path $fullPath -Parent if (-not [string]::IsNullOrWhiteSpace($parentDir) -and -not (Test-Path -LiteralPath $parentDir)) { New-Item -Path $parentDir -ItemType Directory -Force | Out-Null } # Write layer JSON (UTF-8 without BOM for ATT&CK Navigator compatibility) $layerJson = $layer | ConvertTo-Json -Depth 10 [System.IO.File]::WriteAllText($fullPath, $layerJson) # Validate the exported layer parses as JSON and contains required keys Write-Host "Validating exported layer..." -ForegroundColor Cyan try { $savedJson = [System.IO.File]::ReadAllText($fullPath) $parsedLayer = $savedJson | ConvertFrom-Json -ErrorAction Stop $requiredKeys = @('name', 'versions', 'domain', 'techniques', 'gradient', 'legendItems') foreach ($key in $requiredKeys) { if ($null -eq $parsedLayer.PSObject.Properties[$key]) { throw "Missing required layer key: $key" } } foreach ($versionKey in @('attack', 'navigator', 'layer')) { if ($null -eq $parsedLayer.versions.PSObject.Properties[$versionKey]) { throw "Missing required versions key: $versionKey" } } if ($parsedLayer.domain -ne 'enterprise-attack') { throw "Unexpected domain value: $($parsedLayer.domain)" } Write-Host "Layer validated: JSON parses and contains all required keys" -ForegroundColor Green } catch { Write-Error "Generated layer file failed validation: $_" throw } Write-Host "`n=== EXPORT COMPLETE ===" -ForegroundColor Green Write-Host "`nLayer Summary:" -ForegroundColor Cyan Write-Host " Layer Name: $LayerName" -ForegroundColor White Write-Host " Unique Techniques: $($techniqueMap.Count)" -ForegroundColor White Write-Host " Total Detections: $($detectionRecords.Count)" -ForegroundColor White Write-Host " Output File: $fullPath" -ForegroundColor White if ($techniques.Count -gt 0) { Write-Host "`nTop Scored Techniques:" -ForegroundColor Cyan $techniques | Select-Object -First 5 | ForEach-Object { Write-Host " $($_.techniqueID) [Score: $($_.score)]" -ForegroundColor Yellow } } Write-Host "`nImport the layer into ATT&CK Navigator:" -ForegroundColor Cyan Write-Host " https://mitre-attack.github.io/attack-navigator/" -ForegroundColor Gray return [PSCustomObject]@{ LayerName = $LayerName OutputPath = $fullPath TechniqueCount = $techniqueMap.Count DetectionCount = $detectionRecords.Count NavigatorUrl = 'https://mitre-attack.github.io/attack-navigator/' } } catch { Write-Error "Error exporting ATT&CK Navigator layer: $_" throw } } } |