← zurück zur Toolbox

Bestandsaufnahme.ps1 · Quelltext

92862 Bytes · SHA-256: baf7a1f18bb87cc8004fee37248af6031e4adba7c37ca6b931ee8d25ea8d9dee

⤓ Download Anleitung
#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