#Requires -Version 5.1 <# .SYNOPSIS Vector 1 - Scoreboard: cruza lo plantado, lo enviado, lo recibido y las alertas. .DESCRIPTION Cuatro fuentes de verdad, y las cuatro se cruzan por canary_id: 1. Manifiesto de implantacion (plant-*.jsonl) -> lo que se puso en el activo 2. Manifiesto de exfiltracion (exfil-*.jsonl) -> lo que el cliente dice haber enviado 3. Recibos del C2 (receipts.jsonl o ?action=list) -> lo que el servidor recibio 4. CSV de alertas de Blue Team (opcional) -> quien lo detecto y cuando Metricas que produce: - Cobertura de exfiltracion: recibidos / enviados y recibidos / plantados - Integridad: sha256 del cliente vs sha256 del servidor - Deteccion: % de canarios con alerta, por control - TTFA: minutos desde el envio hasta la primera alerta (media, minimo, maximo) - Falsos negativos: canarios que salieron y nadie vio - Telemetria DNS: consultas registradas por el resolver del laboratorio Es de solo lectura: no modifica nada. .EXAMPLE .\Get-Scoreboard.ps1 -ScopePath ..\scope\scope.vector1.json ` -ReceiptsPath '\\LAB-SRV\c2-storage\receipts.jsonl' ` -AlertsCsv .\alertas-blue.csv #> [CmdletBinding()] param( [string]$ScopePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'scope\scope.vector1.json'), [string]$RunId = 'latest', [string]$ReceiptsPath, [string]$ReceiptsUrl, [string]$AlertsCsv, [string]$DnsLogPath, [string]$OutDir ) . (Join-Path (Split-Path -Parent $PSScriptRoot) 'lib\Common.ps1') . (Join-Path (Split-Path -Parent $PSScriptRoot) 'lib\Canary.ps1') function Get-ColumnName { param($Names, [string]$Pattern) return ($Names | Where-Object { $_ -match $Pattern } | Select-Object -First 1) } function ConvertTo-Utc { param([string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return $null } try { return [datetime]::Parse($Text, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal) } catch { return $null } } $cfg = Get-RTCanaryConfig -Path $ScopePath $manifestRoot = Expand-RTPath -Path $cfg.manifest_root $logRoot = Expand-RTPath -Path $cfg.log_root if (-not $OutDir) { $OutDir = $logRoot } $OutDir = New-RTDirectory -Path $OutDir $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss') Write-Host '' Write-Host '=== Scoreboard Vector 1 ===' -ForegroundColor Cyan Write-Host "Manifiestos: $manifestRoot" # -------------------------------------------------------------------------- # 1. Lo plantado # -------------------------------------------------------------------------- $plantManifests = @(Get-ChildItem -LiteralPath $manifestRoot -Filter 'plant-*.jsonl' -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending) if ($RunId -ne 'latest') { $plantManifests = @($plantManifests | Where-Object { $_.Name -like "*$RunId*" }) } $planted = @{} foreach ($m in $plantManifests) { foreach ($e in @(Get-RTManifestEntries -ManifestPath $m.FullName | Where-Object { $_.type -eq 'plant' -and $_.verified })) { $planted[[string]$e.canary_id] = $e } } Write-Host ("Plantados y verificados : {0}" -f $planted.Count) # -------------------------------------------------------------------------- # 2. Lo que el cliente dice haber enviado # -------------------------------------------------------------------------- $exfilManifests = @(Get-ChildItem -LiteralPath $manifestRoot -Filter 'exfil-*.jsonl' -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending) if ($RunId -ne 'latest') { $exfilManifests = @($exfilManifests | Where-Object { $_.Name -like "*$RunId*" }) } $sent = @{} $dnsClient = @() foreach ($m in $exfilManifests) { foreach ($e in @(Get-RTManifestEntries -ManifestPath $m.FullName)) { if ($e.type -eq 'exfil') { $sent[[string]$e.canary_id] = $e } elseif ($e.type -eq 'dns') { $dnsClient += $e } } } Write-Host ("Enviados (segun cliente): {0} consultas DNS declaradas: {1}" -f $sent.Count, $dnsClient.Count) # -------------------------------------------------------------------------- # 3. Lo que el C2 recibio # -------------------------------------------------------------------------- $receipts = @() if ($ReceiptsUrl) { try { $token = Get-RTTokenFromConfig -Config $cfg $sep = if ($ReceiptsUrl.Contains('?')) { '&' } else { '?' } $uri = '{0}{1}action=list' -f $ReceiptsUrl, $sep $resp = Invoke-WebRequest -Uri $uri -Headers @{ Authorization = "Bearer $token" } ` -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop $receipts = @(($resp.Content | ConvertFrom-Json).receipts) Write-Host "Recibos obtenidos por HTTP: $($receipts.Count)" } catch { Write-Warning "No se pudieron obtener los recibos por HTTP: $($_.Exception.Message)" } } elseif ($ReceiptsPath) { if (Test-Path -LiteralPath $ReceiptsPath) { $receipts = @(Get-Content -LiteralPath $ReceiptsPath | Where-Object { $_ -match '\S' } | ForEach-Object { try { $_ | ConvertFrom-Json } catch { } }) Write-Host "Recibos leidos de fichero : $($receipts.Count)" } else { Write-Warning "No existe el fichero de recibos: $ReceiptsPath" } } else { Write-Warning "Sin -ReceiptsPath ni -ReceiptsUrl: la cobertura del C2 no se puede calcular." } $received = @{} foreach ($r in $receipts) { $id = [string]$r.canary_id if ([string]::IsNullOrWhiteSpace($id)) { continue } if (-not $received.ContainsKey($id)) { $received[$id] = $r } } # -------------------------------------------------------------------------- # 4. Alertas de Blue Team (opcional) # -------------------------------------------------------------------------- $alertMap = @{} $controls = @{} if ($AlertsCsv -and (Test-Path -LiteralPath $AlertsCsv)) { $rows = @(Import-Csv -LiteralPath $AlertsCsv) if ($rows.Count -gt 0) { $names = $rows[0].PSObject.Properties.Name $cId = Get-ColumnName -Names $names -Pattern '^(?i)canary_?id$' $cTs = Get-ColumnName -Names $names -Pattern '^(?i)(alert_?utc|alert_?time|ts_?utc|timestamp)$' $cCtl = Get-ColumnName -Names $names -Pattern '^(?i)(control|source|origen)$' if (-not $cId -or -not $cTs) { Write-Warning "El CSV de alertas necesita columnas de canario y de hora (CanaryId, AlertUtc)." } else { foreach ($row in $rows) { $id = [string]$row.$cId if ([string]::IsNullOrWhiteSpace($id)) { continue } $ts = ConvertTo-Utc -Text ([string]$row.$cTs) $ctl = if ($cCtl) { [string]$row.$cCtl } else { 'sin especificar' } if (-not $alertMap.ContainsKey($id) -or ($ts -and $ts -lt $alertMap[$id].ts)) { $alertMap[$id] = [pscustomobject]@{ ts = $ts; control = $ctl } } if ($controls.ContainsKey($ctl)) { $controls[$ctl]++ } else { $controls[$ctl] = 1 } } } } } # -------------------------------------------------------------------------- # 5. Telemetria del resolver DNS del laboratorio (opcional) # -------------------------------------------------------------------------- $dnsServer = @() if ($DnsLogPath -and (Test-Path -LiteralPath $DnsLogPath)) { $dnsServer = @(Get-Content -LiteralPath $DnsLogPath | Where-Object { $_ -match '\S' } | ForEach-Object { try { $_ | ConvertFrom-Json } catch { } }) } # -------------------------------------------------------------------------- # 6. Cruce # -------------------------------------------------------------------------- $allIds = @($planted.Keys + $sent.Keys + $received.Keys | Sort-Object -Unique) $rows = @() $ttfas = New-Object System.Collections.ArrayList foreach ($id in $allIds) { $p = if ($planted.ContainsKey($id)) { $planted[$id] } else { $null } $s = if ($sent.ContainsKey($id)) { $sent[$id] } else { $null } $r = if ($received.ContainsKey($id)) { $received[$id] } else { $null } $a = if ($alertMap.ContainsKey($id)) { $alertMap[$id] } else { $null } $integrity = '' if ($s -and $r) { $integrity = if ([string]$s.sha256 -eq [string]$r.sha256) { 'coincide' } else { 'DIVERGE' } } $ttfa = $null if ($a -and $a.ts -and $s) { $sentTs = ConvertTo-Utc -Text ([string]$s.ts_utc) if ($sentTs) { $ttfa = [Math]::Round((($a.ts) - $sentTs).TotalMinutes, 2); [void]$ttfas.Add($ttfa) } } $rows += [pscustomobject]@{ canary_id = $id host = if ($p) { $p.host } elseif ($s) { $env:COMPUTERNAME } else { '' } plantado = [bool]$p enviado = [bool]($s -and $s.ok) recibido = [bool]$r integridad = $integrity canal = if ($s) { $s.channel } else { '' } modo = if ($s) { $s.mode } else { '' } bytes = if ($r) { $r.bytes } elseif ($s) { $s.bytes } else { 0 } detectado = [bool]$a control = if ($a) { $a.control } else { '' } ttfa_min = $ttfa } } $plantedN = @($rows | Where-Object { $_.plantado }).Count $sentOkN = @($rows | Where-Object { $_.enviado }).Count $receivedN = @($rows | Where-Object { $_.recibido }).Count $integrityN = @($rows | Where-Object { $_.integridad -eq 'coincide' }).Count $divergedN = @($rows | Where-Object { $_.integridad -eq 'DIVERGE' }).Count $detectedN = @($rows | Where-Object { $_.detectado }).Count $undetected = @($rows | Where-Object { $_.recibido -and (-not $_.detectado) }) $metrics = [ordered]@{ generated_utc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') exercise_id = $cfg.exercise_id plantados = $plantedN enviados_ok = $sentOkN recibidos_c2 = $receivedN cobertura_de_enviados = if ($sentOkN -gt 0) { [Math]::Round(100.0 * $receivedN / $sentOkN, 1) } else { 0 } cobertura_de_plantados = if ($plantedN -gt 0) { [Math]::Round(100.0 * $receivedN / $plantedN, 1) } else { 0 } integridad_ok = $integrityN integridad_divergente = $divergedN detectados = $detectedN tasa_deteccion_pct = if ($receivedN -gt 0) { [Math]::Round(100.0 * $detectedN / $receivedN, 1) } else { 0 } ttfa_media_min = if ($ttfas.Count -gt 0) { [Math]::Round(($ttfas | Measure-Object -Average).Average, 2) } else { $null } ttfa_min_min = if ($ttfas.Count -gt 0) { ($ttfas | Measure-Object -Minimum).Minimum } else { $null } ttfa_max_min = if ($ttfas.Count -gt 0) { ($ttfas | Measure-Object -Maximum).Maximum } else { $null } falsos_negativos = $undetected.Count deteccion_por_control = $controls dns_consultas_cliente = $dnsClient.Count dns_consultas_resolver = $dnsServer.Count dns_nombres_unicos = @($dnsServer | Select-Object -ExpandProperty qname -Unique).Count dns_label_max = if ($dnsServer.Count -gt 0) { ($dnsServer | Measure-Object -Property label_max -Maximum).Maximum } else { $null } dns_entropia_media = if ($dnsServer.Count -gt 0) { [Math]::Round(($dnsServer | Measure-Object -Property entropy -Average).Average, 3) } else { $null } } $csvPath = Join-Path $OutDir ("scoreboard-$stamp.csv") $jsonPath = Join-Path $OutDir ("scoreboard-$stamp.json") $rows | Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding UTF8 ([ordered]@{ metrics = $metrics; detalle = $rows }) | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $jsonPath -Encoding UTF8 # -------------------------------------------------------------------------- # 7. Salida # -------------------------------------------------------------------------- $rows | Sort-Object canary_id | Format-Table canary_id, host, plantado, enviado, recibido, integridad, detectado, control, ttfa_min -AutoSize Write-Host '' $metrics.GetEnumerator() | Where-Object { $_.Key -notlike 'deteccion_por_control' } | ForEach-Object { Write-Host (" {0,-26} {1}" -f $_.Key, $_.Value) } if ($metrics.deteccion_por_control -and @($metrics.deteccion_por_control.Keys).Count -gt 0) { Write-Host '' Write-Host ' Deteccion por control:' -ForegroundColor Cyan $metrics.deteccion_por_control.GetEnumerator() | ForEach-Object { Write-Host (" {0,-28} {1}" -f $_.Key, $_.Value) } } if ($undetected.Count -gt 0) { Write-Host '' Write-Host ("FALSOS NEGATIVOS ({0}) - salieron y nadie los vio:" -f $undetected.Count) -ForegroundColor Red $undetected | ForEach-Object { Write-Host (" - {0} ({1} / {2})" -f $_.canary_id, $_.canal, $_.modo) -ForegroundColor Red } } Write-Host '' Write-Host "CSV : $csvPath" -ForegroundColor Green Write-Host "JSON: $jsonPath" -ForegroundColor Green