#Requires -Version 5.1 <# .SYNOPSIS Vector 2 - Reversion verificada de la simulacion de cifrado. .DESCRIPTION Restaura los ficheros a partir del manifiesto append-only generado por Invoke-RansomSim.ps1, verificando sha256 en cada paso. No depende de ninguna convencion de carpetas: la verdad esta en el manifiesto. Modo conservador: ante cualquier duda NO borra nada y marca el fichero como 'manual_review' o 'conflict' en el informe. Asimetria deliberada respecto a Invoke-RansomSim.ps1: - Solo requiere -Execute (no exige dry_run=false): la reversion NUNCA debe quedar bloqueada por la configuracion. - Ignora la ventana temporal y el STOP flag: el rollback debe poder correr en cualquier momento, incluso durante un incidente. .EXAMPLE .\Invoke-Revert.ps1 -Execute Revierte la ultima ejecucion de este host. .EXAMPLE .\Invoke-Revert.ps1 -RunId RT-2026-01-20260310-081500-ab12 -Execute -KeepBackup Revierte una ejecucion concreta y conserva el respaldo para auditoria. #> [CmdletBinding()] param( [string]$ScopePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'scope\scope.json'), [string]$RunId = 'latest', [switch]$Execute, [switch]$KeepBackup, [switch]$Force ) . (Join-Path (Split-Path -Parent $PSScriptRoot) 'lib\Common.ps1') $harnessRoot = Split-Path -Parent $PSScriptRoot $startedUtc = (Get-Date).ToUniversalTime() $config = Get-RTConfig -Path $ScopePath $manifestRoot = Expand-RTPath -Path $config.manifest_root $logRoot = Expand-RTPath -Path $config.log_root $backupRoot = Expand-RTPath -Path $config.backup_root $activeMarker = Join-Path $manifestRoot ("active-$($env:COMPUTERNAME).json") New-RTDirectory -Path $logRoot | Out-Null # -------------------------------------------------------------------------- # 1. Localizar el manifiesto # -------------------------------------------------------------------------- $candidates = @() if (Test-Path -LiteralPath $manifestRoot) { $candidates = @(Get-ChildItem -LiteralPath $manifestRoot -Filter "manifest-$($env:COMPUTERNAME)-*.jsonl" -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending) } if ($RunId -ne 'latest') { $candidates = @($candidates | Where-Object { $_.Name -like "*$RunId*" }) } if ($candidates.Count -eq 0) { throw "No hay manifiesto para el host $env:COMPUTERNAME (run: $RunId) en $manifestRoot. Nada que revertir." } $manifestPath = $candidates[0].FullName $entries = Get-RTManifestEntries -ManifestPath $manifestPath $header = $entries | Where-Object { $_.type -eq 'header' } | Select-Object -First 1 $realRunId = if ($header) { $header.run_id } else { [System.IO.Path]::GetFileNameWithoutExtension($manifestPath) } $logPath = Join-Path $logRoot ("revert-$realRunId.log") Write-RTLog -LogPath $logPath -Message "================================================================" Write-RTLog -LogPath $logPath -Message "Revert del Vector 2. run_id=$realRunId Host=$env:COMPUTERNAME" Write-RTLog -LogPath $logPath -Message "Manifiesto: $manifestPath" if (-not $Execute) { Write-RTLog -LogPath $logPath -Level DRYRUN -Message "MODO SIMULACION: no se restaura ni se borra nada. Anade -Execute para actuar." } # -------------------------------------------------------------------------- # 2. Estado final por fichero (la ultima entrada del manifiesto manda) # -------------------------------------------------------------------------- $files = @{} foreach ($e in $entries) { if ($e.type -ne 'file') { continue } if ([string]::IsNullOrWhiteSpace($e.original)) { continue } $files[[string]$e.original] = $e } if ($files.Count -eq 0) { Write-RTLog -LogPath $logPath -Level WARN -Message "El manifiesto no contiene entradas de fichero." } $report = @() $counters = [ordered]@{ total = $files.Count; restored = 0; already_ok = 0 conflicts = 0; manual_review = 0; cleaned = 0 } # Se deshace en orden inverso al de la ejecucion. $keys = @($files.Keys) [array]::Reverse($keys) foreach ($original in $keys) { $rec = $files[$original] $backup = [string]$rec.backup $encrypted = [string]$rec.encrypted $shaBefore = [string]$rec.sha256_before $backupExists = (-not [string]::IsNullOrWhiteSpace($backup)) -and (Test-Path -LiteralPath $backup -PathType Leaf) $origExists = Test-Path -LiteralPath $original -PathType Leaf $encExists = (-not [string]::IsNullOrWhiteSpace($encrypted)) -and (Test-Path -LiteralPath $encrypted -PathType Leaf) $status = 'unknown' $note = '' if (-not $backupExists) { # El 'cifrado' es SOLO un cambio de nombre: el fichero renombrado ES el original. # Si su sha256 coincide con el del manifiesto, renombrarlo de vuelta es una # restauracion demostrable y NO necesita el respaldo. Sin esta salida, un respaldo # perdido (p. ej. porque cambio backup_root entre la ejecucion y la reversion) # dejaba los ficheros atrapados con la extension simulada. if ($origExists -and ((Get-RTFileSha256 -Path $original) -eq $shaBefore)) { $status = 'already_restored' } elseif ($origExists) { $status = 'conflict'; $note = 'existe contenido distinto en la ruta original y no hay respaldo' } elseif ($encExists -and ((Get-RTFileSha256 -Path $encrypted) -eq $shaBefore)) { $status = 'pending_restore_from_renamed' } else { $status = 'manual_review'; $note = 'el respaldo no existe y el fichero renombrado no verifica' } } elseif ((Get-RTFileSha256 -Path $backup) -ne $shaBefore) { $status = 'manual_review'; $note = 'el sha256 del respaldo no coincide con el manifiesto' } elseif (-not $origExists) { $status = 'pending_restore' } elseif ((Get-RTFileSha256 -Path $original) -eq $shaBefore) { $status = 'already_restored' } else { $status = 'conflict'; $note = 'existe contenido distinto en la ruta original' } # Interpretacion del alcance (aviso, no bloqueo): la reversion debe poder correr siempre. if (-not (Test-RTPathAllowed -Path $original -Config $config)) { Write-RTLog -LogPath $logPath -Level WARN -Message "La ruta original ya no esta en el allowlist actual: $original (se continua, es una reversion)" } $action = 'ninguna' if ($Execute) { switch ($status) { 'pending_restore' { try { $parent = Split-Path -Parent $original if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } Copy-Item -LiteralPath $backup -Destination $original -Force if ((Get-RTFileSha256 -Path $original) -ne $shaBefore) { Remove-Item -LiteralPath $original -Force -ErrorAction SilentlyContinue $status = 'manual_review'; $note = 'la verificacion posterior a la restauracion fallo; se deshizo la copia' $action = 'restauracion deshecha' } else { $action = 'restaurado desde respaldo' $counters.restored++ if ($encExists -and ($encrypted -ne $original)) { Remove-Item -LiteralPath $encrypted -Force -ErrorAction Stop $counters.cleaned++ } } } catch { $status = 'manual_review'; $note = "error al restaurar: $($_.Exception.Message)"; $action = 'fallo' } } 'pending_restore_from_renamed' { try { $parent = Split-Path -Parent $original if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } Move-Item -LiteralPath $encrypted -Destination $original -Force -ErrorAction Stop if ((Get-RTFileSha256 -Path $original) -ne $shaBefore) { Move-Item -LiteralPath $original -Destination $encrypted -Force -ErrorAction SilentlyContinue $status = 'manual_review'; $note = 'la verificacion posterior fallo; se deshizo el renombrado' $action = 'renombrado deshecho' } else { $action = 'restaurado desde el fichero renombrado (sha256 verificado, sin respaldo)' $counters.restored++ } } catch { $status = 'manual_review'; $note = "error al renombrar de vuelta: $($_.Exception.Message)"; $action = 'fallo' } } 'already_restored' { $action = 'ya estaba restaurado' $counters.already_ok++ if ($encExists -and ($encrypted -ne $original)) { try { Remove-Item -LiteralPath $encrypted -Force -ErrorAction Stop $counters.cleaned++ $action = 'ya estaba restaurado; se elimino la copia simulada' } catch { $status = 'manual_review'; $note = "no se pudo eliminar '$encrypted': $($_.Exception.Message)" } } } 'conflict' { $counters.conflicts++ } 'manual_review' { $counters.manual_review++ } } } else { switch ($status) { 'pending_restore' { $counters.restored++ } 'pending_restore_from_renamed' { $counters.restored++ } 'already_restored' { $counters.already_ok++ } 'conflict' { $counters.conflicts++ } 'manual_review' { $counters.manual_review++ } } } $report += [ordered]@{ original = $original encrypted = $encrypted backup = $backup sha256_before = $shaBefore status = $status action = $action note = $note } $level = switch ($status) { 'conflict' { 'ERROR' } 'manual_review' { 'ERROR' } default { 'INFO' } } Write-RTLog -LogPath $logPath -Level $level -Message ("{0,-16} {1} {2}" -f $status, $original, $(if ($note) { "-> $note" } else { '' })) } # -------------------------------------------------------------------------- # 3. Estado global: registro, fondo, nota # -------------------------------------------------------------------------- $globalState = [ordered]@{} if ($Execute) { if ([bool]$config.options.registry_marker) { $reg = [string]$config.options.registry_path if (Test-Path -LiteralPath $reg) { try { Remove-Item -LiteralPath $reg -Recurse -Force -ErrorAction Stop $globalState.registry = 'eliminada' Write-RTLog -LogPath $logPath -Level ACTION -Message "Clave de registro eliminada: $reg" } catch { $globalState.registry = "error: $($_.Exception.Message)" Write-RTLog -LogPath $logPath -Level WARN -Message "No se pudo eliminar la clave de registro: $($_.Exception.Message)" } } else { $globalState.registry = 'no existia' } } # Notas de rescate. Se borran EXACTAMENTE las copias registradas en el manifiesto, y # solo si su contenido lleva el VICTIM ID de esta ejecucion: asi no se toca ningun # README ajeno que estuviera en esas carpetas. $noteEntries = @($entries | Where-Object { $_.type -eq 'note' }) if ($noteEntries.Count -gt 0) { $victimId = [string](($entries | Where-Object { $_.type -eq 'victim' } | Select-Object -First 1).victim_id) $borradas = 0 $omitidas = 0 foreach ($n in $noteEntries) { $p = [string]$n.path if ([string]::IsNullOrWhiteSpace($p) -or -not (Test-Path -LiteralPath $p)) { continue } try { $txt = Get-Content -LiteralPath $p -Raw -ErrorAction Stop if ($victimId -and ($txt -notlike "*$victimId*")) { $omitidas++ Write-RTLog -LogPath $logPath -Level WARN -Message "Nota NO eliminada (no lleva el Victim ID): $p" continue } Remove-Item -LiteralPath $p -Force -ErrorAction Stop $borradas++ Write-RTLog -LogPath $logPath -Level ACTION -Message "Nota eliminada: $p" } catch { $omitidas++ Write-RTLog -LogPath $logPath -Level WARN -Message "No se pudo eliminar la nota '$p': $($_.Exception.Message)" } } $globalState.notes = "eliminadas=$borradas omitidas=$omitidas victim_id=$victimId" } elseif ([bool]$config.options.write_readme) { # Compatibilidad con manifiestos de ejecuciones anteriores (sin entradas 'note'). $desktop = [Environment]::GetFolderPath('Desktop') $readme = if ($desktop) { Join-Path $desktop 'README.txt' } else { $null } if ($readme -and (Test-Path -LiteralPath $readme)) { try { Remove-Item -LiteralPath $readme -Force -ErrorAction Stop $globalState.readme = 'eliminada (manifiesto antiguo)' Write-RTLog -LogPath $logPath -Level ACTION -Message "Nota eliminada: $readme" } catch { $globalState.readme = "error: $($_.Exception.Message)" } } else { $globalState.readme = 'no existia' } } if ([bool]$config.options.set_wallpaper) { $asset = [string]$config.options.wallpaper_restore if (-not [System.IO.Path]::IsPathRooted($asset)) { $asset = Join-Path $harnessRoot $asset } if (-not (Test-Path -LiteralPath $asset)) { $globalState.wallpaper = "PENDIENTE: falta el asset '$asset'. Captura el fondo original con .\Save-RTCurrentWallpaper.ps1 antes del ejercicio." Write-RTLog -LogPath $logPath -Level WARN -Message $globalState.wallpaper } else { try { $applied = Set-RTWallpaper -Path $asset $globalState.wallpaper = "restaurado: $applied" Write-RTLog -LogPath $logPath -Level ACTION -Message "Fondo restaurado: $applied" } catch { $globalState.wallpaper = "error: $($_.Exception.Message)" Write-RTLog -LogPath $logPath -Level WARN -Message "No se pudo restaurar el fondo: $($_.Exception.Message)" } } } } # -------------------------------------------------------------------------- # 4. Cierre: manifiesto, respaldo y marca de ejecucion activa # -------------------------------------------------------------------------- $unresolved = @($report | Where-Object { $_.status -in @('conflict', 'manual_review') }) if ($Execute) { $baseName = [System.IO.Path]::GetFileNameWithoutExtension($manifestPath) if ($baseName -notlike '*-reverted') { $baseName = "$baseName-reverted" } $revertedManifest = Join-Path (Split-Path -Parent $manifestPath) ($baseName + '.jsonl') try { Move-Item -LiteralPath $manifestPath -Destination $revertedManifest -Force -ErrorAction Stop Write-RTLog -LogPath $logPath -Message "Manifiesto archivado como: $revertedManifest" } catch { Write-RTLog -LogPath $logPath -Level WARN -Message "No se pudo archivar el manifiesto: $($_.Exception.Message)" } if (Test-Path -LiteralPath $activeMarker) { Remove-Item -LiteralPath $activeMarker -Force -ErrorAction SilentlyContinue } $runBackupDir = Join-Path $backupRoot $realRunId if (Test-Path -LiteralPath $runBackupDir) { if ($KeepBackup) { Write-RTLog -LogPath $logPath -Message "Respaldo conservado por peticion: $runBackupDir" } elseif ($unresolved.Count -gt 0) { Write-RTLog -LogPath $logPath -Level WARN -Message "Hay $($unresolved.Count) fichero(s) sin resolver: se CONSERVA el respaldo en $runBackupDir" } else { try { Remove-Item -LiteralPath $runBackupDir -Recurse -Force -ErrorAction Stop Write-RTLog -LogPath $logPath -Message "Respaldo eliminado: $runBackupDir" } catch { Write-RTLog -LogPath $logPath -Level WARN -Message "No se pudo eliminar el respaldo: $($_.Exception.Message)" } } } } # -------------------------------------------------------------------------- # 5. Informe # -------------------------------------------------------------------------- $finishedUtc = (Get-Date).ToUniversalTime() $result = [ordered]@{ run_id = $realRunId host = $env:COMPUTERNAME user = $env:USERNAME acting = [bool]$Execute started_utc = $startedUtc.ToString('yyyy-MM-ddTHH:mm:ssZ') finished_utc = $finishedUtc.ToString('yyyy-MM-ddTHH:mm:ssZ') manifest = $manifestPath counters = $counters global_state = $globalState files = $report } $reportPath = Join-Path $logRoot ("revert-report-$realRunId.json") $result | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $reportPath -Encoding UTF8 Write-RTLog -LogPath $logPath -Message "RESUMEN: restaurados=$($counters.restored) ya_ok=$($counters.already_ok) conflictos=$($counters.conflicts) revision_manual=$($counters.manual_review) copias_eliminadas=$($counters.cleaned)" Write-RTLog -LogPath $logPath -Message "Informe: $reportPath" Write-RTLog -LogPath $logPath -Message "================================================================" Write-Host '' Write-Host ('Modo: {0}' -f $(if ($Execute) { 'REVERSION REAL' } else { 'SIMULACION (nada modificado)' })) -ForegroundColor $(if ($Execute) { 'Cyan' } else { 'Magenta' }) Write-Host ("Restaurados: {0} Ya correctos: {1} Conflictos: {2} Revision manual: {3}" -f $counters.restored, $counters.already_ok, $counters.conflicts, $counters.manual_review) if ($unresolved.Count -gt 0) { Write-Host '' Write-Host 'REQUIEREN INTERVENCION MANUAL (no se ha borrado nada de estos):' -ForegroundColor Red $unresolved | ForEach-Object { Write-Host (" - {0} [{1}] {2}" -f $_.original, $_.status, $_.note) -ForegroundColor Red } } if (-not $Execute) { Write-Host 'Anade -Execute para aplicar la reversion.' -ForegroundColor Yellow }