← zurück zur Toolbox
Bestandsaufnahme.ps1 · Quelltext
92862 Bytes · SHA-256: baf7a1f18bb87cc8004fee37248af6031e4adba7c37ca6b931ee8d25ea8d9dee
#Requires -Version 3.0
# TOOLBOX-TILE: {"title": "Windows Migration Inventory", "desc": "Rein lesende Bestandsaufnahme eines Windows-PCs vor dem Umzug auf neue Hardware - 20 Berichte von Software bis WLAN-Schlüssel und Passwort-Tresor, komplett lokal, mit ehrlichem Laufprotokoll.", "icon": "🧳", "type": "download", "doc": "bestandsaufnahme.html", "os": ["windows"], "order": 27, "downloads": [{"file": "Bestandsaufnahme.ps1", "label": "Bestandsaufnahme.ps1"}, {"file": "Start_Bestandsaufnahme.cmd", "label": "Starter (.cmd)"}]}
<#
======================================================================
Bestandsaufnahme.ps1
Vollstaendige Zustandsaufnahme eines Windows-Rechners fuer die
Migration auf neue Hardware (Schwerpunkt Buchhaltungs-PC).
Ergebnis:
- Ein Ordner mit je einer Textdatei pro Bereich
- _ZUSAMMENFASSUNG.txt mit den wichtigsten Funden
- _LAUFPROTOKOLL.txt mit Status und Dauer jedes Abschnitts
- Alles zusaetzlich als ZIP daneben
Das Skript ist NUR LESEND. Es aendert nichts am System.
Fuer volle Ergebnisse als Administrator ausfuehren (der Starter
.cmd macht das automatisch).
ACHTUNG: Die Ausgabe enthaelt bewusst KLARTEXT-GEHEIMNISSE
(WLAN-Schluessel, Produktschluessel, Seriennummern). Der Ordner
und die ZIP sind entsprechend vertraulich zu behandeln.
Diese Datei ist als UTF-8 MIT BOM gespeichert und muss es bleiben:
Windows PowerShell 5.1 liest eine .ps1 ohne BOM als ANSI - jedes
Nicht-ASCII-Zeichen in einem Suchmuster waere dann still kaputt.
======================================================================
#>
param(
# Ablageort der Ergebnisse. Ohne Angabe: siehe Ermittlung unten.
[string]$Ausgabeordner,
# Ueberspringt die zeitintensiven Dateisuchen (Zertifikate, Tresore).
[switch]$OhneDateisuche,
# Laufwerke fuer die Dateisuche. Ohne Angabe: alle lokalen Festplatten.
# Wichtig, weil ELSTER-Zertifikat und Passwort-Tresor erfahrungsgemaess
# oft NICHT auf C: liegen, sondern auf einem Datenlaufwerk.
[string[]]$Laufwerke,
# Zeitbudget je Laufwerk. Wird es ueberschritten, bricht die Suche fuer
# dieses Laufwerk ab und sagt das in der Ausgabe ausdruecklich.
# Gemessen auf einem realen Rechner: ein Laufwerk ohne Treffer kann
# ueber 500 s brauchen. Ohne Deckel wartet der Bediener vor einem
# scheinbar eingefrorenen Fenster.
[int]$SucheSekundenProLaufwerk = 120
)
# Fassung dieses Skripts. Wird in Konsole, _LAUFPROTOKOLL.txt und
# _ZUSAMMENFASSUNG.txt ausgegeben, damit jeder Feldlauf einer Codefassung
# zuordenbar ist. Bei jeder inhaltlichen Aenderung mitziehen;
# build_paket.sh prueft den Gleichstand von Paket und Repo-Stand.
$SkriptFassung = '2026-08-07 (Kaspersky-Tresorsuche)'
$ErrorActionPreference = 'SilentlyContinue'
$ProgressPreference = 'SilentlyContinue'
# Grosszuegiger Fehlerpuffer: Die Dateisuche ueber alle Laufwerke erzeugt
# leicht mehrere hundert Zugriffsfehler. Bei der Standardgroesse 256 waere
# der Puffer voll, bevor die spaeteren Abschnitte ueberhaupt anfangen.
$MaximumErrorCount = 4096
# =====================================================================
# GRUNDGERUEST: Status, Ausgabe, Hilfsfunktionen
# =====================================================================
$stamp = Get-Date -Format 'yyyy-MM-dd_HHmm'
$pcName = $env:COMPUTERNAME
$startZeit = Get-Date
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
# --- Ausgabeort bestimmen -------------------------------------------
# Standard ist BEWUSST NICHT der Desktop: Desktop ist auf vielen Rechnern
# per Known-Folder-Redirection in einen Cloud-Sync-Ordner umgeleitet. Die
# Ergebnisse enthalten Klartext-Geheimnisse und sollen nicht ungefragt in
# eine Cloud hochgeladen werden.
$ausgabeHinweis = ''
if (-not $Ausgabeordner) {
$bevorzugt = Join-Path $env:SystemDrive 'Bestandsaufnahme'
try {
if (-not (Test-Path $bevorzugt)) {
New-Item -ItemType Directory -Path $bevorzugt -Force -ErrorAction Stop | Out-Null
}
$Ausgabeordner = $bevorzugt
} catch {
$Ausgabeordner = [Environment]::GetFolderPath('Desktop')
$ausgabeHinweis = "Ausweich-Ablage auf dem Desktop, weil '$bevorzugt' nicht anlegbar war."
}
}
$base = Join-Path $Ausgabeordner "Bestandsaufnahme_${pcName}_${stamp}"
New-Item -ItemType Directory -Path $base -Force | Out-Null
if (-not (Test-Path $base)) {
Write-Host "FEHLER: Ausgabeordner '$base' konnte nicht angelegt werden. Abbruch." -ForegroundColor Red
exit 1
}
# --- Schreibprobe -----------------------------------------------------
# Ein anlegbarer Ordner ist noch kein beschreibbarer Ordner: Der Windows-
# Ransomware-Schutz ("Ueberwachter Ordnerzugriff") und manche EDR-Politiken
# lassen das Anlegen von Ordnern zu, blockieren aber das Schreiben von
# Dateien. Ohne diese Probe liefe der komplette Scan durch, schriebe NULL
# Dateien und meldete am Ende trotzdem "fertig, nur die ZIP fehlt"
# (auf CREA-Tier zweimal reproduziert: Out-File wirft dann einen Fehler,
# den SilentlyContinue nicht abfaengt, und jeder Abschnitt bricht still ab).
try {
$probe = Join-Path $base '_SCHREIBPROBE.tmp'
'Schreibprobe' | Out-File -FilePath $probe -Encoding UTF8 -ErrorAction Stop
Remove-Item $probe -Force -ErrorAction SilentlyContinue
} catch {
Write-Host "FEHLER: Im Ausgabeordner '$base' kann KEINE Datei geschrieben werden." -ForegroundColor Red
Write-Host " ($($_.Exception.Message))" -ForegroundColor Red
Write-Host " Haeufige Ursache: Windows-Ransomware-Schutz ('Ueberwachter Ordnerzugriff')" -ForegroundColor Red
Write-Host " oder ein Virenschutz. Bitte mit -Ausgabeordner einen anderen Pfad waehlen" -ForegroundColor Red
Write-Host " (z.B. ein USB-Laufwerk) oder den Ordner freigeben. ABBRUCH - es wurden" -ForegroundColor Red
Write-Host " KEINE Daten erhoben." -ForegroundColor Red
exit 1
}
# --- Liegt die Ablage in einem Cloud-Sync-Ordner? --------------------
$syncWurzeln = @()
foreach ($v in 'OneDrive','OneDriveConsumer','OneDriveCommercial') {
$w = [Environment]::GetEnvironmentVariable($v)
if ($w) { $syncWurzeln += $w }
}
foreach ($p in "$env:USERPROFILE\Dropbox", "$env:USERPROFILE\Nextcloud") {
if (Test-Path $p) { $syncWurzeln += $p }
}
$syncWurzeln = $syncWurzeln | Sort-Object -Unique
$ablageImSync = $null
foreach ($w in $syncWurzeln) {
if ($base.ToLower().StartsWith($w.ToLower())) { $ablageImSync = $w }
}
# --- Status je Abschnitt --------------------------------------------
$protokoll = New-Object System.Collections.ArrayList
$highlights = New-Object System.Collections.ArrayList
$warnungen = New-Object System.Collections.ArrayList
function Note($msg) { [void]$highlights.Add($msg) }
function Warnung($msg) {
# Doppelte Warnungen wuerden die Zusammenfassung aufblaehen: derselbe
# Sachverhalt wird von mehreren Abschnitten gemeldet.
if ($warnungen -notcontains $msg) { [void]$warnungen.Add($msg) }
}
# Einheitliche Tabellenformatierung. Der Breitenparameter MUSS hier
# stehen: Out-String rendert die Tabelle, danach ist nichts mehr zu
# retten (Standardbreite waere 120 Zeichen, Spalten fielen weg).
function Fmt($obj) {
if ($null -eq $obj) { return '' }
($obj | Format-Table -AutoSize | Out-String -Width 8000)
}
function FmtListe($obj) {
if ($null -eq $obj) { return '' }
($obj | Format-List | Out-String -Width 8000)
}
function Datum($d) {
if ($null -eq $d) { return '(unbekannt)' }
try { return ([datetime]$d).ToString('dd.MM.yyyy HH:mm:ss') } catch { return [string]$d }
}
# Native Kommandos geben je nach Werkzeug unterschiedliche Codepages aus:
# netsh liefert UTF-8, ipconfig/net/cmdkey liefern die OEM-Codepage.
# Ein globaler Umschalter repariert deshalb das eine und zerstoert das
# andere - die Kodierung wird hier pro Aufruf gesetzt.
function Invoke-Native {
param([scriptblock]$Aktion, [switch]$Utf8)
$alt = [Console]::OutputEncoding
try {
if ($Utf8) { [Console]::OutputEncoding = [Text.Encoding]::UTF8 }
else { [Console]::OutputEncoding = [Text.Encoding]::GetEncoding([int](Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Nls\CodePage' -Name OEMCP).OEMCP) }
& $Aktion
} catch {
& $Aktion
} finally {
[Console]::OutputEncoding = $alt
}
}
# --- Dateisuche ------------------------------------------------------
# Sucht ausschliesslich ueber Verzeichniseintraege (Name, Groesse,
# Attribute). Dateiinhalte werden NIE geoeffnet. Das ist entscheidend,
# weil auf Cloud-Laufwerken (OneDrive, Nextcloud) die meisten Dateien nur
# Platzhalter sind: Sie liegen nicht lokal, sondern werden beim Oeffnen
# aus dem Netz nachgeladen. Reines Auflisten loest das nicht aus
# (auf einem realen OneDrive-Bestand nachgemessen: 0 von 20 Platzhaltern
# wurden durch die Suche heruntergeladen).
#
# Platzhalter werden erkannt und in der Ausgabe gekennzeichnet, damit
# niemand eine Datei fuer gesichert haelt, die lokal gar nicht existiert.
$ATTR_OFFLINE = 0x1000 # FILE_ATTRIBUTE_OFFLINE
$ATTR_RECALL = 0x400000 # FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS
# Der Verzeichnisbaum wird selbst durchlaufen statt per
# Get-ChildItem -Recurse. Grund: Bei -Recurse laesst sich eine laufende
# Suche nicht abbrechen. Auf einem Laufwerk OHNE Treffer kommt nie ein
# Element durch die Pipeline, an dem sich die Zeit pruefen liesse - die
# Suche laeuft dann ueber Terabytes ungebremst durch (real passiert:
# 24-TB-Laufwerk, kein Treffer, kein Abbruch). Hier wird nach jedem
# Verzeichnis geprueft.
function Find-Dateien {
param(
[string[]]$Wurzeln,
[string[]]$Muster,
[int]$BudgetSekunden = 180,
[int]$MaxTiefe = 40
)
$treffer = New-Object System.Collections.ArrayList
$abgesucht = New-Object System.Collections.ArrayList
$gekappt = New-Object System.Collections.ArrayList
# Nicht lesbare Ordner/Dateien werden gezaehlt statt still uebersprungen:
# Ein Ordner, den die Suche nicht betreten durfte, sieht sonst genauso
# aus wie ein Ordner ohne Treffer - und ein dort liegender Tresor oder
# ein Zertifikat bliebe unbemerkt.
$unlesbar = 0
# Muster ('*.pfx') zu Endungen ('.pfx') fuer den Vergleich im Speicher.
$endungen = @($Muster | ForEach-Object { ($_ -replace '^\*', '').ToLower() })
# Verzeichnisse, die nur Zeit kosten und nie ein Migrationsziel enthalten.
$ueberspringen = '\\(\$Recycle\.Bin|System Volume Information|\$WinREAgent|OneDriveTemp)$',
'\\Windows\\(WinSxS|servicing|Installer|SoftwareDistribution|Temp)$',
'\\AppData\\Local\\(Packages|Temp|Microsoft\\Windows\\INetCache)$',
'\\node_modules$'
foreach ($w in $Wurzeln) {
if (-not $w -or -not (Test-Path -LiteralPath $w)) { continue }
$uhr = [Diagnostics.Stopwatch]::StartNew()
$abbruch = $false
$stapel = New-Object System.Collections.Stack
$stapel.Push(@($w, 0))
while ($stapel.Count -gt 0) {
if ($uhr.Elapsed.TotalSeconds -gt $BudgetSekunden) { $abbruch = $true; break }
$eintrag = $stapel.Pop()
$ordner = $eintrag[0]
$tiefe = $eintrag[1]
# Ein Durchlauf je Verzeichnis, dann im Speicher filtern. Ein
# eigener EnumerateFiles-Aufruf je Endung wuerde dasselbe
# Verzeichnis elfmal vom Datentraeger lesen und das Zeitbudget
# entsprechend schneller aufbrauchen.
try {
foreach ($pfad in [System.IO.Directory]::EnumerateFiles($ordner)) {
$endung = [System.IO.Path]::GetExtension($pfad)
if (-not $endung) { continue }
if ($endungen -notcontains $endung.ToLower()) { continue }
try {
$fi = New-Object System.IO.FileInfo $pfad
$attr = [int]$fi.Attributes
$nurOnline = (($attr -band $ATTR_OFFLINE) -ne 0) -or (($attr -band $ATTR_RECALL) -ne 0)
[void]$treffer.Add([pscustomobject]@{
Datei = $pfad
KB = [math]::Round($fi.Length/1KB, 1)
Geaendert = Datum $fi.LastWriteTime
Verfuegbar = $(if ($nurOnline) { 'NUR ONLINE (nicht lokal!)' } else { 'lokal' })
})
} catch { $unlesbar++ }
}
} catch { $unlesbar++ }
if ($tiefe -lt $MaxTiefe) {
try {
foreach ($unter in [System.IO.Directory]::EnumerateDirectories($ordner)) {
$raus = $false
foreach ($u in $ueberspringen) { if ($unter -match $u) { $raus = $true; break } }
if (-not $raus) { $stapel.Push(@($unter, $tiefe + 1)) }
}
} catch { $unlesbar++ }
}
}
$uhr.Stop()
[void]$abgesucht.Add(("{0} ({1:N0} s)" -f $w, $uhr.Elapsed.TotalSeconds))
if ($abbruch) { [void]$gekappt.Add($w) }
# Fortschritt sichtbar machen: die Suche ist der mit Abstand
# laengste Abschnitt, ohne Rueckmeldung wirkt das Fenster tot.
Write-Host (" {0} durchsucht in {1:N0} s{2} - Treffer bisher: {3}" -f `
$w, $uhr.Elapsed.TotalSeconds, $(if ($abbruch) { ' (ABGEBROCHEN, Zeitbudget)' } else { '' }), $treffer.Count)
}
return [pscustomobject]@{
Treffer = @($treffer | Sort-Object Datei -Unique)
Abgesucht = @($abgesucht)
Gekappt = @($gekappt)
Unlesbar = $unlesbar
}
}
# Formatiert das Ergebnis einer Suche samt ehrlicher Abdeckungsangabe.
function SuchBericht($res, $was) {
$t = "Abgesucht: " + (($res.Abgesucht) -join ' | ') + "`r`n"
if ($res.Unlesbar -gt 0) {
$t += "HINWEIS: $($res.Unlesbar) Ordner/Dateien waren nicht lesbar (Zugriff verweigert o.ae.).`r`n"
$t += "Dort koennten weitere $was liegen - die Suche kann das nicht ausschliessen.`r`n"
}
if ($res.Gekappt.Count -gt 0) {
$t += "ACHTUNG: Auf diesen Laufwerken wurde die Suche nach $SucheSekundenProLaufwerk s ABGEBROCHEN,`r`n"
$t += "sie ist dort UNVOLLSTAENDIG: " + (($res.Gekappt) -join ', ') + "`r`n"
$t += "Diese Laufwerke bitte von Hand nach $was durchsehen.`r`n"
}
$t += "`r`n" + (Fmt $res.Treffer)
if (@($res.Treffer | Where-Object { $_.Verfuegbar -ne 'lokal' }).Count -gt 0) {
$t += "`r`nHINWEIS: Als 'NUR ONLINE' markierte Dateien liegen nicht auf der Platte,`r`n"
$t += "sondern im Cloud-Speicher. Eine reine Dateikopie erfasst sie NICHT.`r`n"
$t += "Vor der Migration im Explorer 'Immer auf diesem Geraet behalten' waehlen.`r`n"
}
$t
}
$gesamtAbschnitte = 20
$lfd = 0
# Fuehrt einen Abschnitt aus, faengt Fehler, misst Dauer und macht den
# Unterschied zwischen "geprueft, nichts gefunden" und "Abruf
# fehlgeschlagen" in der Datei selbst sichtbar.
function Invoke-Abschnitt {
param(
[string]$Titel,
[string]$Datei,
[string]$Abdeckung,
[scriptblock]$Aktion,
# Fuer bekannte Langlaeufer: wird direkt unter dem Titel angezeigt,
# damit der Bediener das Fenster nicht fuer eingefroren haelt und
# den Lauf abbricht (real 65,8 s beim Lizenz-Abschnitt gemessen).
[string]$Dauerhinweis
)
$script:lfd++
Write-Host ("[{0,2}/{1}] {2}" -f $script:lfd, $gesamtAbschnitte, $Titel)
if ($Dauerhinweis) {
Write-Host (" {0}" -f $Dauerhinweis) -ForegroundColor DarkGray
}
$uhr = [Diagnostics.Stopwatch]::StartNew()
$inhalt = $null
$zustand = 'OK'
$meldung = ''
# $Error wird geleert statt vorher/nachher zu zaehlen. Der Puffer ist ein
# Ringspeicher fester Groesse: Sobald er voll ist, waechst $Error.Count
# nicht mehr, und eine Differenzmessung meldet ab da fuer JEDEN weiteren
# Abschnitt null Fehler - die Fehlererkennung waere blind, ohne dass es
# auffiele. Die laufwerksweite Dateisuche allein erzeugt genug
# Zugriffsfehler, um den Puffer zu fuellen.
$Error.Clear()
try {
$inhalt = & $Aktion
} catch {
$zustand = 'FEHLER'
$meldung = $_.Exception.Message
}
$uhr.Stop()
$neueFehler = @($Error | ForEach-Object { $_.ToString() } | Select-Object -Unique)
$Error.Clear()
$text = ''
if ($null -ne $inhalt) { $text = ($inhalt | Out-String -Width 8000) }
if ($zustand -ne 'FEHLER' -and $text.Trim().Length -eq 0) {
# Leer UND fehlerbehaftet ist kein "geprueft, nichts vorhanden",
# sondern ein gescheiterter Abruf. Ohne diese Unterscheidung stand
# auf IMPORT1-PC ein Abschnitt mit Abruffehler als harmlos-LEER in
# der Zusammenfassung, und der Lauf endete mit Exitcode 0.
if ($neueFehler.Count -gt 0) {
$zustand = 'FEHLER'
$meldung = "kein Ergebnis, und $($neueFehler.Count) Fehler waehrend der Abfrage (unten protokolliert)"
} else {
$zustand = 'LEER'
}
}
$kopf = "===== $Titel =====`r`n"
$kopf += "Stand : $(Get-Date -Format 'dd.MM.yyyy HH:mm:ss')`r`n"
$kopf += "Status : $zustand"
if ($zustand -eq 'LEER') { $kopf += " (Abfrage lief durch, hat aber nichts gefunden)" }
if ($zustand -eq 'FEHLER') { $kopf += " -> $meldung" }
$kopf += "`r`n"
if ($Abdeckung) { $kopf += "Geprueft : $Abdeckung`r`n" }
$kopf += "Admin : $isAdmin`r`n"
if ($neueFehler.Count -gt 0) {
$kopf += "Hinweise : $($neueFehler.Count) Fehler waehrend der Abfrage (unten protokolliert)`r`n"
}
$kopf += ("-" * 70) + "`r`n`r`n"
$fuss = ''
if ($neueFehler.Count -gt 0) {
$fuss = "`r`n`r`n" + ("-" * 70) + "`r`n--- Fehler waehrend dieses Abschnitts ---`r`n"
$fuss += ($neueFehler -join "`r`n")
$fuss += "`r`nDiese Meldungen bedeuten: Teile dieses Abschnitts konnten NICHT`r`n"
$fuss += "erhoben werden. Der Abschnitt ist moeglicherweise unvollstaendig.`r`n"
}
# Das Schreiben selbst absichern: Ein blockierter Schreibzugriff wirft
# hier einen Fehler, den SilentlyContinue NICHT abfaengt - ohne das
# try/catch braeche der Rest dieser Funktion ab, der Abschnitt fiele
# aus dem Protokoll und der Verlust bliebe unsichtbar (auf CREA-Tier
# reproduziert). Die Schreibprobe am Start faengt den Normalfall ab;
# dieses Netz greift, wenn die Blockade erst waehrend des Laufs beginnt.
try {
($kopf + $text + $fuss) | Out-File -FilePath (Join-Path $base $Datei) -Encoding UTF8 -Width 8000 -ErrorAction Stop
} catch {
$zustand = 'FEHLER'
# Anhaengen statt ueberschreiben: War der Abschnitt schon FEHLER,
# bliebe sonst nur der Schreibfehler uebrig und die eigentliche
# Fehlerdiagnose ginge verloren.
$schreibMeldung = "Ausgabedatei nicht schreibbar: $($_.Exception.Message)"
if ($meldung) { $meldung = "$meldung / zusaetzlich: $schreibMeldung" }
else { $meldung = $schreibMeldung }
Write-Host (" FEHLER: {0} konnte nicht geschrieben werden!" -f $Datei) -ForegroundColor Red
}
[void]$protokoll.Add([pscustomobject]@{
Nr = $script:lfd
Abschnitt = $Titel
Datei = $Datei
Status = $zustand
Sekunden = [math]::Round($uhr.Elapsed.TotalSeconds, 1)
Fehler = $neueFehler.Count
Meldung = $meldung
})
if ($zustand -eq 'FEHLER') {
Warnung "Abschnitt '$Titel' ist fehlgeschlagen ($meldung) - $Datei ist unvollstaendig."
}
# Bewusst KEIN return $inhalt: Kein Aufrufer nimmt den Wert ab, er
# liefe komplett ueber die Konsole - inklusive der Klartext-Schluessel
# (real gemessen: 1840 stdout-Zeilen mit WLAN-Schluesseln im Log).
}
Write-Host ""
Write-Host "=== Bestandsaufnahme laeuft, bitte warten... ===" -ForegroundColor Cyan
Write-Host "Fassung : $SkriptFassung" -ForegroundColor Cyan
Write-Host "Rechner : $pcName Admin: $isAdmin" -ForegroundColor Cyan
Write-Host "Ablage : $base" -ForegroundColor Cyan
Write-Host ""
if (-not $isAdmin) {
Warnung "OHNE ADMIN-RECHTE gestartet: WLAN-Schluessel, fremde Benutzerprofile und Teile der Lizenzdaten fehlen."
}
# --- Plattform-Check: gebaut fuer Windows 10/11 mit PowerShell 5.1 ----
# Auf aelteren Systemen laeuft das Skript bewusst weiter (kein Abbruch),
# aber mehrere Abschnitte nutzen Cmdlets, die es dort nicht gibt
# (Get-LocalUser ab PS 5.1; Get-ScheduledTask, Get-SmbShare, Get-Printer,
# Get-VpnConnection, Get-OdbcDsn ab Windows 8/Server 2012). Diese
# Abschnitte erscheinen dann im _LAUFPROTOKOLL.txt als FEHLER/LEER.
# Die Warnung hier macht das VOR dem Lauf sichtbar, statt den Bediener
# hinterher raetseln zu lassen. Gemessen (M4, CREA-Tier): ein fehlendes
# Cmdlet bricht den Abschnitt NICHT ab und vernichtet keine Teildaten -
# es fehlt nur der jeweilige Block, mit Fehlereintrag in der Datei.
if ($PSVersionTable.PSVersion.Major -lt 5 -or
[Environment]::OSVersion.Version -lt [Version]'6.2') {
$plattformWarnung = ("ALTES SYSTEM: PowerShell $($PSVersionTable.PSVersion) auf Windows " +
"$([Environment]::OSVersion.Version). Dieses Skript ist fuer Windows 10/11 mit " +
"PowerShell 5.1 gebaut. Folgende Bereiche werden hier voraussichtlich fehlschlagen " +
"und muessen VON HAND erhoben werden: lokale Benutzerkonten, geplante Aufgaben, " +
"Freigaben, Drucker, VPN, ODBC-Datenquellen. Das Laufprotokoll weist die Ausfaelle aus.")
Warnung $plattformWarnung
Write-Host " ! $plattformWarnung" -ForegroundColor Yellow
}
# --- Laeuft der Scan unter einem anderen Konto als der angemeldete? ---
# Bei UAC-Elevation ueber ein FREMDES Admin-Konto liest alles
# Benutzerbezogene (HKCU, USERPROFILE, APPDATA) das falsche Profil.
$angemeldeterUser = (Get-CimInstance Win32_ComputerSystem).UserName
$prozessUser = [Security.Principal.WindowsIdentity]::GetCurrent().Name
if ($angemeldeterUser -and $prozessUser -and
($angemeldeterUser.Trim().ToLower() -ne $prozessUser.Trim().ToLower())) {
Warnung ("KONTEN-MISMATCH: Das Skript laeuft als '$prozessUser', angemeldet ist aber " +
"'$angemeldeterUser'. Alle benutzerbezogenen Angaben (Autostart, Netzlaufwerke, " +
"E-Mail, Browser, Datenordner) beziehen sich auf '$prozessUser'. Fuer vollstaendige " +
"Ergebnisse als '$angemeldeterUser' anmelden und erneut ausfuehren.")
}
if ($ablageImSync) {
Warnung ("Die Ergebnisse liegen unter '$ablageImSync' und werden damit in einen " +
"Cloud-Speicher synchronisiert. Sie enthalten Klartext-Passwoerter. Ggf. mit " +
"-Ausgabeordner einen lokalen Pfad waehlen.")
}
if ($ausgabeHinweis) { Warnung $ausgabeHinweis }
# Muss vor dem try stehen, damit das finally die Liste in jedem Fall sieht.
$benutzerProfile = @()
try {
# --- Benutzerprofile samt Registry-Hive bereitstellen ----------------
# Nicht angemeldete Profile werden temporaer geladen. Das Laden steht
# INNERHALB des try: Schlaegt irgendetwas zwischen dem ersten reg load und
# dem Ende schief, entlaedt das finally trotzdem. Stuende die Schleife
# davor, bliebe bei einem Fehler mittendrin ein fremder Registry-Zweig
# geladen - eine Aenderung am System, die dieses Skript nicht machen darf.
foreach ($p in (Get-CimInstance Win32_UserProfile | Where-Object { $_.LocalPath -and -not $_.Special })) {
$sid = $p.SID
$geladen = Test-Path "Registry::HKEY_USERS\$sid"
$selbstGeladen = $false
if (-not $geladen -and $isAdmin) {
$dat = Join-Path $p.LocalPath 'NTUSER.DAT'
if (Test-Path $dat) {
reg load "HKU\$sid" "$dat" *>$null
if ($LASTEXITCODE -eq 0 -and (Test-Path "Registry::HKEY_USERS\$sid")) {
$geladen = $true; $selbstGeladen = $true
}
}
}
$benutzerProfile += [pscustomobject]@{
Name = Split-Path $p.LocalPath -Leaf
SID = $sid
Pfad = $p.LocalPath
Geladen = $geladen
SelbstGeladen = $selbstGeladen
RegRoot = "Registry::HKEY_USERS\$sid"
}
}
if ($benutzerProfile.Count -eq 0) {
# Kein Profil gefunden heisst hier nicht "keine Benutzer", sondern
# "Ermittlung fehlgeschlagen" - sonst liest sich das Ergebnis als
# Entwarnung, obwohl acht Abschnitte ins Leere laufen.
Warnung ("Die Benutzerprofile konnten NICHT ermittelt werden (Win32_UserProfile lieferte nichts). " +
"Autostart, E-Mail, Browser, Netzlaufwerke und Datenordner sind deshalb unvollstaendig - " +
"nicht, weil dort nichts vorhanden waere.")
}
$nichtLesbar = @($benutzerProfile | Where-Object { -not $_.Geladen })
if ($nichtLesbar.Count -gt 0) {
Warnung ("Benutzerprofile ohne lesbare Registry: " + (($nichtLesbar | ForEach-Object { $_.Name }) -join ', ') +
" - deren Autostart/E-Mail/Netzlaufwerke fehlen.")
}
# =====================================================================
# 1) SYSTEM / HARDWARE / SERIENNUMMERN
# =====================================================================
Invoke-Abschnitt -Titel 'System, Hardware & Seriennummern' -Datei '01_System.txt' `
-Abdeckung 'CIM: OperatingSystem, ComputerSystem, BIOS, ComputerSystemProduct, BaseBoard, Processor, DiskDrive' -Aktion {
$os = Get-CimInstance Win32_OperatingSystem
$cs = Get-CimInstance Win32_ComputerSystem
$bios = Get-CimInstance Win32_BIOS
$prod = Get-CimInstance Win32_ComputerSystemProduct
$bb = Get-CimInstance Win32_BaseBoard
$cpu = @(Get-CimInstance Win32_Processor)
$cpuNamen = @($cpu | ForEach-Object { $_.Name } | Select-Object -Unique)
$cpuText = ($cpuNamen -join ' + ')
if ($cpu.Count -gt 1) { $cpuText += " (physische Sockel: $($cpu.Count))" }
# Viele Mainboards liefern statt der Seriennummer einen Platzhalter.
$platzhalter = @('System Serial Number','Default string','To be filled by O.E.M.','None','')
function SerienNr($wert, $quelle) {
if ($null -eq $wert -or $platzhalter -contains $wert.Trim()) {
return "$quelle : (kein Wert hinterlegt - '$wert')"
}
return "$quelle : $($wert.Trim())"
}
$t = @"
Rechnername : $($cs.Name)
Hersteller/Modell : $($cs.Manufacturer) $($cs.Model)
Windows : $($os.Caption) ($($os.Version), Build $($os.BuildNumber))
Architektur : $($os.OSArchitecture)
Windows install. : $(Datum $os.InstallDate)
Letzter Neustart : $(Datum $os.LastBootUpTime)
CPU : $cpuText
Arbeitsspeicher : $('{0:N1}' -f ($cs.TotalPhysicalMemory/1GB)) GB
Angemeldeter User : $($cs.UserName)
Scan laeuft als : $prozessUser
Domaene/Workgroup : $($cs.Domain) (PartOfDomain=$($cs.PartOfDomain))
--- Seriennummern (fuer Support, Garantie, Lizenzuebertragung) ---
$(SerienNr $bios.SerialNumber 'BIOS/Geraet ')
$(SerienNr $prod.IdentifyingNumber 'System-Produkt ')
$(SerienNr $bb.SerialNumber 'Mainboard ')
Mainboard-Modell : $($bb.Manufacturer) $($bb.Product)
System-UUID : $($prod.UUID)
--- Datentraeger (Seriennummern) ---
"@
$t += (Fmt (Get-CimInstance Win32_DiskDrive | Select-Object `
@{n='Modell';e={$_.Model}},
@{n='GB';e={[math]::Round($_.Size/1GB,0)}},
@{n='Seriennummer';e={ if ($_.SerialNumber) { $_.SerialNumber.Trim() } else { '' } }},
@{n='Schnittstelle';e={$_.InterfaceType}}))
if ($cs.PartOfDomain) { Note "Rechner ist Teil einer Domaene: $($cs.Domain) -> Server im Netz vorhanden." }
$t
}
# =====================================================================
# 2) LAUFWERKE
# =====================================================================
Invoke-Abschnitt -Titel 'Laufwerke & Speicherplatz' -Datei '02_Laufwerke.txt' `
-Abdeckung 'Alle lokalen Festplatten (Win32_LogicalDisk DriveType=3)' -Aktion {
$disks = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object {
"{0} {1,9:N1} GB gesamt {2,9:N1} GB frei {3,9:N1} GB belegt [{4}]" -f `
$_.DeviceID, ($_.Size/1GB), ($_.FreeSpace/1GB), (($_.Size - $_.FreeSpace)/1GB), $_.VolumeName
}
($disks -join "`r`n") + "`r`n`r`nHinweis: Ausser C: koennen auch Datenlaufwerke Buchhaltungs-,`r`n" +
"Zertifikats- oder Tresordateien enthalten. Siehe 14_Zertifikate.txt und 15_Passworttresore.txt."
}
# =====================================================================
# 3) INSTALLIERTE SOFTWARE (maschinenweit + alle Benutzerprofile)
# =====================================================================
$sw = @()
Invoke-Abschnitt -Titel 'Installierte Software' -Datei '03_Software.txt' `
-Abdeckung 'HKLM Uninstall (64/32 Bit) + Uninstall-Zweig jedes lesbaren Benutzerprofils' -Aktion {
$script:sw = @()
$script:sw += Get-ItemProperty `
HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*, `
HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\* |
Where-Object DisplayName |
Select-Object @{n='Konto';e={'[Maschine]'}}, DisplayName, DisplayVersion, Publisher, InstallDate
foreach ($p in ($benutzerProfile | Where-Object { $_.Geladen })) {
$script:sw += Get-ItemProperty `
"$($p.RegRoot)\Software\Microsoft\Windows\CurrentVersion\Uninstall\*", `
"$($p.RegRoot)\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" |
Where-Object DisplayName |
Select-Object @{n='Konto';e={$p.Name}}, DisplayName, DisplayVersion, Publisher, InstallDate
}
$script:sw = $script:sw | Sort-Object Konto, DisplayName
# CSV mit Semikolon: deutsches Excel erwartet das Listentrennzeichen,
# mit Komma landet alles in einer Spalte.
$script:sw | Export-Csv (Join-Path $base '03_Software.csv') -NoTypeInformation -Encoding UTF8 -Delimiter ';'
"Gefundene Eintraege: $($script:sw.Count) (Profile ausgewertet: " +
(($benutzerProfile | Where-Object { $_.Geladen } | ForEach-Object { $_.Name }) -join ', ') + ")`r`n" +
"Maschinenlesbar in 03_Software.csv (Semikolon-getrennt, fuer Excel).`r`n`r`n" + (Fmt $script:sw)
}
# --- Wichtige Programme erkennen -------------------------------------
# Wortgrenzen sind Pflicht: ohne sie matcht z.B. 'Sage' in
# 'NVIDIA MessageBus' und meldet eine Buchhaltungssoftware, die es
# gar nicht gibt.
function Detect($label, [string[]]$patterns) {
$namen = @($sw | ForEach-Object { $_.DisplayName } | Sort-Object -Unique)
$treffer = $namen | Where-Object {
$d = $_
($patterns | Where-Object { $d -match "\b$_\b" })
}
if ($treffer) {
Note ("{0}: {1}" -f $label, (($treffer | Select-Object -Unique) -join ', '))
return $true
}
return $false
}
$gefunden = @{}
$gefunden['Buchhaltung'] = Detect 'Buchhaltung' @('DATEV','Lexware','Sage','Collmex','WISO','Buhl','Addison','Agenda','Stotax','SBS Rewe','Taxpool','Simba','Unternehmen online')
$gefunden['Banking'] = Detect 'Online-Banking' @('SFirm','StarMoney','ProfiCash','VR-NetWorld','Windata','moneyplex','Hibiscus','Mein Geld','Banking')
$gefunden['Mail'] = Detect 'E-Mail-Programm' @('Outlook','Thunderbird','eM Client','Mailbird','The Bat')
$gefunden['Elster'] = Detect 'Steuer/ELSTER' @('ELSTER','ElsterFormular','ElsterSecure')
$gefunden['Office'] = Detect 'Office-Paket' @('Microsoft 365','Office','LibreOffice','OpenOffice')
$gefunden['Branche'] = Detect 'Branchensoftware' @('JTL','orgaMAX','faktura','Warenwirtschaft','Sage 50','Lexware warenwirtschaft','WISO Mein Buero','WISO Mein Büro')
$gefunden['Fernwartung'] = Detect 'Fernwartung' @('AnyDesk','TeamViewer','RustDesk')
$gefunden['Tresor'] = Detect 'Passwort-Manager' @('KeePass','KeePassXC','Bitwarden','1Password','LastPass','Dashlane','Enpass','Kaspersky Password Manager')
$gefunden['CloudSync'] = Detect 'Cloud-Speicher' @('OneDrive','Dropbox','Nextcloud','ownCloud','Google Drive','MEGAsync')
$gefunden['Datenbank'] = Detect 'Datenbank' @('SQL Server','Firebird','MySQL','MariaDB','PostgreSQL','Sybase')
# =====================================================================
# 4) AUTOSTART (alle Quellen, alle Profile)
# =====================================================================
Invoke-Abschnitt -Titel 'Autostart-Programme' -Datei '04_Autostart.txt' `
-Abdeckung 'Win32_StartupCommand (Startup-Ordner + Run) UND Run/RunOnce in HKLM (64/32 Bit) und in jedem lesbaren Benutzerprofil' -Aktion {
$zeilen = @()
# Win32_StartupCommand deckt die Startup-ORDNER ab, die in der
# Registry gar nicht auftauchen - aber nicht den Wow6432Node-Zweig.
# Beides zusammen ergibt das vollstaendige Bild.
$zeilen += "--- Win32_StartupCommand (inkl. Autostart-Ordner) ---"
$zeilen += (Fmt (Get-CimInstance Win32_StartupCommand |
Select-Object Name, User, Location, Command)).TrimEnd()
$zeilen += ""
$zeilen += "--- Registry-Zweige (inkl. 32-Bit und RunOnce) ---"
$regQuellen = @()
foreach ($z in 'Run','RunOnce') {
$regQuellen += "HKLM:\Software\Microsoft\Windows\CurrentVersion\$z"
$regQuellen += "HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\$z"
foreach ($p in ($benutzerProfile | Where-Object { $_.Geladen })) {
$regQuellen += "$($p.RegRoot)\Software\Microsoft\Windows\CurrentVersion\$z"
$regQuellen += "$($p.RegRoot)\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\$z"
}
}
$meta = @('PSPath','PSParentPath','PSChildName','PSDrive','PSProvider')
foreach ($k in $regQuellen) {
if (-not (Test-Path $k)) { continue }
$werte = Get-ItemProperty $k
foreach ($prop in $werte.PSObject.Properties) {
if ($meta -contains $prop.Name) { continue }
$name = $prop.Name
if ($name -eq '(default)') { $name = '(Standardwert)' }
$zeilen += "[$k]`r`n $name = $($prop.Value)"
}
}
# Dienste starten an der Registry und den Autostart-Ordnern vorbei.
# Ein Lizenzwaechter oder VPN-Hintergrunddienst taucht sonst nirgends auf.
$zeilen += ""
$zeilen += "--- Automatisch startende Dienste (ohne Windows-eigene) ---"
$dienste = Get-CimInstance Win32_Service |
Where-Object { $_.StartMode -eq 'Auto' -and $_.PathName -and $_.PathName -notmatch '\\Windows\\' } |
Select-Object Name, DisplayName, State, StartName, PathName |
Sort-Object DisplayName
$zeilen += (Fmt $dienste).TrimEnd()
$zeilen -join "`r`n"
}
# =====================================================================
# 5) GEPLANTE AUFGABEN (ohne Microsoft-Standard)
# =====================================================================
Invoke-Abschnitt -Titel 'Geplante Aufgaben' -Datei '05_GeplanteAufgaben.txt' `
-Abdeckung 'Alle Aufgaben ausser Pfad \Microsoft\*' -Aktion {
Fmt (Get-ScheduledTask | Where-Object { $_.TaskPath -notlike '\Microsoft\*' } |
Select-Object TaskName, TaskPath, State, @{n='Autor';e={$_.Author}})
}
# =====================================================================
# 6) NETZWERK-KONFIGURATION
# =====================================================================
Invoke-Abschnitt -Titel 'Netzwerk-Konfiguration' -Datei '06_Netzwerk_ipconfig.txt' `
-Abdeckung 'ipconfig /all (alle Adapter)' -Aktion {
(Invoke-Native { ipconfig /all } | Out-String -Width 8000)
}
# =====================================================================
# 7) NETZLAUFWERKE / FREIGABEN
# =====================================================================
Invoke-Abschnitt -Titel 'Netzlaufwerke & Freigaben' -Datei '07_Netzlaufwerke.txt' `
-Abdeckung 'net use + HKCU:\Network jedes lesbaren Benutzerprofils' -Aktion {
$t = "--- Aktuelle Verbindungen (net use) ---`r`n"
$t += (Invoke-Native { net use } | Out-String -Width 8000)
$t += "`r`n--- Dauerhaft verbunden (Registry, je Benutzer) ---`r`n"
$alle = @()
foreach ($p in ($benutzerProfile | Where-Object { $_.Geladen })) {
$alle += Get-ItemProperty "$($p.RegRoot)\Network\*" |
Select-Object @{n='Konto';e={$p.Name}},
@{n='Laufwerk';e={$_.PSChildName}},
@{n='Ziel';e={$_.RemotePath}},
@{n='Benutzer';e={$_.UserName}}
}
$t += (Fmt $alle)
foreach ($m in $alle) { Note "Netzlaufwerk $($m.Laufwerk): -> $($m.Ziel) (Konto $($m.Konto))" }
$t
}
Invoke-Abschnitt -Titel 'Eigene Freigaben dieses Rechners' -Datei '08_Freigaben.txt' `
-Abdeckung 'Alle SMB-Freigaben inkl. administrativer Standardfreigaben' -Aktion {
$shares = Get-SmbShare | Select-Object Name, Path, Description, @{n='Typ';e={ if ($_.Name -match '\$$') { 'administrativ' } else { 'benutzerdefiniert' } }}
$eigene = @($shares | Where-Object { $_.Name -notmatch '\$$' })
if ($eigene.Count -gt 0) {
Note ("Dieser Rechner gibt selbst Ordner frei (" + $eigene.Count + "): " +
(($eigene | ForEach-Object { "$($_.Name) -> $($_.Path)" }) -join '; ') +
" -> auf dem neuen Rechner nachbauen, Zugriffe pruefen.")
}
Fmt $shares
}
# =====================================================================
# 8) WLAN-PROFILE INKL. KLARTEXT-SCHLUESSEL
# =====================================================================
# Der Textweg (netsh wlan show profile name=...) scheitert an Profilnamen
# mit fuehrendem '#' (netsh liest es als Kommentar) und liefert UTF-8, das
# als OEM-Codepage gelesen Umlaute zerstoert. Der XML-Export kennt beide
# Probleme nicht und erfasst ALLE Profile.
Invoke-Abschnitt -Titel 'WLAN-Profile inkl. Klartext-Schluessel' -Datei '09_WLAN.txt' `
-Abdeckung 'netsh wlan export profile key=clear (alle Profile aller Schnittstellen)' -Aktion {
if (-not $isAdmin) {
Warnung "WLAN-Schluessel konnten nicht im Klartext gelesen werden (keine Admin-Rechte)."
# Ohne Admin gibt es keine Schluessel - aber die Profilnamen schon.
# Die sind fuer die Migration bereits die halbe Miete, deshalb hier
# nicht kommentarlos aussteigen.
$namen = Invoke-Native -Utf8 { netsh wlan show profiles }
return "Ohne Admin-Rechte liefert Windows die WLAN-Schluessel nicht im Klartext.`r`n" +
"Fuer die Schluessel bitte als Administrator wiederholen.`r`n`r`n" +
"--- Immerhin: vorhandene Profilnamen ---`r`n" +
($namen | Out-String -Width 8000)
}
$xmlDir = Join-Path $base 'WLAN_Profile'
New-Item -ItemType Directory -Path $xmlDir -Force | Out-Null
$roh = Invoke-Native -Utf8 { netsh wlan export profile key=clear folder="$xmlDir" }
$dateien = @(Get-ChildItem $xmlDir -Filter *.xml -ErrorAction SilentlyContinue)
if ($dateien.Count -eq 0) {
return "Keine WLAN-Profile gefunden (Rechner haengt vermutlich am LAN-Kabel).`r`n`r`n" +
($roh | Out-String -Width 8000)
}
$liste = @()
foreach ($f in $dateien) {
try {
$x = New-Object System.Xml.XmlDocument
$x.Load($f.FullName)
$wp = $x.WLANProfile
$sec = $wp.MSM.security
$liste += [pscustomobject]@{
Profil = $wp.name
SSID = $wp.SSIDConfig.SSID.name
Verbindung = $wp.connectionMode
Sicherheit = $sec.authEncryption.authentication
Verschluessel= $sec.authEncryption.encryption
Schluessel = $(if ($sec.sharedKey.keyMaterial) { $sec.sharedKey.keyMaterial } else { '(offen / kein Schluessel)' })
}
} catch {
$liste += [pscustomobject]@{
Profil = $f.Name; SSID = ''; Verbindung = ''; Sicherheit = ''
Verschluessel = ''; Schluessel = "(XML nicht lesbar: $($_.Exception.Message))"
}
}
}
Note "WLAN-Profile mit Klartext-Schluessel erfasst: $($liste.Count) (siehe 09_WLAN.txt)."
"Die Schluessel stehen im Klartext in der Spalte 'Schluessel'.`r`n" +
"Die unveraenderten Profil-XMLs liegen im Unterordner WLAN_Profile\ und`r`n" +
"lassen sich auf dem neuen Rechner direkt importieren:`r`n" +
" netsh wlan add profile filename=""<Datei>.xml"" user=all`r`n`r`n" +
(Fmt $liste)
}
# =====================================================================
# 9) DRUCKER
# =====================================================================
Invoke-Abschnitt -Titel 'Drucker & Anschluesse' -Datei '10_Drucker.txt' `
-Abdeckung 'Get-Printer und Get-PrinterPort (alle, inkl. Netzwerkadressen)' -Aktion {
$printers = Get-Printer | Select-Object Name, DriverName, PortName, Shared, Published, Type, Location
$ports = Get-PrinterPort | Select-Object Name, PrinterHostAddress, PortNumber, Description
$netz = @($ports | Where-Object { $_.PrinterHostAddress })
if ($netz.Count -gt 0) {
Note ("Netzwerkdrucker mit IP-Adresse: " +
(($netz | ForEach-Object { "$($_.Name) -> $($_.PrinterHostAddress)" }) -join '; '))
}
(Fmt $printers) + "`r`n--- Anschluesse (IP-Adresse = Netzwerkdrucker) ---`r`n" + (Fmt $ports)
}
# =====================================================================
# 10) GESPEICHERTE ZUGANGSDATEN (Namen; Windows gibt keine Passwoerter her)
# =====================================================================
Invoke-Abschnitt -Titel 'Gespeicherte Zugangsdaten' -Datei '11_Anmeldeinformationen.txt' `
-Abdeckung 'cmdkey /list im Kontext des ausfuehrenden Benutzers' -Aktion {
$roh = (Invoke-Native { cmdkey /list } | Out-String -Width 8000)
# Remotedesktop-Ziele sind fuer die Migration wichtig: sie zeigen,
# auf welche Server der Arbeitsplatz zugreift.
$rdp = @([regex]::Matches($roh, 'TERMSRV/([^\s]+)') | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
if ($rdp.Count -gt 0) {
Note ("Remotedesktop-Ziele hinterlegt: " + ($rdp -join ', ') + " -> Zugaenge auf dem neuen Rechner einrichten.")
}
$t = ''
if ($rdp.Count -gt 0) { $t += "--- Remotedesktop-Ziele (aus den Eintraegen herausgezogen) ---`r`n" + ($rdp -join "`r`n") + "`r`n`r`n" }
$t += "--- Vollstaendige Liste ---`r`n" + $roh
$t += "`r`nHinweis: Windows zeigt hier nur, WELCHE Zugaenge gespeichert sind.`r`n"
$t += "Die Passwoerter selbst gibt das Betriebssystem nicht heraus - sie`r`n"
$t += "muessen auf dem neuen Rechner neu eingegeben werden.`r`n"
$t
}
# =====================================================================
# 11) VPN-VERBINDUNGEN
# =====================================================================
Invoke-Abschnitt -Titel 'VPN-Verbindungen' -Datei '12_VPN.txt' `
-Abdeckung 'Get-VpnConnection (Benutzer + Alle-Benutzer)' -Aktion {
$u = @(Get-VpnConnection | Select-Object Name, ServerAddress, TunnelType, AuthenticationMethod, SplitTunneling)
$a = @(Get-VpnConnection -AllUserConnection | Select-Object Name, ServerAddress, TunnelType, AuthenticationMethod)
if ($u.Count -gt 0) { Note "VPN eingerichtet: $(($u | ForEach-Object { $_.Name }) -join ', ')" }
if ($a.Count -gt 0) { Note "VPN (alle Benutzer): $(($a | ForEach-Object { $_.Name }) -join ', ')" }
if ($u.Count -eq 0 -and $a.Count -eq 0) { return "Keine VPN-Verbindungen eingerichtet." }
"--- Benutzer-VPN ---`r`n" + (Fmt $u) + "`r`n--- Alle-Benutzer-VPN ---`r`n" + (Fmt $a)
}
# =====================================================================
# DATEISUCHE (einmal fuer alle Abschnitte, die Dateien brauchen)
# =====================================================================
# Suchlaufwerke: standardmaessig ALLE lokalen Festplatten. Das ist
# Absicht - ELSTER-Zertifikat, Passwort-Tresor und ausgelagerte
# .pst-Archive liegen in der Praxis regelmaessig auf einem
# Datenlaufwerk und nicht unter C:\Users.
if ($Laufwerke) {
$suchLaufwerke = @($Laufwerke)
} else {
$suchLaufwerke = @(Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" |
ForEach-Object { $_.DeviceID + '\' })
}
# Alles in EINEM Durchgang. Getrennte Laeufe je Abschnitt wuerden jedes
# Laufwerk mehrfach durchwandern und das Zeitbudget vervielfachen.
$dateiFunde = $null
if (-not $OhneDateisuche) {
Write-Host "[**] Dateisuche ueber $($suchLaufwerke -join ', ') (kann einige Minuten dauern)..."
$dateiFunde = Find-Dateien -Wurzeln $suchLaufwerke `
-Muster '*.pfx','*.p12','*.cer','*.crt','*.pem',
'*.kdbx','*.kdb','*.opvault','*.psafe3','*.key','*.keyx','*.kpm',
'*.pst','*.ost' `
-BudgetSekunden $SucheSekundenProLaufwerk
# Einmal warnen, nicht je auswertendem Abschnitt.
if ($dateiFunde.Gekappt.Count -gt 0) {
Warnung ("Dateisuche auf " + (($dateiFunde.Gekappt) -join ', ') +
" nach $SucheSekundenProLaufwerk s abgebrochen - diese Laufwerke sind UNVOLLSTAENDIG durchsucht. " +
"Dort von Hand nach Zertifikaten (.pfx), Tresordateien (.kdbx) und Outlook-Archiven (.pst) sehen " +
"oder mit -SucheSekundenProLaufwerk 600 wiederholen.")
}
}
# Teilt das gemeinsame Suchergebnis nach Endungen auf.
function Teilmenge($endungen) {
if ($null -eq $dateiFunde) { return $null }
$muster = '\.(' + (($endungen | ForEach-Object { [regex]::Escape($_) }) -join '|') + ')$'
[pscustomobject]@{
Treffer = @($dateiFunde.Treffer | Where-Object { $_.Datei -match $muster })
Abgesucht = $dateiFunde.Abgesucht
Gekappt = $dateiFunde.Gekappt
Unlesbar = $dateiFunde.Unlesbar
}
}
# =====================================================================
# 12) E-MAIL (Outlook + Thunderbird inkl. Serverdaten)
# =====================================================================
Invoke-Abschnitt -Titel 'E-Mail-Konten' -Datei '13_Email.txt' `
-Abdeckung 'Outlook-Profile in der Registry jedes lesbaren Profils, .pst/.ost-Dateien, Thunderbird prefs.js' -Aktion {
$mail = ''
# Eindeutige Adressen ueber ALLE Profile - die fruehere Zaehlung je
# Regex-Treffer meldete auf IMPORT1-PC "95 Adressfunde" bei rund 8
# realen Postfaechern (dieselbe Adresse steht in Dutzenden
# MAPI-Eigenschaften).
$adressenGesamt = @{}
$verworfen = 0
# Outlook legt seine Kontodaten unter MAPI-Eigenschaftsnummern ab
# (z.B. 001f6641), die sich zwischen Versionen und Kontotypen
# unterscheiden. Statt einer Liste geratener Nummern werden hier ALLE
# Werte gelesen und die behalten, die erkennbar Inhalt tragen -
# Adressen, Servernamen, Datendateipfade. Das ueberlebt einen
# Versionswechsel, eine feste Liste nicht.
foreach ($p in ($benutzerProfile | Where-Object { $_.Geladen })) {
$roots = @(
"$($p.RegRoot)\Software\Microsoft\Office\16.0\Outlook\Profiles",
"$($p.RegRoot)\Software\Microsoft\Office\15.0\Outlook\Profiles",
"$($p.RegRoot)\Software\Microsoft\Office\14.0\Outlook\Profiles",
"$($p.RegRoot)\Software\Microsoft\Windows Messaging Subsystem\Profiles"
)
foreach ($r in $roots) {
if (-not (Test-Path $r)) { continue }
$mail += "== Outlook-Profile ($($p.Name)) unter $r ==`r`n"
foreach ($prof in (Get-ChildItem $r -ErrorAction SilentlyContinue)) {
$mail += "`r`n Profilname: $($prof.PSChildName)`r`n"
$gesehen = @{}
# Deckel gegen entartete Profilbaeume: mehr als 500 Unterschluessel
# je Outlook-Profil sind kein realer Kontobestand mehr, sondern
# Registry-Wildwuchs, der den Lauf nur aufhaelt.
$unterSchluessel = @(Get-ChildItem $prof.PSPath -Recurse -ErrorAction SilentlyContinue | Select-Object -First 500)
if ($unterSchluessel.Count -eq 500) {
$mail += " (Hinweis: Profilbaum sehr gross - Auswertung auf 500 Unterschluessel begrenzt)`r`n"
}
foreach ($item in @($prof) + $unterSchluessel) {
$props = Get-ItemProperty $item.PSPath -ErrorAction SilentlyContinue
if (-not $props) { continue }
foreach ($pr in $props.PSObject.Properties) {
if ($pr.Name -match '^PS') { continue }
$val = $pr.Value
if ($val -is [byte[]]) {
$val = ([Text.Encoding]::Unicode.GetString($val)) -replace '\x00',''
}
$val = "$val".Trim()
if ($val.Length -lt 4) { continue }
if ($val.Length -gt 300) { $verworfen++; continue }
# Binaere MAPI-Eigenschaften (Entry-IDs, Suchschluessel,
# Provider-Blobs) sind KEIN Text. Der Filter deckt neben
# C0-Steuerzeichen und U+FFFD auch DEL und die C1-Zone
# \x80-\x9F ab: UTF-16-fehldekodierte Blobs bestehen oft
# nur aus druckbarem Extended-Latin plus C1-Zeichen - ohne
# den C1-Teil baute die Adress-Suche daraus Schein-Adressen
# (real auf IMPORT1-PC: drei erfundene Adressen aus einem
# einzigen Blob, beobachtet 2026-08-04, gemessen 2026-08-06).
if ($val -match '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F\uFFFD]') { $verworfen++; continue }
# Reihenfolge ist tragend: ERST Datendatei-Pfade herausziehen
# und aus dem Wert entfernen, DANN Adressen suchen. Outlook-
# Datendateien tragen die Mailadresse oft im Dateinamen
# (name@firma.de.ost) - die Adress-Suche wuerde daraus eine
# Schein-Adresse mit '.ost' als Endung bauen, und ein
# Datei-Ausdruck ohne \, @ und Klammer faende vom vollen
# Pfad nur ein nutzloses Fragment oder gar nichts.
$funde = @()
$rest = $val
foreach ($px in '(?:[A-Za-z]:|\\\\[^\\/:*?"<>|\r\n ]+)\\[^:*?"<>|\r\n]+?\.(?:pst|ost)(?![A-Za-z0-9])',
'[^\\/:*?"<>|\r\n]+?\.(?:pst|ost)(?![A-Za-z0-9])') {
foreach ($m in [regex]::Matches($rest, $px)) {
$funde += "Datei : $($m.Value.Trim())"
}
$rest = [regex]::Replace($rest, $px, ' ')
}
foreach ($m in [regex]::Matches($rest, '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}')) {
$funde += "E-Mail : $($m.Value)"
}
if ($funde.Count -eq 0) {
# Server/Hostnamen und benannte Felder ohne @: nur, wenn
# der ganze Wert wie ein Hostname aussieht oder das Feld
# ausdruecklich ein Kontofeld ist.
$istHost = $val -match '^[A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,}$'
$istFeld = $pr.Name -match '^(Email|Account Name|SMTP Server|POP3 Server|IMAP Server|Display Name)$'
if ($istHost -or ($istFeld -and $val -match '^[\x20-\x7E]+$')) {
$funde += "$($pr.Name) : $val"
}
}
foreach ($f in $funde) {
if ($gesehen.ContainsKey($f)) { continue }
$gesehen[$f] = $true
if ($f -like 'E-Mail : *') { $adressenGesamt[$f.Substring(9).ToLower()] = $true }
$mail += " $f`r`n"
}
}
}
if ($gesehen.Count -eq 0) { $mail += " (keine lesbaren Kontodaten in diesem Profil)`r`n" }
}
}
}
if ($verworfen -gt 0) {
$mail += "`r`n ($verworfen Registry-Werte als binaer/ueberlang verworfen - Binaerdaten tragen keine Kontoinfo)`r`n"
}
# Neues Outlook (Microsoft Store) speichert nicht in der Registry.
foreach ($p in $benutzerProfile) {
$neu = Join-Path $p.Pfad 'AppData\Local\Packages\Microsoft.OutlookForWindows_8wekyb3d8bbwe'
if (Test-Path $neu) {
$mail += "`r`n== Neues Outlook (Store-App) bei $($p.Name) ==`r`n$neu`r`n"
$mail += " Konten dieser App liegen nicht in der Registry. Sie sind an das`r`n"
$mail += " Microsoft-Konto gebunden und richten sich nach der Anmeldung neu ein.`r`n"
Note "Neues Outlook (Store-App) bei $($p.Name) gefunden - Konten kommen ueber die Microsoft-Anmeldung, nicht aus einer Datei."
}
}
# Outlook-Datendateien: kommen aus der laufwerksweiten Suche, weil
# .pst-Archive haeufig auf ein Datenlaufwerk ausgelagert werden.
$pst = $null
if (-not $OhneDateisuche) { $pst = Teilmenge @('pst','ost') }
if ($pst -and $pst.Treffer.Count -gt 0) {
$mail += "`r`n== Outlook-Datendateien (.pst/.ost) ==`r`n" + (SuchBericht $pst 'Outlook-Datendateien (.pst/.ost)')
Note "Outlook-Datendateien gefunden ($($pst.Treffer.Count)) -> unbedingt mitsichern (siehe 13_Email.txt)."
} elseif ($OhneDateisuche) {
$mail += "`r`n== Outlook-Datendateien ==`r`nSuche uebersprungen (Schalter -OhneDateisuche).`r`n"
}
# Thunderbird: prefs.js enthaelt Server, Ports und Benutzernamen.
foreach ($p in $benutzerProfile) {
$tb = Join-Path $p.Pfad 'AppData\Roaming\Thunderbird\Profiles'
if (-not (Test-Path $tb)) { continue }
Note "Thunderbird gefunden ($($p.Name)) -> Profilordner komplett sichern: $tb"
$mail += "`r`n== Thunderbird-Profile ($($p.Name)) ==`r`n"
foreach ($prof in (Get-ChildItem $tb -Directory)) {
$mail += "`r`nProfil: $($prof.FullName)`r`n"
$prefs = Join-Path $prof.FullName 'prefs.js'
if (Test-Path $prefs) {
$zeilen = Get-Content $prefs -Encoding UTF8 | Where-Object {
$_ -match 'mail\.server\.[^.]+\.(hostname|type|port|userName|realhostname)"' -or
$_ -match 'mail\.identity\.[^.]+\.(useremail|fullName)"' -or
$_ -match 'mail\.smtpserver\.[^.]+\.(hostname|port|username)"'
}
if ($zeilen) { $mail += ($zeilen -join "`r`n") + "`r`n" }
else { $mail += " (prefs.js enthaelt keine Kontodaten)`r`n" }
} else { $mail += " (keine prefs.js)`r`n" }
}
}
if ($adressenGesamt.Count -gt 0) {
Note "Outlook-Konten in der Registry gefunden ($($adressenGesamt.Count) eindeutige Adressen) - Serveradressen erfasst, Passwoerter nicht auslesbar."
}
if (-not $mail) { $mail = "Kein Outlook und kein Thunderbird gefunden.`r`nE-Mail laeuft moeglicherweise nur im Browser (Webmail) - beim Kunden erfragen." }
$mail + "`r`nHinweis: E-Mail-Passwoerter speichert Windows verschluesselt je Benutzerkonto;`r`nsie sind nicht auslesbar und muessen neu eingegeben werden.`r`n"
}
# =====================================================================
# 13) ZERTIFIKATE (ELSTER u.a.)
# =====================================================================
Invoke-Abschnitt -Titel 'Zertifikatsdateien (ELSTER u.a.)' -Datei '14_Zertifikate.txt' `
-Abdeckung "Alle lokalen Laufwerke ($($suchLaufwerke -join ' ')), nur Verzeichniseintraege - Dateien werden nicht geoeffnet" -Aktion {
if ($OhneDateisuche) { return "Uebersprungen (Schalter -OhneDateisuche)." }
# Getrennt ausgeben: ELSTER und andere Anwenderzertifikate sind immer
# .pfx oder .p12 (Container mit privatem Schluessel). Auf Rechnern mit
# Entwicklungswerkzeugen liegen dagegen hunderte .pem/.crt herum - in
# einer gemeinsamen Liste waere das Gesuchte darin nicht auffindbar.
$wichtig = Teilmenge @('pfx','p12')
$sonstige = Teilmenge @('cer','crt','pem')
if ($wichtig.Treffer.Count -gt 0) {
Note ("ZERTIFIKATSDATEIEN mit privatem Schluessel gefunden ($($wichtig.Treffer.Count) x .pfx/.p12) - " +
"darunter sehr wahrscheinlich das ELSTER-Zertifikat. Siehe 14_Zertifikate.txt, UNBEDINGT sichern.")
}
$t = "=== WICHTIG: Zertifikate mit privatem Schluessel (.pfx/.p12) ===`r`n"
$t += "Diese Dateien sind die migrationskritischen. Ein ELSTER-Zertifikat`r`n"
$t += "ist immer eine davon.`r`n`r`n"
$t += (SuchBericht $wichtig 'Zertifikatsdateien (.pfx/.p12)')
$t += "`r`n`r`n--- Sonstige Zertifikatsdateien (.cer/.crt/.pem) ---`r`n"
$t += "Meist oeffentliche Zertifikate von Programmen und Entwicklungs-`r`n"
$t += "werkzeugen. Fuer die Migration in aller Regel nicht noetig.`r`n`r`n"
$t += (Fmt $sonstige.Treffer)
$t += "`r`n--- Persoenlicher Zertifikatsspeicher (Windows) ---`r`n"
$t += (Fmt (Get-ChildItem Cert:\CurrentUser\My -ErrorAction SilentlyContinue |
Select-Object Subject, Issuer, @{n='Laeuft_ab';e={Datum $_.NotAfter}}, HasPrivateKey, Thumbprint))
$t += "`r`nWICHTIG: Das ELSTER-Zertifikat (.pfx) braucht zusaetzlich sein Passwort.`r`n"
$t += "Es ist nicht auslesbar - beim Kunden erfragen, bevor der alte Rechner weg ist.`r`n"
$t
}
# =====================================================================
# 14) PASSWORT-TRESORE
# =====================================================================
Invoke-Abschnitt -Titel 'Passwort-Tresore (KeePass u.a.)' -Datei '15_Passworttresore.txt' `
-Abdeckung 'KeePass-Konfiguration (zuletzt geoeffnete Dateien) + Kaspersky-Ablageordner jedes Benutzerprofils + Tresordateien in den Benutzerprofilen' -Aktion {
$t = ''
$gefundenePfade = @()
# Die Konfiguration nennt den echten Ablageort - auch wenn der auf
# einem Datenlaufwerk oder Netzlaufwerk liegt, wo keine Dateisuche
# dieses Skripts hinreicht.
foreach ($p in $benutzerProfile) {
$cfg = Join-Path $p.Pfad 'AppData\Roaming\KeePass\KeePass.config.xml'
if (-not (Test-Path $cfg)) { continue }
$t += "== KeePass-Konfiguration ($($p.Name)) ==`r`n$cfg`r`n"
try {
$x = New-Object System.Xml.XmlDocument
$x.Load($cfg)
$letzte = $x.Configuration.Application.LastUsedFile.Path
if ($letzte) { $t += " Zuletzt geoeffnet : $letzte`r`n"; $gefundenePfade += $letzte }
foreach ($ci in @($x.Configuration.Application.MostRecentlyUsedFiles.Items.ConnectionInfo)) {
if ($ci.Path) { $t += " Zuletzt benutzt : $($ci.Path)`r`n"; $gefundenePfade += $ci.Path }
}
} catch { $t += " (Konfiguration nicht lesbar: $($_.Exception.Message))`r`n" }
}
# Kaspersky Password Manager legt seine verschluesselte Datenbank NICHT
# als frei benannte Datei ab, sondern in einem festen Programmordner je
# Windows-Profil (KPM 9.x/10.x; aeltere Fassungen bis 8.x nutzten frei
# ablegbare .kpm-Dateien - die deckt die Endungssuche unten ab). Ohne
# diesen Block blieb ein installierter KPM unsichtbar, weil die Suche
# nur die KeePass-Familie kannte: IMPORT1-PC 2026-08-04 hatte KPM 9.0
# installiert, der Bericht meldete "Kein Passwort-Tresor" (Befund F6-01).
# Absichtlich NICHT abgedeckt: das Dateiformat wird nicht gedeutet und
# ein aktiver My-Kaspersky-Cloud-Abgleich nicht geprueft - beides gibt
# das System ohne Hauptkennwort nicht her. Der Ordner ist das
# Sicherungsziel, mehr behauptet der Bericht nicht.
$kpmOrdnerFunde = @()
foreach ($p in $benutzerProfile) {
foreach ($rel in @('AppData\Local\Kaspersky Lab','AppData\Roaming\Kaspersky Lab')) {
$wurzel = Join-Path $p.Pfad $rel
# Test-Path zuerst: ein fehlender Kaspersky-Ordner ist der Normalfall,
# kein Erhebungsfehler. Get-ChildItem auf einen fehlenden Pfad landet
# trotz SilentlyContinue im $Error-Protokoll des Abschnitts und
# meldete "moeglicherweise unvollstaendig" (gemessen CREA-Tier 2026-08-07).
if (-not (Test-Path $wurzel)) { continue }
foreach ($o in @(Get-ChildItem $wurzel -Directory -Filter 'Kaspersky Password Manager*' -ErrorAction SilentlyContinue)) {
$dateien = @(Get-ChildItem $o.FullName -Recurse -File -ErrorAction SilentlyContinue)
$t += "`r`n== Kaspersky-Password-Manager-Ablage ($($p.Name)) ==`r`n$($o.FullName)`r`n"
if ($dateien.Count -gt 0) {
$t += (Fmt ($dateien | Select-Object @{n='Datei';e={$_.FullName}},
@{n='KB';e={[int]($_.Length/1KB)}},
@{n='Geaendert';e={$_.LastWriteTime}}))
} else {
$t += " (Ordner vorhanden, aber leer)`r`n"
}
$kpmOrdnerFunde += $o.FullName
}
}
}
if ($kpmOrdnerFunde.Count -gt 0) {
$t += "Den GESAMTEN Ordner sichern - welche Datei darin der Tresor ist, haengt`r`n"
$t += "von der Programmversion ab. Nutzbar nur mit dem Hauptkennwort; ein evtl.`r`n"
$t += "aktiver Cloud-Abgleich (My-Kaspersky-Konto) ersetzt die Sicherung nicht.`r`n"
$gefundenePfade += $kpmOrdnerFunde
}
if (-not $OhneDateisuche) {
# Echte Tresordatenbanken - das ist der starke Fund.
$dbRes = Teilmenge @('kdbx','kdb','opvault','psafe3','kpm')
$t += "`r`n== Tresordatenbanken auf allen lokalen Laufwerken ==`r`n"
$t += (SuchBericht $dbRes 'Tresordatenbanken (.kdbx/.kpm)')
$gefundenePfade += @($dbRes.Treffer | ForEach-Object { $_.Datei })
# Schluesseldateien (.key/.keyx) sind mehrdeutig: dieselbe Endung tragen
# Windows-Produktschluessel (C:\I386\*.key), TLS-Schluessel, Lizenz- und
# SSH-Dateien. Als KeePass-Keyfile zaehlt eine .key nur, wenn im SELBEN
# Ordner eine Tresordatenbank liegt - genau so wird ein Keyfile abgelegt.
# Ohne diese Bedingung meldete das Skript eine XP-Schluesseldatei als
# "Passwort-Tresor" (Fehlalarm auf IMPORT1-PC, 2026-08-04).
$keyRes = Teilmenge @('key','keyx')
# Als bekannte Tresor-Orte zaehlen ALLE Wege, auf denen eine Datenbank
# bekannt ist: die lokalen Suchtreffer UND die aus KeePass.config.xml
# referenzierten Pfade. Nur mit den Suchtreffern wurde ein Keyfile
# neben einer Netzlaufwerk-Datenbank aktiv als "KEIN Passwort-Tresor"
# ausgewiesen (Fixture-Messung 2026-08-06) - und ohne Keyfile ist eine
# damit gesicherte Datenbank trotz Master-Passwort unwiederbringlich zu.
$dbOrdner = @{}
foreach ($d in $dbRes.Treffer) { $dbOrdner[([IO.Path]::GetDirectoryName($d.Datei)).ToLower()] = $true }
foreach ($p2 in $gefundenePfade) {
$o = [IO.Path]::GetDirectoryName($p2)
if ($o) { $dbOrdner[$o.ToLower()] = $true }
}
$keyRelevant = @($keyRes.Treffer | Where-Object { $dbOrdner[([IO.Path]::GetDirectoryName($_.Datei)).ToLower()] })
if ($keyRelevant.Count -gt 0) {
$t += "`r`n== Schluesseldateien NEBEN einer Tresordatenbank (mitsichern!) ==`r`n"
$t += (Fmt $keyRelevant)
$gefundenePfade += @($keyRelevant | ForEach-Object { $_.Datei })
}
$keyRest = @($keyRes.Treffer | Where-Object { -not $dbOrdner[([IO.Path]::GetDirectoryName($_.Datei)).ToLower()] })
if ($keyRest.Count -gt 0) {
$t += "`r`n== Sonstige .key/.keyx-Dateien (vermutlich KEIN Passwort-Tresor) ==`r`n"
$t += "Diese Dateien liegen nicht neben einer BEKANNTEN Tresordatenbank. Meist sind`r`n"
$t += "es Windows-/Lizenz-/TLS-Schluessel, kein Passwort-Manager.`r`n"
# Die Ordner-Zuordnung kann strukturell nicht greifen, wenn eine
# Datenbank ausserhalb der durchsuchten Laufwerke liegt (Netzlaufwerk,
# nur aus KeePass.config bekannt): Ihr Keyfile kann ueberall liegen.
# Ohne diesen Hinweis wurde ein echtes Keyfile aktiv als "kein
# Tresor" abgetan (Fixture-Messung 2026-08-06) - und ohne Keyfile
# ist eine damit gesicherte Datenbank unwiederbringlich zu.
$lokalGefunden = @{}
foreach ($d in $dbRes.Treffer) { $lokalGefunden[$d.Datei.ToLower()] = $true }
$externeDbs = @($gefundenePfade | Where-Object { -not $lokalGefunden[$_.ToLower()] })
if ($externeDbs.Count -gt 0) {
$t += "ABER: Mindestens eine Tresordatenbank liegt AUSSERHALB der durchsuchten`r`n"
$t += "Laufwerke (" + ($externeDbs -join ' | ') + ").`r`n"
$t += "Deren Schluesseldatei laesst sich nicht automatisch zuordnen - die unten`r`n"
$t += "gelisteten .key-Dateien im Zweifel MITSICHERN.`r`n"
}
if ($dateiFunde -and $dateiFunde.Gekappt.Count -gt 0) {
$t += "ABER: Die Dateisuche war auf " + (($dateiFunde.Gekappt) -join ', ') + " gekappt -`r`n"
$t += "eine dort liegende Datenbank waere hier unbekannt. Im Zweifel mitsichern.`r`n"
}
$t += "`r`n"
$t += (Fmt $keyRest)
}
} else {
$t += "`r`nDateisuche uebersprungen (Schalter -OhneDateisuche).`r`n"
}
# Fail-closed: Ist KPM laut Softwareliste installiert, darf "Kein
# Passwort-Tresor gefunden" nicht unkommentiert stehenbleiben - die
# Ablage kann in einer hier unbekannten Programmfassung oder auf einem
# uebersprungenen Laufwerk liegen. Stille waere ein falscher Nachweis.
# Grenze: greift nur, wenn die Softwareliste (Abschnitt 3) lesbar war.
$kpmInstalliert = @($sw | Where-Object { $_.DisplayName -match '\bKaspersky Password Manager\b' })
if ($kpmInstalliert.Count -gt 0 -and $kpmOrdnerFunde.Count -eq 0) {
Warnung ("Kaspersky Password Manager ist installiert (" +
(($kpmInstalliert | ForEach-Object { $_.DisplayVersion } | Sort-Object -Unique) -join ', ') +
"), aber keine Ablage gefunden - vor einem Geraeteabbau im Programm selbst pruefen, " +
"ob ein Tresor existiert und ob er ueber ein My-Kaspersky-Konto synchronisiert.")
$t += "`r`nACHTUNG: Kaspersky Password Manager ist INSTALLIERT, aber es wurde keine`r`n"
$t += "Ablage gefunden. Ob ein Tresor existiert, zeigt nur das Programm selbst -`r`n"
$t += "vor dem Abbau des Geraets dort nachsehen.`r`n"
}
$gefundenePfade = @($gefundenePfade | Where-Object { $_ } | Sort-Object -Unique)
if ($gefundenePfade.Count -gt 0) {
Note ("PASSWORT-TRESOR gefunden ($($gefundenePfade.Count)) - ohne diese Datei sind ALLE " +
"gespeicherten Zugaenge verloren: " + ($gefundenePfade -join ' | '))
$t += "`r`nWICHTIG: Ohne die Tresordatei UND ihr Master-Passwort (und ggf. die`r`n"
$t += "Schluesseldatei) sind saemtliche darin gespeicherten Zugaenge verloren.`r`n"
} else {
$t += "Kein Passwort-Tresor gefunden.`r`n"
}
$t
}
# =====================================================================
# 15) BROWSER
# =====================================================================
Invoke-Abschnitt -Titel 'Browser, Profile & Lesezeichen' -Datei '16_Browser.txt' `
-Abdeckung 'Bekannte Chromium- und Firefox-Ablagen aller Benutzerprofile, nur vorhandene Pfade' -Aktion {
$kandidaten = @(
@{ Name='Google Chrome'; Rel='AppData\Local\Google\Chrome\User Data'; Datei='Default\Bookmarks' },
@{ Name='Microsoft Edge'; Rel='AppData\Local\Microsoft\Edge\User Data'; Datei='Default\Bookmarks' },
@{ Name='Brave'; Rel='AppData\Local\BraveSoftware\Brave-Browser\User Data'; Datei='Default\Bookmarks' },
@{ Name='Vivaldi'; Rel='AppData\Local\Vivaldi\User Data'; Datei='Default\Bookmarks' },
@{ Name='Opera'; Rel='AppData\Roaming\Opera Software\Opera Stable'; Datei='Bookmarks' },
@{ Name='Firefox'; Rel='AppData\Roaming\Mozilla\Firefox\Profiles'; Datei='' }
)
$liste = @()
foreach ($p in $benutzerProfile) {
foreach ($k in $kandidaten) {
$pfad = Join-Path $p.Pfad $k.Rel
if (-not (Test-Path $pfad)) { continue }
$lz = ''
if ($k.Datei) {
$b = Join-Path $pfad $k.Datei
if (Test-Path $b) { $lz = $b }
} else {
$lz = (Get-ChildItem $pfad -Directory -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName }) -join ' ; '
}
$liste += [pscustomobject]@{
Konto = $p.Name; Browser = $k.Name; Profilordner = $pfad
Lesezeichen = $(if ($lz) { $lz } else { '(keine Lesezeichendatei gefunden)' })
}
}
}
$std = ''
try {
$std = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice').ProgId
} catch { }
if ($liste.Count -gt 0) {
Note ("Installierte Browser: " + (($liste | ForEach-Object { $_.Browser } | Sort-Object -Unique) -join ', ') +
" -> Lesezeichen exportieren, bevor der alte Rechner weg ist.")
}
"Standardbrowser (Registrierung): $std`r`n`r`n" + (FmtListe $liste) +
"`r`nHinweis: Lesezeichen lassen sich aus diesen Dateien uebernehmen.`r`n" +
"Im Browser gespeicherte Website-Passwoerter sind an das Windows-Benutzerkonto`r`n" +
"gebunden und NICHT exportierbar - im Browser selbst exportieren oder neu setzen.`r`n"
}
# =====================================================================
# 16) LOKALE BENUTZERKONTEN
# =====================================================================
Invoke-Abschnitt -Titel 'Lokale Benutzerkonten' -Datei '17_Benutzerkonten.txt' `
-Abdeckung 'Get-LocalUser + Mitglieder der lokalen Administratorengruppe' -Aktion {
$t = "--- Lokale Konten ---`r`n"
$t += (Fmt (Get-LocalUser | Select-Object Name, Enabled, @{n='LetzteAnmeldung';e={Datum $_.LastLogon}}, PasswordRequired, Description))
$t += "`r`n--- Mitglieder der Administratorengruppe ---`r`n"
$t += (Fmt (Get-LocalGroupMember -SID 'S-1-5-32-544' -ErrorAction SilentlyContinue |
Select-Object Name, ObjectClass, PrincipalSource))
$t += "`r`n--- Profilordner auf der Platte ---`r`n"
$t += (Fmt ($benutzerProfile | Select-Object Name, Pfad, @{n='RegistryLesbar';e={$_.Geladen}}))
$t
}
# =====================================================================
# 17) LIZENZEN & PRODUKTSCHLUESSEL
# =====================================================================
Invoke-Abschnitt -Titel 'Lizenzen & Produktschluessel' -Datei '18_Lizenzen.txt' `
-Abdeckung 'SoftwareLicensingService (OEM-Key im BIOS) + SoftwareLicensingProduct (Status, Kanal, Teilschluessel)' `
-Dauerhinweis '(Lizenzabfrage und Office-Suche brauchen oft 1-2 Minuten - das Fenster ist nicht eingefroren)' -Aktion {
$t = "--- Windows ---`r`n"
$oem = (Get-CimInstance SoftwareLicensingService).OA3xOriginalProductKey
if ($oem) {
$t += "OEM-Key (im BIOS hinterlegt): $oem`r`n"
Note "Windows-OEM-Produktschluessel ausgelesen (siehe 18_Lizenzen.txt)."
} else {
$t += "OEM-Key (im BIOS hinterlegt): nicht vorhanden.`r`n"
$t += " Das ist KEIN Fehler: einen BIOS-Key haben nur Rechner mit vorinstalliertem`r`n"
$t += " Windows ab Werk. Bei Retail-, Upgrade- oder Volumenlizenzen ist das Feld leer.`r`n"
}
$t += "`r`n--- Lizenzstatus (Windows und Office) ---`r`n"
$status = @{ 0='nicht lizenziert'; 1='lizenziert'; 2='Testzeitraum'; 3='Testzeitraum abgelaufen'; 4='Nicht-Original'; 5='Benachrichtigungsmodus'; 6='Karenzzeit' }
$lic = Get-CimInstance SoftwareLicensingProduct | Where-Object { $_.PartialProductKey } |
Select-Object @{n='Produkt';e={$_.Name}},
@{n='Teilschluessel';e={$_.PartialProductKey}},
@{n='Status';e={ if ($status.ContainsKey([int]$_.LicenseStatus)) { $status[[int]$_.LicenseStatus] } else { $_.LicenseStatus } }},
@{n='Kanal';e={$_.ProductKeyChannel}}
$t += (Fmt $lic)
foreach ($l in $lic) { Note "Lizenz: $($l.Produkt) - Kanal $($l.Kanal), Teilschluessel ...$($l.Teilschluessel)" }
# Nicht auf C: verdrahten - Windows kann auf einem anderen Laufwerk
# liegen. Auf 32-Bit-Systemen gibt es ProgramFiles(x86) nicht; die
# leere Variable wird herausgefiltert, sonst stolpert Join-Path.
$officeWurzeln = @($env:ProgramFiles, ${env:ProgramFiles(x86)}) |
Where-Object { $_ } | ForEach-Object { Join-Path $_ 'Microsoft Office' }
$ospp = Get-ChildItem $officeWurzeln -Filter 'ospp.vbs' -Recurse -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($ospp) {
$t += "`r`n--- Office-Lizenzdetails ---`r`n"
$t += "Vollstaendige Office-Lizenzinfo bei Bedarf mit:`r`n"
$t += " cscript ""$($ospp.FullName)"" /dstatus`r`n"
}
$t += "`r`nHinweis: Windows und Office geben nur die letzten fuenf Zeichen des`r`n"
$t += "Schluessels heraus. Den vollstaendigen Schluessel gibt es nur aus den`r`n"
$t += "Lizenzunterlagen, der Bestell-E-Mail oder vom Aufkleber am Geraet.`r`n"
$t
}
# =====================================================================
# 18) DATENORDNER & CLOUD-SYNC
# =====================================================================
Invoke-Abschnitt -Titel 'Datenordner-Groessen & Cloud-Sync' -Datei '19_Datenordner_Groessen.txt' `
-Abdeckung 'Bekannte Ordner ueber die Windows-Ordnerverwaltung (nicht ueber geratene Pfade) + Sync-Wurzeln von OneDrive/Dropbox/Nextcloud; Messung je Ordner auf 120 s gedeckelt' `
-Dauerhinweis '(vermisst die Datenordner - bei grossen Bestaenden einige Minuten, das Fenster ist nicht eingefroren)' -Aktion {
# Known-Folder-Redirection ist der Normalfall: Desktop und Dokumente
# liegen dann z.B. unter OneDrive. Ein geratener Pfad
# $env:USERPROFILE\Desktop existiert dort schlicht nicht und wuerde
# kommentarlos uebersprungen - das Datenvolumen erschiene zu klein.
$ordner = @(
@{ Name='Desktop'; Pfad=[Environment]::GetFolderPath('Desktop') },
@{ Name='Dokumente'; Pfad=[Environment]::GetFolderPath('MyDocuments') },
@{ Name='Bilder'; Pfad=[Environment]::GetFolderPath('MyPictures') },
@{ Name='Musik'; Pfad=[Environment]::GetFolderPath('MyMusic') },
@{ Name='Videos'; Pfad=[Environment]::GetFolderPath('MyVideos') },
@{ Name='Downloads'; Pfad=(Join-Path $env:USERPROFILE 'Downloads') }
)
foreach ($w in $syncWurzeln) { $ordner += @{ Name='Cloud-Sync'; Pfad=$w } }
# Groessenmessung mit Zeitdeckel je Ordner. Get-ChildItem -Recurse laesst
# sich nicht abbrechen - eine auf einen Netzpfad umgeleitete Bibliothek
# oder eine riesige Cloud-Wurzel hielte den Lauf sonst unbegrenzt auf
# (real: 860 GB / 162.000 Dateien allein in einer OneDrive-Wurzel).
# Dieselbe Stack-Traversierung wie die Dateisuche; nach dem Deckel wird
# der bis dahin gemessene Wert ausdruecklich als MINDESTWERT gemeldet.
# Reparse-Points (Junctions/Symlinks) werden nicht betreten - sie fuehren
# zu Zyklen und Doppelzaehlung.
function Miss-Ordner([string]$wurzel, [int]$budget = 120) {
$uhr = [Diagnostics.Stopwatch]::StartNew()
$sum = [long]0; $n = 0; $gekappt = $false
$stapel = New-Object System.Collections.Stack
$stapel.Push($wurzel)
while ($stapel.Count -gt 0) {
if ($uhr.Elapsed.TotalSeconds -gt $budget) { $gekappt = $true; break }
$o2 = $stapel.Pop()
try {
foreach ($f in [System.IO.Directory]::EnumerateFiles($o2)) {
try { $fi = New-Object System.IO.FileInfo $f; $sum += $fi.Length; $n++ } catch { }
}
} catch { }
try {
foreach ($u in [System.IO.Directory]::EnumerateDirectories($o2)) {
try {
$di = New-Object System.IO.DirectoryInfo $u
if (($di.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { continue }
} catch { }
$stapel.Push($u)
}
} catch { }
}
[pscustomobject]@{ GB = $sum/1GB; Anzahl = $n; Gekappt = $gekappt }
}
# Erst messen, dann summieren. Die bekannten Ordner liegen bei
# Known-Folder-Redirection INNERHALB der Cloud-Sync-Wurzel. Wer beides
# ungeprueft addiert, zaehlt dieselben Dateien doppelt und meldet ein
# Datenvolumen, das es nicht gibt.
$gemessen = @()
foreach ($o in $ordner) {
if (-not $o.Pfad) {
$gemessen += [pscustomobject]@{ Name=$o.Name; Pfad=''; GB=$null; Anzahl=0; Hinweis='Pfad nicht ermittelbar' }
continue
}
if (-not (Test-Path $o.Pfad)) {
$gemessen += [pscustomobject]@{ Name=$o.Name; Pfad=$o.Pfad; GB=$null; Anzahl=0; Hinweis='NICHT VORHANDEN' }
continue
}
$m = Miss-Ordner $o.Pfad
if ($m.Gekappt) {
Warnung ("Groessenmessung fuer '$($o.Pfad)' nach 120 s abgebrochen - der Wert in " +
"19_Datenordner_Groessen.txt ist ein MINDESTWERT, tatsaechlich liegt dort mehr.")
}
$gemessen += [pscustomobject]@{ Name=$o.Name; Pfad=$o.Pfad; GB=$m.GB; Anzahl=$m.Anzahl
Hinweis=$(if ($m.Gekappt) { 'MINDESTWERT, Messung nach 120 s abgebrochen' } else { '' }) }
}
# Ein Ordner zaehlt nur, wenn kein anderer gemessener Ordner ihn enthaelt.
$vorhanden = @($gemessen | Where-Object { $_.Pfad -and $null -ne $_.GB })
$zeilen = @()
$gesamt = 0
foreach ($g in $gemessen) {
if ($null -eq $g.GB) {
$zeilen += "{0,-12} {1,10} {2}: {3}" -f $g.Name, '-', $g.Hinweis, $g.Pfad
continue
}
$enthaltenIn = $null
foreach ($a in $vorhanden) {
if ($a.Pfad -eq $g.Pfad) { continue }
if ($g.Pfad.ToLower().StartsWith($a.Pfad.ToLower().TrimEnd('\') + '\')) { $enthaltenIn = $a.Pfad }
}
$zusatz = ''
if ($g.Hinweis) { $zusatz = " [$($g.Hinweis)]" }
if ($enthaltenIn) {
$zeilen += "{0,-12} {1,8:N1} GB {2,7} Dateien ({3}) [in {4} enthalten, nicht doppelt gezaehlt]{5}" -f `
$g.Name, $g.GB, $g.Anzahl, $g.Pfad, (Split-Path $enthaltenIn -Leaf), $zusatz
} else {
$gesamt += $g.GB
$zeilen += "{0,-12} {1,8:N1} GB {2,7} Dateien ({3}){4}" -f $g.Name, $g.GB, $g.Anzahl, $g.Pfad, $zusatz
}
}
if ($gesamt -gt 0) { Note ("Zu uebertragendes Datenvolumen in den Benutzerordnern: rund {0:N0} GB (ohne Doppelzaehlung)." -f $gesamt) }
$t = ($zeilen -join "`r`n") + "`r`n`r`n"
$t += ("Summe ohne doppelt gezaehlte Ordner: {0:N1} GB`r`n`r`n" -f $gesamt)
$t += "--- Cloud-Sync-Konten ---`r`n"
$od = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
if (Test-Path $od) {
foreach ($k in (Get-ChildItem $od)) {
$pr = Get-ItemProperty $k.PSPath
if ($pr.UserFolder) {
$t += "OneDrive [$($k.PSChildName)] $($pr.UserEmail) -> $($pr.UserFolder)`r`n"
Note "Cloud-Sync OneDrive: $($pr.UserEmail) -> $($pr.UserFolder)"
}
}
}
foreach ($v in 'OneDrive','OneDriveConsumer','OneDriveCommercial') {
$w = [Environment]::GetEnvironmentVariable($v)
if ($w) { $t += "Umgebungsvariable $v = $w`r`n" }
}
# Dropbox und Nextcloud fuehren ihre Konten in eigenen Dateien. Ohne die
# bleibt offen, welches Konto synchronisiert und - bei Nextcloud - gegen
# welchen Server.
foreach ($p in $benutzerProfile) {
$dbInfo = Join-Path $p.Pfad 'AppData\Local\Dropbox\info.json'
if (-not (Test-Path $dbInfo)) { $dbInfo = Join-Path $p.Pfad 'AppData\Roaming\Dropbox\info.json' }
if (Test-Path $dbInfo) {
$roh = Get-Content $dbInfo -Raw -Encoding UTF8
foreach ($treffer in [regex]::Matches($roh, '"(business|personal)"\s*:\s*\{[^}]*?"path"\s*:\s*"([^"]+)"')) {
$pfad = $treffer.Groups[2].Value -replace '\\\\', '\'
$t += "Dropbox [$($treffer.Groups[1].Value)] ($($p.Name)) -> $pfad`r`n"
Note "Cloud-Sync Dropbox ($($p.Name)): $pfad"
}
}
$ncCfg = Join-Path $p.Pfad 'AppData\Roaming\Nextcloud\nextcloud.cfg'
if (Test-Path $ncCfg) {
$t += "Nextcloud-Konfiguration ($($p.Name)): $ncCfg`r`n"
foreach ($z in (Get-Content $ncCfg -Encoding UTF8 |
Where-Object { $_ -match '^\s*(url|localPath|user|davUser)\s*=' })) {
$t += " $($z.Trim())`r`n"
}
Note "Cloud-Sync Nextcloud ($($p.Name)) eingerichtet - Serveradresse siehe 19_Datenordner_Groessen.txt."
}
}
$t += "`r`nACHTUNG: Daten in Cloud-Ordnern liegen doppelt (lokal und in der Cloud).`r`n"
$t += "Vor dem Abschalten pruefen, ob die Synchronisierung vollstaendig abgeschlossen ist,`r`n"
$t += "und ob Dateien nur online verfuegbar sind (dann fehlen sie in einer reinen Dateikopie).`r`n"
$t
}
# =====================================================================
# 19) PERIPHERIE & DATENQUELLEN
# =====================================================================
Invoke-Abschnitt -Titel 'Peripherie & ODBC-Datenquellen' -Datei '20_Peripherie.txt' `
-Abdeckung 'Chipkartenleser, serielle Geraete, ODBC-DSNs (Maschine und Benutzer), Datenbankdienste' -Aktion {
$t = "--- Chipkartenleser (Online-Banking HBCI, Signaturkarten) ---`r`n"
$sc = Get-CimInstance Win32_PnPEntity -Filter "PNPClass='SmartCardReader'" -ErrorAction SilentlyContinue |
Select-Object Name, Manufacturer, Status
$t += (Fmt $sc)
if ($sc) { Note "Chipkartenleser vorhanden -> auf dem neuen Rechner Treiber und Banking-Software einrichten." }
$t += "`r`n--- Serielle Anschluesse / COM-Geraete ---`r`n"
$t += (Fmt (Get-CimInstance Win32_SerialPort -ErrorAction SilentlyContinue | Select-Object DeviceID, Description))
$t += "`r`n--- ODBC-Datenquellen (Maschine) ---`r`n"
$t += (Fmt (Get-OdbcDsn -DsnType System -ErrorAction SilentlyContinue | Select-Object Name, DriverName, Platform))
$t += "`r`n--- ODBC-Datenquellen (Benutzer) ---`r`n"
$t += (Fmt (Get-OdbcDsn -DsnType User -ErrorAction SilentlyContinue | Select-Object Name, DriverName, Platform))
$t += "`r`n--- Datenbankdienste (Hinweis auf lokale Mandantendaten) ---`r`n"
$db = Get-Service -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -match 'SQL|Firebird|MySQL|MariaDB|PostgreSQL|Sybase|Adaptive Server' } |
Select-Object Name, DisplayName, Status, StartType
$t += (Fmt $db)
if ($db) { Note "Lokale Datenbankdienste laufen -> Datensicherung AUS DER ANWENDUNG heraus ziehen, nicht nur Dateien kopieren." }
$t
}
} finally {
# --- Temporaer geladene Hives IMMER wieder entladen ---------------
# Bliebe ein Hive geladen, waere das eine Aenderung am System und
# wuerde spaeter das Anmelden dieses Benutzers stoeren.
foreach ($p in ($benutzerProfile | Where-Object { $_.SelbstGeladen })) {
[gc]::Collect()
[gc]::WaitForPendingFinalizers()
reg unload "HKU\$($p.SID)" *>$null
if (Test-Path "Registry::HKEY_USERS\$($p.SID)") {
reg unload "HKU\$($p.SID)" *>$null
}
if (Test-Path "Registry::HKEY_USERS\$($p.SID)") {
Warnung ("Registry-Zweig des Profils '$($p.Name)' konnte nicht entladen werden " +
"(HKU\$($p.SID)). Bitte den Rechner neu starten - dann ist er wieder weg.")
}
}
}
# =====================================================================
# LAUFPROTOKOLL
# =====================================================================
$dauer = (Get-Date) - $startZeit
$prot = "===== LAUFPROTOKOLL =====`r`n"
$prot += "Skript-Fassung : $SkriptFassung`r`n"
$prot += "Rechner : $pcName`r`n"
$prot += "Start : $($startZeit.ToString('dd.MM.yyyy HH:mm:ss'))`r`n"
$prot += "Dauer : $([int]$dauer.TotalMinutes) min $($dauer.Seconds) s`r`n"
$prot += "Admin-Rechte : $isAdmin`r`n"
$prot += "Ausgefuehrt als: $prozessUser`r`n"
$prot += "Angemeldet : $angemeldeterUser`r`n"
$prot += "Ablage : $base`r`n`r`n"
$prot += "Status je Abschnitt:`r`n"
$prot += " OK = Daten erhoben`r`n"
$prot += " LEER = Abfrage lief fehlerfrei durch, hat aber nichts gefunden`r`n"
$prot += " FEHLER = Abfrage fehlgeschlagen oder ohne Ergebnis bei Fehlern - Datei unvollstaendig`r`n`r`n"
$prot += (Fmt ($protokoll | Select-Object Nr, Abschnitt, Datei, Status, Sekunden, Fehler))
$prot | Out-File -FilePath (Join-Path $base '_LAUFPROTOKOLL.txt') -Encoding UTF8 -Width 8000
# =====================================================================
# ZUSAMMENFASSUNG
# =====================================================================
$fehlerhafte = @($protokoll | Where-Object { $_.Status -eq 'FEHLER' })
$leere = @($protokoll | Where-Object { $_.Status -eq 'LEER' })
$s = @()
$s += "======================================================="
$s += " ZUSAMMENFASSUNG - $pcName - $stamp"
$s += " (Details in den nummerierten Dateien in diesem Ordner)"
$s += "======================================================="
$s += ""
$s += "!!! DIESE DATEIEN ENTHALTEN KLARTEXT-PASSWOERTER !!!"
$s += "WLAN-Schluessel, Produktschluessel und Seriennummern stehen"
$s += "unverschluesselt in den Textdateien. Vertraulich behandeln,"
$s += "verschluesselt uebertragen und nach der Migration loeschen."
$s += ""
$s += "Skript-Fassung : $SkriptFassung"
$s += "Admin-Rechte beim Scan : $isAdmin"
$s += "Ausgefuehrt als : $prozessUser"
$s += "Laufzeit : $([int]$dauer.TotalMinutes) min $($dauer.Seconds) s"
$s += "Abschnitte : $($protokoll.Count) gesamt, $($fehlerhafte.Count) fehlgeschlagen, $($leere.Count) ohne Fund"
$s += ""
if ($warnungen.Count -gt 0) {
$s += "-------------------------------------------------------"
$s += "WARNUNGEN - BITTE ZUERST LESEN:"
$warnungen | ForEach-Object { $s += " ! $_" }
$s += ""
}
if ($fehlerhafte.Count -gt 0) {
$s += "FEHLGESCHLAGENE ABSCHNITTE (Daten fehlen!):"
$fehlerhafte | ForEach-Object { $s += " ! $($_.Abschnitt) -> $($_.Datei): $($_.Meldung)" }
$s += ""
}
if ($leere.Count -gt 0) {
$s += "ABSCHNITTE OHNE FUND (geprueft, nichts vorhanden):"
$leere | ForEach-Object { $s += " - $($_.Abschnitt) ($($_.Datei))" }
$s += ""
}
$s += "WICHTIGE FUNDE:"
if ($highlights.Count -eq 0) { $s += " (keine besonderen Funde markiert)" }
else { $highlights | ForEach-Object { $s += " - $_" } }
$s += ""
$s += "NAECHSTE SCHRITTE / MANUELL PRUEFEN:"
$s += " - Buchhaltungssoftware: Datensicherung AUS DEM PROGRAMM HERAUS ziehen"
$s += " (nicht nur Dateien kopieren) + Lizenz-/Seriennummer notieren."
$s += " - ELSTER-Zertifikat (.pfx) sichern + Passwort erfragen."
$s += " - Passwort-Tresor (KeePass o.ae.) sichern + Master-Passwort erfragen."
$s += " - Online-Banking: Umzugs-/Exportfunktion der Banking-Software nutzen."
$s += " - Lesezeichen aus jedem Browser exportieren."
$s += " - Dateien, die in 14_Zertifikate.txt oder 15_Passworttresore.txt als"
$s += " 'NUR ONLINE' markiert sind, VOR der Migration lokal verfuegbar machen"
$s += " (Explorer: 'Immer auf diesem Geraet behalten') - sonst fehlen sie in"
$s += " jeder Dateikopie."
$s += " - Passwoerter (Mail, VPN, Banking, Webseiten) sind NICHT auslesbar ->"
$s += " beim Kunden erfragen oder neu setzen lassen."
$s += ""
$s += "WICHTIG: Alten Rechner / alte Festplatte NICHT loeschen, bis der neue"
$s += "Rechner nachweislich laeuft (GoBD - Buchhaltungsdaten aufbewahren)."
($s -join "`r`n") | Out-File -FilePath (Join-Path $base '_ZUSAMMENFASSUNG.txt') -Encoding UTF8 -Width 8000
# =====================================================================
# ZIP ERSTELLEN
# =====================================================================
Write-Host "[**] Packe die Ergebnisse in eine ZIP-Datei (kann bei vielen WLAN-Profilen etwas dauern)..."
$zip = "$base.zip"
$zipOk = $false
try {
if (Test-Path $zip) { Remove-Item $zip -Force -ErrorAction Stop }
# Beide Assemblies noetig: ZipFile liegt in .FileSystem, ZipArchive und
# ZipArchiveMode dagegen in System.IO.Compression.
Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop
Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop
# Das Archiv wird Eintrag fuer Eintrag gebaut statt mit
# CreateFromDirectory. Zwei Gruende:
# - Eintragsnamen brauchen Schraegstriche. CreateFromDirectory schreibt
# unter .NET Framework Backslashes, sobald Unterordner im Spiel sind
# (hier: WLAN_Profile\). Das widerspricht dem ZIP-Format; Windows
# oeffnet es noch, andere Programme stolpern darueber.
# - Die Namen sollen UTF-8 sein, damit Umlaute in WLAN-Profilnamen
# im Archiv lesbar bleiben.
$fs = New-Object System.IO.FileStream($zip, [System.IO.FileMode]::Create)
try {
$archiv = New-Object System.IO.Compression.ZipArchive(
$fs, [System.IO.Compression.ZipArchiveMode]::Create, $false, [Text.Encoding]::UTF8)
try {
foreach ($f in (Get-ChildItem -LiteralPath $base -Recurse -File)) {
$rel = $f.FullName.Substring($base.Length + 1).Replace('\', '/')
$eintrag = $archiv.CreateEntry($rel, [System.IO.Compression.CompressionLevel]::Optimal)
$ziel = $eintrag.Open()
try {
$bytes = [System.IO.File]::ReadAllBytes($f.FullName)
$ziel.Write($bytes, 0, $bytes.Length)
} finally { $ziel.Dispose() }
}
} finally { $archiv.Dispose() }
} finally { $fs.Dispose() }
$zipOk = Test-Path $zip
} catch {
$zipOk = $false
$zipFehler = $_.Exception.Message
}
Write-Host ""
if ($zipOk) {
Write-Host "=========================================================" -ForegroundColor Green
Write-Host " FERTIG!" -ForegroundColor Green
Write-Host " Ordner: $base" -ForegroundColor Green
Write-Host " ZIP : $zip" -ForegroundColor Green
Write-Host "=========================================================" -ForegroundColor Green
} else {
Write-Host "=========================================================" -ForegroundColor Yellow
Write-Host " Bestandsaufnahme fertig - ABER die ZIP-Datei fehlt!" -ForegroundColor Yellow
Write-Host " Grund : $zipFehler" -ForegroundColor Yellow
Write-Host " Ordner: $base" -ForegroundColor Yellow
Write-Host " Bitte den Ordner von Hand packen oder komplett kopieren." -ForegroundColor Yellow
Write-Host "=========================================================" -ForegroundColor Yellow
}
if ($fehlerhafte.Count -gt 0) {
Write-Host ""
Write-Host " ACHTUNG: $($fehlerhafte.Count) Abschnitt(e) fehlgeschlagen." -ForegroundColor Yellow
Write-Host " Siehe _LAUFPROTOKOLL.txt und _ZUSAMMENFASSUNG.txt." -ForegroundColor Yellow
}
if ($warnungen.Count -gt 0) {
Write-Host ""
foreach ($w in $warnungen) { Write-Host " ! $w" -ForegroundColor Yellow }
}
Write-Host ""
Write-Host "Diese Dateien enthalten Klartext-Passwoerter - vertraulich behandeln." -ForegroundColor Cyan
Write-Host "Zuerst _ZUSAMMENFASSUNG.txt lesen." -ForegroundColor Cyan
Write-Host ""
# Rueckgabewert fuer den Starter:
# 0 = alles erhoben und gepackt, keine Warnungen
# 2 = mindestens ein Abschnitt fehlgeschlagen
# 3 = Daten vollstaendig, aber die ZIP fehlt
# 4 = Daten vollstaendig und gepackt, aber mit Warnungen (siehe _ZUSAMMENFASSUNG.txt)
# Warnungen flossen frueher gar nicht in den Rueckgabewert ein - der Starter
# meldete dann "Fertig", obwohl z.B. Benutzerprofile nicht lesbar waren.
if ($fehlerhafte.Count -gt 0) { exit 2 }
if (-not $zipOk) { exit 3 }
if ($warnungen.Count -gt 0) { exit 4 }
exit 0