#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="".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