Berechtigungsbericht-Light.ps1

<#PSScriptInfo
.VERSION 1.0.1
.GUID bbe11dae-7c47-41a9-836d-e14504479979
.AUTHOR IT Admin Tools by MM
.COMPANYNAME IT Admin Tools by MM
.COPYRIGHT (c) 2026 IT Admin Tools by MM. MIT-Lizenz.
.TAGS ActiveDirectory Audit Permissions Groups Delegation Report HTML Sysadmin German Deutsch ReadOnly
.LICENSEURI https://github.com/jackyno7/berechtigungsbericht-light/blob/main/LICENSE
.PROJECTURI https://github.com/jackyno7/berechtigungsbericht-light
.ICONURI
.EXTERNALMODULEDEPENDENCIES
.REQUIREDSCRIPTS
.EXTERNALSCRIPTDEPENDENCIES
.RELEASENOTES
1.0.1: Verweis auf die Vollversion (Etsy, Gumroad) ergänzt, Skript sonst unverändert.
1.0: erste Veröffentlichung. 4 Prüfpunkte zu Gruppen und Rechten, HTML-Bericht.
.PRIVATEDATA
#>


<#
.SYNOPSIS
    LIGHT-VERSION (4 von 14 Prüfpunkten, kostenlos). Berechtigungsbericht: zeigt, wer in welcher Gruppe ist (auch verschachtelt), wer delegierte Rechte auf
    Organisationseinheiten, das Domänenobjekt und Gruppenrichtlinien hat, und erzeugt einen HTML-Bericht
    mit Auffälligkeiten.
 
.DESCRIPTION
    Vollversion mit weiteren Prüfpunkten: Etsy https://www.etsy.com/listing/4593113568 oder Gumroad https://mueller86.gumroad.com/l/xyjmdr
    Alle Skripte und Vorlagen: https://github.com/jackyno7
    Das Skript arbeitet ausschließlich LESEND. Es ändert keine Berechtigungen, Gruppen oder Objekte und
    überträgt keine Daten. Es schreibt genau eine HTML-Datei (und auf Wunsch CSV-Dateien) in den
    angegebenen Ausgabeordner.
 
    Voraussetzungen: Windows PowerShell 5.1, das Modul "ActiveDirectory" und ein Domänenkonto mit Leserechten.
    Administratorrechte sind nicht erforderlich.
 
    Ausgewertet werden die Berechtigungen im Active Directory selbst (wer darf Konten anlegen, Kennwörter
    zurücksetzen, Gruppen ändern, Richtlinien bearbeiten). Berechtigungen auf Dateifreigaben prüft das
    Skript nicht.
 
.PARAMETER Ausgabe
    Pfad der HTML-Datei. Standard: Berechtigungsbericht_<Domäne>_<Datum>.html im aktuellen Ordner.
 
.PARAMETER MaxGruppen
    Konten, die (auch verschachtelt) in mehr Gruppen sind, werden gemeldet (Standard 60).
 
.PARAMETER Server
    Bestimmter Domänencontroller oder Domäne (optional).
 
.PARAMETER Anmeldung
    Anmeldedaten eines Domänenkontos (Get-Credential), falls das Skript nicht als Domänenbenutzer läuft.
    Dann zusätzlich -Server angeben.
 
.PARAMETER Csv
    Schreibt zusätzlich die vollständige Liste aller Gruppenmitgliedschaften, alle delegierten Rechte und
    je Prüfpunkt mit Treffern eine CSV-Datei neben den Bericht.
 
.PARAMETER NichtOeffnen
    Öffnet den fertigen Bericht nicht automatisch im Browser.
 
.PARAMETER PassThru
    Gibt die Befunde zusätzlich als Objekte zurück (für eigene Auswertungen).
 
.EXAMPLE
    powershell -ExecutionPolicy Bypass -File .\Berechtigungsbericht.ps1
 
.EXAMPLE
    .\Berechtigungsbericht.ps1 -Csv -Ausgabe C:\Berichte\berechtigungen.html
 
.NOTES
    IT Admin Tools by MM - Version 1.0 (10.2026)
    Kein Ersatz für eine Sicherheitsprüfung. Nutzung auf eigene Verantwortung.
#>

#Requires -Version 5.1
[CmdletBinding()]
param(
    [string]$Ausgabe,
    [ValidateRange(2, 5000)][int]$MaxGruppen = 60,
    [string]$Server,
    [System.Management.Automation.PSCredential]$Anmeldung,
    [switch]$Csv,
    [switch]$NichtOeffnen,
    [switch]$PassThru
)

$ErrorActionPreference = 'Stop'
$Version = '1.0'

if (-not (Get-Module -ListAvailable -Name ActiveDirectory)) {
    Write-Host 'Das PowerShell-Modul "ActiveDirectory" fehlt.' -ForegroundColor Red
    Write-Host 'Windows 10/11: Einstellungen > Optionale Features > "RSAT: Active Directory Domain Services" hinzufügen.'
    Write-Host 'Windows Server: Install-WindowsFeature RSAT-AD-PowerShell'
    exit 2
}
Import-Module ActiveDirectory

$ad = @{}
if ($Server) { $ad.Server = $Server }
if ($Anmeldung) { $ad.Credential = $Anmeldung }

# ------------------------------------------------------------------ Hilfsfunktionen
function Datum($d, $leer = 'nie') { if ($d) { ([datetime]$d).ToString('dd.MM.yyyy') } else { $leer } }
function JaNein($b) { if ($b) { 'Ja' } else { 'Nein' } }
function Ort($dn) { ($dn -replace '^(CN|OU)=.+?(?<!\\),', '') -replace ',DC=.*$', '' }
function Html($t) { [System.Net.WebUtility]::HtmlEncode([string]$t) }
function Kurz($t, $n = 220) { $t = ([string]$t -replace '\s+', ' ').Trim(); if ($t.Length -gt $n) { $t.Substring(0, $n) + ' ...' } else { $t } }

# CSV-Dateien öffnen sich in Excel: Werte, die mit = + - @ beginnen, würden dort als Formel ausgeführt (Namen von Dateien, Ordnern, Konten oder Computern
# stammen von Benutzern). Diese Funktion ersetzt für dieses Skript den Befehl Export-Csv und setzt vor solche Werte ein Hochkomma.
function Export-Csv {
    [CmdletBinding()]
    param([Parameter(ValueFromPipeline = $true)]$InputObject, [Parameter(Mandatory)][string]$Path, [switch]$NoTypeInformation, [string]$Delimiter = ';', [string]$Encoding = 'UTF8')
    begin { $alle = New-Object System.Collections.Generic.List[object] }
    process { if ($null -ne $InputObject) { $alle.Add($InputObject) } }
    end {
        $sicher = foreach ($o in $alle) {
            $n = [ordered]@{}
            foreach ($p in $o.PSObject.Properties) { $v = $p.Value; if (($v -is [string]) -and ($v -match '^[=+@\t\r]|^-.')) { $v = "'" + $v }; $n[$p.Name] = $v }
            [pscustomobject]$n
        }
        $sicher | Microsoft.PowerShell.Utility\Export-Csv -Path $Path -NoTypeInformation:$NoTypeInformation -Delimiter $Delimiter -Encoding $Encoding
    }
}

# Tabellen: ConvertTo-Html und Export-Csv nehmen die Spalten des ersten Objekts. Haben die Zeilen einer Liste verschiedene
# Eigenschaften (z. B. Fehlzeile neben normalen Treffern), gingen Spalten verloren: alle Namen der Reihe nach vereinigen.
function Einheitlich($liste) {
    $liste = @($liste | Where-Object { $null -ne $_ })
    if ($liste.Count -lt 2) { return $liste }
    $namen = New-Object System.Collections.Generic.List[string]
    foreach ($o in $liste) { foreach ($p in $o.PSObject.Properties.Name) { if (-not $namen.Contains($p)) { $namen.Add($p) } } }
    if ($namen.Count -eq @($liste[0].PSObject.Properties.Name).Count) { return $liste }
    @($liste | Select-Object -Property $namen)
}
# Sortierschluessel fuer Nummern wie K2, K10: erst Buchstaben, dann die Zahl als Zahl
function IdZahl($id) { $m = [regex]::Match([string]$id, '\d+'); if ($m.Success) { [int]$m.Value } else { 0 } }
function IdText($id) { [regex]::Replace([string]$id, '\d+', '') }

$Befunde = New-Object System.Collections.Generic.List[object]

# Platzhalterzeilen ("nicht geprüft") erkennt das Skript nur an den vollständigen, festen Texten, die es selbst erzeugt - keine Teilstring-Suche
# in Konto-, Objekt- oder Beschreibungstexten, die ein Angreifer beeinflussen könnte
$PlatzhalterTexte = @('Abfrage fehlgeschlagen - nicht geprüft', 'Berechtigungen nicht lesbar - nicht geprüft', 'SYSVOL nicht lesbar - nicht geprüft', 'WMI-Filter nicht lesbar - nicht geprüft', 'Kein Domänencontroller ermittelbar - SYSVOL nicht geprüft', 'Inhalt des Ordners nicht lesbar - Richtlinie nicht geprüft')
function IstPlatzhalter($o) {
    # Ein Skript kann $script:PlatzhalterSpalten setzen: dann zählen nur diese Spalten (Namensspalten sind von Benutzern beeinflussbar)
    foreach ($pr in $o.PSObject.Properties) {
        if ($script:PlatzhalterSpalten -and ($pr.Name -notin $script:PlatzhalterSpalten)) { continue }
        if (($pr.Value -is [string]) -and ($PlatzhalterTexte -ccontains $pr.Value)) { return $true }
    }
    ("$($o.Feststellung)" -clike 'Nicht geprüft - *')
}

# Besteht ein Prüfpunkt nur aus Platzhalterzeilen (Abfrage fehlgeschlagen), ist das kein Mangel dieses Prüfpunkts: als Info führen statt
# mit seiner Stufe, damit es in den Kacheln nicht als hoher Befund zählt. Nur für Prüfpunkte, deren Ergebnis von einer Abfrage abhängt.
function InfoWennNurNichtGeprueft([string]$IdMuster) {
    foreach ($x in ($Befunde | Where-Object { $_.Id -match $IdMuster -and $_.Anzahl -gt 0 -and $_.Stufe -ne 'Info' })) {
        if (-not @($x.Objekte | Where-Object { -not (IstPlatzhalter $_) }).Count) { $x.Stufe = 'Info' }
    }
}

# Prüfpunkte, die für einzelne Server nicht ausgewertet werden konnten (Abfrage fehlgeschlagen), dürfen nicht als "ohne Befund" erscheinen:
# je Server eine Zeile "nicht geprüft" anhängen; hatte der Prüfpunkt sonst keinen Treffer, steht er als Info statt als OK.
function NichtGeprueft([string]$IdMuster, [string[]]$Namen, [string]$Verweis, [string]$Spalte = 'Server', [string]$Grund = 'Abfrage nicht möglich') {
    foreach ($x in ($Befunde | Where-Object { $_.Id -match $IdMuster })) {
        $war = $x.Anzahl
        $zeilen = @($Namen | ForEach-Object { [pscustomobject][ordered]@{ $Spalte = $_; Feststellung = "Nicht geprüft - $Grund (siehe $Verweis)"; Details = '' } })
        $x.Objekte = @($x.Objekte) + $zeilen
        $x.Anzahl = $x.Objekte.Count
        if (-not $war) { $x.Stufe = 'Info' }
    }
}
function Befund {
    param([string]$Id, [string]$Bereich, [string]$Titel, [ValidateSet('Hoch', 'Mittel', 'Niedrig', 'Info')][string]$Stufe,
        $Objekte, [string]$Erklaerung, [string]$Empfehlung)
    $liste = @($Objekte | Where-Object { $null -ne $_ })
    $status = $Stufe
    if ($liste.Count -eq 0) { $status = 'OK' }
    $Befunde.Add([pscustomobject]@{
            Id = $Id; Bereich = $Bereich; Titel = $Titel; Stufe = $status; Anzahl = $liste.Count
            Objekte = $liste; Erklaerung = $Erklaerung; Empfehlung = $Empfehlung
        })
}

# Schreibt den HTML-Bericht (und auf Wunsch CSV-Dateien). $Abschnitte: Überschrift -> Objekte oder fertiges HTML,
# erscheinen vor den Befunden. $Anhang: Überschrift -> Text, erscheint am Ende unverändert.
function BerichtSchreiben {
    param([string]$Name, [string]$Datei, [string]$Bezug, [string]$BezugName = 'Domäne', [System.Collections.IDictionary]$Umgebung,
        [System.Collections.IDictionary]$Abschnitte = @{}, [System.Collections.IDictionary]$Anhang = @{}, [string]$Fuss)
    Write-Host ' Bericht schreiben ...'
    $jetzt = Get-Date
    $ziel = $script:Ausgabe
    if (-not $ziel) { $ziel = Join-Path (Get-Location).Path ("{0}_{1}_{2:yyyy-MM-dd_HHmm}.html" -f $Datei, $Bezug, $jetzt) }
    $ziel = [System.IO.Path]::GetFullPath($ziel)
    $ordner = Split-Path $ziel -Parent
    if (-not (Test-Path $ordner)) { New-Item -ItemType Directory -Path $ordner -Force | Out-Null }

    $rang = @{ Hoch = 0; Mittel = 1; Niedrig = 2; Info = 3; OK = 4 }
    $zahl = @{}
    foreach ($s in 'Hoch', 'Mittel', 'Niedrig', 'Info', 'OK') { $zahl[$s] = @($Befunde | Where-Object Stufe -eq $s).Count }
    $css = @'
body{font-family:Segoe UI,Arial,sans-serif;color:#2b2f36;margin:0;background:#f4f6fa}
.seite{max-width:1100px;margin:0 auto;padding:28px 24px 60px}
h1{color:#1f5fae;margin:0 0 4px;font-size:28px} h2{color:#1f5fae;font-size:19px;margin:34px 0 10px;border-bottom:2px solid #d0d5dd;padding-bottom:6px}
.meta{color:#6b7280;font-size:13px;margin-bottom:22px}
.kacheln{display:flex;gap:14px;flex-wrap:wrap;margin:18px 0}
.kachel{flex:1;min-width:150px;background:#fff;border-radius:10px;padding:14px 18px;border-left:8px solid #999;box-shadow:0 1px 3px rgba(0,0,0,.08)}
.kachel b{display:block;font-size:30px} .kachel span{font-size:13px;color:#6b7280}
table{border-collapse:collapse;width:100%;background:#fff;font-size:13px;margin:8px 0 4px}
th{background:#1f5fae;color:#fff;text-align:left;padding:7px 9px;font-weight:600} td{padding:6px 9px;border-bottom:1px solid #e5e7eb;vertical-align:top;overflow-wrap:break-word}
tr:nth-child(even) td{background:#f9fafb}
.marke{display:inline-block;min-width:58px;text-align:center;padding:2px 9px;border-radius:11px;color:#fff;font-size:12px;font-weight:600}
.m-Hoch{background:#d92d20}.m-Mittel{background:#f79009}.m-Niedrig{background:#ca8a04}.m-OK{background:#12b76a}.m-Info{background:#1f5fae}
.befund{background:#fff;border-radius:10px;padding:16px 20px;margin:14px 0;border-left:8px solid #999;box-shadow:0 1px 3px rgba(0,0,0,.08)}
.befund h3{margin:0 0 6px;font-size:16px} .befund p{margin:5px 0;font-size:14px;line-height:1.45} .befund .emp{color:#14532d}
.Hoch{border-color:#d92d20}.Mittel{border-color:#f79009}.Niedrig{border-color:#eab308}.OK{border-color:#12b76a}.Info{border-color:#1f5fae}
.klein{color:#6b7280;font-size:12px} a{color:#1f5fae;text-decoration:none}
pre{background:#fff;border:1px solid #d0d5dd;border-radius:8px;padding:12px 14px;font-size:12px;white-space:pre-wrap;word-break:break-word}
.fuss{margin-top:40px;color:#6b7280;font-size:12px;border-top:1px solid #d0d5dd;padding-top:12px}
@media print{body{background:#fff}.befund,.kachel{box-shadow:none;border:1px solid #d0d5dd;border-left-width:8px;break-inside:avoid}}
'@

    $h = New-Object System.Text.StringBuilder
    [void]$h.Append("<!DOCTYPE html><html lang='de'><head><meta charset='utf-8'><title>$(Html $Name) $(Html $Bezug)</title><style>$css</style></head><body><div class='seite'>")
    [void]$h.Append("<h1>$(Html $Name)</h1><div class='meta'>$(Html $BezugName) <b>$(Html $Bezug)</b> · erstellt am $($jetzt.ToString('dd.MM.yyyy HH:mm')) von $(Html "$env:USERDOMAIN\$env:USERNAME") auf $(Html $env:COMPUTERNAME)</div>")
    [void]$h.Append("<div class='kacheln'>")
    foreach ($s in 'Hoch', 'Mittel', 'Niedrig', 'Info', 'OK') {
        $text = @{ Hoch = 'Befunde mit hoher Priorität'; Mittel = 'mit mittlerer Priorität'; Niedrig = 'mit niedriger Priorität'; Info = 'Hinweise und nicht geprüft'; OK = 'Prüfpunkte ohne Befund' }[$s]
        [void]$h.Append("<div class='kachel $s'><b>$($zahl[$s])</b><span>$text</span></div>")
    }
    [void]$h.Append("</div><h2>Umgebung</h2><table><tr><th>Eigenschaft</th><th>Wert</th></tr>")
    foreach ($k in $Umgebung.Keys) { [void]$h.Append("<tr><td>$(Html $k)</td><td>$(Html $Umgebung[$k])</td></tr>") }
    [void]$h.Append("</table>")

    [void]$h.Append("<h2>Übersicht der Prüfpunkte</h2><table><tr><th>Nr.</th><th>Bereich</th><th>Prüfpunkt</th><th>Treffer</th><th>Bewertung</th></tr>")
    foreach ($x in $Befunde) {
        [void]$h.Append("<tr><td>$($x.Id)</td><td>$(Html $x.Bereich)</td><td><a href='#$($x.Id)'>$(Html $x.Titel)</a></td><td>$($x.Anzahl)</td><td><span class='marke m-$($x.Stufe)'>$($x.Stufe)</span></td></tr>")
    }
    [void]$h.Append("</table>")

    foreach ($k in $Abschnitte.Keys) {
        [void]$h.Append("<h2>$(Html $k)</h2>")
        $inhalt = $Abschnitte[$k]
        if ($inhalt -is [string]) { [void]$h.Append($inhalt) }
        elseif (@($inhalt).Count) { [void]$h.Append(((Einheitlich $inhalt | ConvertTo-Html -Fragment) -join "`n")) }
        else { [void]$h.Append("<p class='klein'>Keine Einträge.</p>") }
    }

    [void]$h.Append("<h2>Befunde</h2>")
    $offen = @($Befunde | Where-Object { $_.Stufe -ne 'OK' } | Sort-Object { $rang[$_.Stufe] }, { IdText $_.Id }, { IdZahl $_.Id })
    foreach ($x in $offen) {
        [void]$h.Append("<div class='befund $($x.Stufe)' id='$($x.Id)'><h3><span class='marke m-$($x.Stufe)'>$($x.Stufe)</span> $($x.Id) · $(Html $x.Titel) <span class='klein'>($($x.Anzahl) Treffer)</span></h3>")
        [void]$h.Append("<p>$(Html $x.Erklaerung)</p><p class='emp'><b>Empfehlung:</b> $(Html $x.Empfehlung)</p>")
        [void]$h.Append(((Einheitlich $x.Objekte | ConvertTo-Html -Fragment) -join "`n"))
        [void]$h.Append("</div>")
    }
    if (-not $offen.Count) { [void]$h.Append("<p>Keine Auffälligkeiten gefunden.</p>") }
    $ok = @($Befunde | Where-Object Stufe -eq 'OK')
    if ($ok.Count) {
        [void]$h.Append("<h2>Ohne Befund</h2><table><tr><th>Nr.</th><th>Prüfpunkt</th></tr>")
        foreach ($x in $ok) { [void]$h.Append("<tr id='$($x.Id)'><td>$($x.Id)</td><td>$(Html $x.Titel)</td></tr>") }
        [void]$h.Append("</table>")
    }
    foreach ($k in $Anhang.Keys) { [void]$h.Append("<h2>$(Html $k)</h2><pre>$(Html $Anhang[$k])</pre>") }
    [void]$h.Append("<div class='fuss'>$(Html $Name) Version $script:Version · IT Admin Tools by MM · Das Skript hat ausschließlich gelesen und nichts verändert. $(Html $Fuss)</div>")
    [void]$h.Append("</div></body></html>")
    [System.IO.File]::WriteAllText($ziel, $h.ToString(), (New-Object System.Text.UTF8Encoding($true)))

    $basis = [System.IO.Path]::ChangeExtension($ziel, $null).TrimEnd('.')
    if ($script:Csv) {
        foreach ($x in ($Befunde | Where-Object { $_.Anzahl -gt 0 })) {
            Einheitlich $x.Objekte | Export-Csv -Path "${basis}_$($x.Id).csv" -NoTypeInformation -Delimiter ';' -Encoding UTF8
        }
    }
    Write-Host ''
    Write-Host ("Fertig: {0} hoch, {1} mittel, {2} niedrig, {3} ohne Befund." -f $zahl.Hoch, $zahl.Mittel, $zahl.Niedrig, $zahl.OK) -ForegroundColor Green
    Write-Host "Bericht: $ziel"
    if (-not $script:NichtOeffnen -and [Environment]::UserInteractive) { try { Start-Process $ziel } catch { } }
    $basis
}

# ------------------------------------------------------------------ Daten lesen
Write-Host 'Berechtigungsbericht - es wird nur gelesen, nichts verändert.' -ForegroundColor Cyan
$dom = Get-ADDomain @ad
$wald = Get-ADForest @ad
$dn = $dom.DistinguishedName
$sid = $dom.DomainSID.Value
$wurzel = Get-ADRootDSE @ad
$stammSid = $sid
try { if ($wald.RootDomain -ne $dom.DNSRoot) { $stammSid = (Get-ADDomain -Identity $wald.RootDomain @ad).DomainSID.Value } } catch { }

Write-Host ' Schema (Namen der Rechte und Eigenschaften) ...'
$guidName = @{}
Get-ADObject -SearchBase $wurzel.schemaNamingContext -LDAPFilter '(schemaIDGUID=*)' -Properties lDAPDisplayName, schemaIDGUID @ad |
    ForEach-Object { $guidName[([guid]$_.schemaIDGUID).Guid] = $_.lDAPDisplayName }
# Klassen des Schemas: ein GenericAll-Eintrag, dessen ObjectType eine Klasse ist, gewährt nur das Anlegen und Löschen von Objekten dieser Klasse
# (im Test mit Testkonten belegt: weder Berechtigungen ändern noch Besitz übernehmen noch DCSync)
$klassenGuid = @{}
Get-ADObject -SearchBase $wurzel.schemaNamingContext -LDAPFilter '(objectClass=classSchema)' -Properties schemaIDGUID @ad |
    ForEach-Object { $klassenGuid[([guid]$_.schemaIDGUID).Guid] = 1 }
Get-ADObject -SearchBase "CN=Extended-Rights,$($wurzel.configurationNamingContext)" -LDAPFilter '(rightsGuid=*)' -Properties displayName, rightsGuid @ad |
    ForEach-Object { $guidName[([guid]$_.rightsGuid).Guid] = $_.displayName }
# Deutsche Namen für die wichtigsten Rechte und Klassen
$deutsch = @{
    '00299570-246d-11d0-a768-00aa006e0529' = 'Kennwort zurücksetzen'
    'ab721a53-1e2f-11d0-9819-00aa0040529b' = 'Kennwort ändern'
    '1131f6aa-9c07-11d1-f79f-00c04fc2dcd2' = 'Verzeichnisänderungen replizieren'
    '1131f6ad-9c07-11d1-f79f-00c04fc2dcd2' = 'Alle Verzeichnisänderungen replizieren (DCSync)'
    '89e95b76-444d-4c62-991a-0facbeda640c' = 'Verzeichnisänderungen in gefilterter Menge replizieren'
}
foreach ($k in $deutsch.Keys) { $guidName[$k] = $deutsch[$k] }
$klasse = @{ user = 'Benutzer'; group = 'Gruppen'; computer = 'Computer'; organizationalUnit = 'Organisationseinheiten'; contact = 'Kontakte'; inetOrgPerson = 'Benutzer (inetOrgPerson)'; groupPolicyContainer = 'Gruppenrichtlinien' }
$leer = [guid]::Empty.Guid
$gefaehrlich = '00299570-246d-11d0-a768-00aa006e0529', '1131f6aa-9c07-11d1-f79f-00c04fc2dcd2', '1131f6ad-9c07-11d1-f79f-00c04fc2dcd2', '89e95b76-444d-4c62-991a-0facbeda640c'
$dcsync = '1131f6ad-9c07-11d1-f79f-00c04fc2dcd2'

# Konten, deren Rechte zum Auslieferungszustand gehören bzw. die ohnehin Administratoren sind
$vertraut = @('S-1-5-18', 'S-1-5-10', 'S-1-3-0', 'S-1-5-9', 'S-1-5-32-544', 'S-1-5-32-548', 'S-1-5-32-550', 'S-1-5-32-560', 'S-1-5-32-561',
    "$sid-512", "$sid-516", "$sid-517", "$sid-521", "$sid-526", "$stammSid-519", "$stammSid-498", "$stammSid-527", "$stammSid-518")
# Sammelgruppen, in denen (fast) jeder Mitglied ist
$jedermann = @{ 'S-1-1-0' = 'Jeder'; 'S-1-5-11' = 'Authentifizierte Benutzer'; 'S-1-5-7' = 'Anonymous-Anmeldung'; 'S-1-5-32-545' = 'Benutzer'; "$sid-513" = 'Domänen-Benutzer'; "$sid-515" = 'Domänencomputer'; 'S-1-5-32-554' = 'Prä-Windows 2000 kompatibler Zugriff' }
$bekannt = @{ 'S-1-5-18' = 'SYSTEM'; 'S-1-5-10' = 'SELBST'; 'S-1-3-0' = 'ERSTELLER-BESITZER'; 'S-1-5-9' = 'Domänencontroller der Organisation'; 'S-1-5-4' = 'INTERAKTIV'; 'S-1-5-2' = 'NETZWERK' }
foreach ($k in $jedermann.Keys) { $bekannt[$k] = $jedermann[$k] }

$sidCache = @{}
function Wer($s) {
    # Liefert Name, Art (Benutzer/Gruppe/Computer/Sonderkonto/Verwaist) zu einer SID
    $s = "$s"
    if (-not $sidCache.ContainsKey($s)) {
        $e = $null
        try { $o = Get-ADObject -LDAPFilter "(objectSid=$s)" -Properties sAMAccountName, objectClass @ad } catch { $o = $null }
        if ($o) {
            # Verwaltete Dienstkonten (gMSA) tragen auch "computer" in der Klassenliste: zuerst prüfen, sonst erscheinen sie als Computer
            $k = "$($o.objectClass)"
            $art = if ($k -match 'msDS-.*ServiceAccount') { 'Benutzer' } elseif ($k -match 'computer') { 'Computer' } elseif ($k -match 'group') { 'Gruppe' } elseif ($k -match 'user') { 'Benutzer' } else { 'Sonderkonto' }
            $e = [pscustomobject]@{ Name = $(if ($o.sAMAccountName) { $o.sAMAccountName } else { $o.Name }); Art = $art }
            if ($bekannt.ContainsKey($s)) { $e.Name = $bekannt[$s] }
        } elseif ($bekannt.ContainsKey($s)) { $e = [pscustomobject]@{ Name = $bekannt[$s]; Art = 'Sonderkonto' } }
        elseif ($s -like 'S-1-5-21-*') { $e = [pscustomobject]@{ Name = $s; Art = 'Verwaist' } }
        else { $e = [pscustomobject]@{ Name = $s; Art = 'Sonderkonto' } }
        $sidCache[$s] = $e
    }
    $sidCache[$s]
}
function GuidText($g, $sonst) { $g = "$g"; if ($g -eq $leer) { $sonst } elseif ($guidName.ContainsKey($g)) { $n = $guidName[$g]; if ($klasse.ContainsKey($n)) { $klasse[$n] } else { $n } } else { $g } }

$R = [System.DirectoryServices.ActiveDirectoryRights]
function RechteText($ace) {
    # Übersetzt einen Zugriffseintrag in lesbare Rechte; leere Liste = nur Leserechte
    $r = $ace.ActiveDirectoryRights
    $ot = "$($ace.ObjectType)"
    if (($r -band $R::GenericAll) -eq $R::GenericAll) {
        # GenericAll mit einer Objektklasse als ObjectType gewährt am Objekt selbst nur das Anlegen und Löschen von Objekten dieser Klasse (im Test mit
        # Testkonten belegt) - aber nur, wenn der Eintrag nicht weitervererbt wird und die Klasse nicht zum Objekt selbst gehört (eigene Klasse oder
        # Oberklasse): sonst gilt der Eintrag für das Objekt bzw. die darunter liegenden Objekte vollständig. Im Zweifel bleibt es Vollzugriff.
        if ($ot -ne $leer -and $klassenGuid.ContainsKey($ot) -and "$($ace.InheritanceType)" -eq 'None' -and -not $script:eigeneKlassen.ContainsKey($ot)) {
            return , @('Anlegen/Löschen: ' + (GuidText $ot ''))
        }
        if ($ot -ne $leer) { return , @('Vollzugriff (Zusatz: ObjectType ' + (GuidText $ot '') + ')') }
        return , @('Vollzugriff')
    }
    $t = @()
    if ($r -band $R::WriteDacl) { $t += 'Berechtigungen ändern' }
    if ($r -band $R::WriteOwner) { $t += 'Besitz übernehmen' }
    if ($r -band $R::WriteProperty) { $t += 'Schreiben: ' + (GuidText $ot 'alle Eigenschaften') }
    if ($r -band $R::CreateChild) { $t += 'Anlegen: ' + (GuidText $ot 'alle Objekte') }
    if ($r -band $R::DeleteChild) { $t += 'Löschen: ' + (GuidText $ot 'alle Objekte') }
    if (($r -band $R::Delete) -or ($r -band $R::DeleteTree)) { $t += 'Dieses Objekt löschen' }
    if ($r -band $R::ExtendedRight) {
        if ($ot -eq $leer) { $t += 'Alle erweiterten Rechte (u. a. Kennwort zurücksetzen)' }
        elseif ($ot -in $gefaehrlich) { $t += GuidText $ot '' }
    }
    if (($r -band $R::Self) -and -not ($r -band $R::WriteProperty)) {
        # Self-Membership (bf9679c0-...) erlaubt, sich selbst in die Gruppe einzutragen: nie als harmloses bestätigtes Schreiben behandeln
        if ($ot -eq 'bf9679c0-0de6-11d0-a285-00aa003049e2') { $t += 'Selbst als Mitglied eintragen (Self-Membership)' } elseif ($ot -eq $leer) { $t += 'Alle bestätigten Schreibrechte (auch Selbst als Mitglied eintragen)' } else { $t += 'Bestätigtes Schreiben: ' + (GuidText $ot 'alle') }
    }
    , $t
}
function GiltFuer($ace) {
    $io = "$($ace.InheritedObjectType)"
    $was = if ($io -eq $leer) { 'alle Objekte' } else { GuidText $io '' }
    switch ("$($ace.InheritanceType)") {
        'None' { 'nur dieses Objekt' }
        'All' { if ($io -eq $leer) { 'dieses Objekt und alles darunter' } else { "$was darunter" } }
        default { "$was darunter" }
    }
}

# Die von Exchange selbst angelegten, bekannten Gruppen (nur diese Namen, und nur in der OU "Microsoft Exchange Security Groups"): ihre Rechte am
# Domänenobjekt gehören zum Standard einer Exchange-Domäne. Sie werden getrennt unter B14 ausgewiesen, nicht als Angriffsspur unter B1 bis B3.
$exchangeSids = @{}
$exchangeBekannt = 'Exchange Trusted Subsystem', 'Exchange Windows Permissions', 'Organization Management', 'Exchange Servers', 'Exchange Enterprise Servers', 'Managed Availability Servers'
try {
    foreach ($n in $exchangeBekannt) {
        $g = Get-ADGroup -Filter "Name -eq '$n'" -SearchBase "OU=Microsoft Exchange Security Groups,$dn" -SearchScope OneLevel @ad -ErrorAction SilentlyContinue
        if ($g) { $exchangeSids[$g.SID.Value] = $g.SamAccountName }
    }
} catch { }

# Übliche Exchange-Einträge (Ausnahme für B1 bis B3): Eintrag einer bekannten Exchange-Gruppe, der keines der gefährlichen Rechte betrifft
# (Replikation/DCSync, Kennwort zurücksetzen). Replikationsrechte und Kennwort-Zurücksetzen einer Exchange-Gruppe werden immer gemeldet.
# Ein Eintrag OHNE ObjectType, der am Objekt selbst wirkt und Vollzugriff, alle erweiterten Rechte, Berechtigungen ändern oder Besitz übernehmen
# umfasst auch DCSync und Kennwort zurücksetzen: ebenfalls kein üblicher Eintrag. Nur nach unten vererbte Einträge (z. B. WriteDacl auf Benutzer)
# gehören zu Exchange und stehen unter B14.
function ExchangeUeblich($e) {
    if (-not $e.Exchange) { return $false }
    $ot = "$($e.Roh.ObjectType)"
    if ($ot -in $gefaehrlich) { return $false }
    if ($e.AmObjekt -and $ot -eq $leer -and $e.Rechte -match 'Vollzugriff|Alle erweiterten Rechte|Berechtigungen ändern|Besitz übernehmen') { return $false }
    $true
}
# Klassen, für die ein nach unten vererbter Eintrag praktisch alle Konten der Domäne betrifft (user, computer, group, organizationalUnit, inetOrgPerson)
$breiteKlassen = $leer, 'bf967aba-0de6-11d0-a285-00aa003049e2', 'bf967a86-0de6-11d0-a285-00aa003049e2', 'bf967a9c-0de6-11d0-a285-00aa003049e2', 'bf967aa5-0de6-11d0-a285-00aa003049e2', '4828cc14-1437-45bc-9b07-ad6f015e5f28'
function BreitVererbt($e) { (-not $e.AmObjekt) -and ("$($e.Roh.InheritedObjectType)" -in $breiteKlassen) }

# Liest die Zugriffseinträge eines Objekts; liefert je schreibendem Eintrag eine Zeile
function Eintraege($obj, $bezeichnung, [switch]$AuchGeerbt) {
    $sd = $obj.nTSecurityDescriptor
    if (-not $sd) { return }
    # Klassen des Objekts selbst (eigene Klasse und Oberklassen); unbekannt = alle Klassen gelten als eigene (vorsichtig)
    $script:eigeneKlassen = @{}
    $oc = @($obj.objectClass) + 'top'
    if ("$($obj.objectClass)" -eq 'domainDNS') { $oc += 'domain' }
    foreach ($gid in $klassenGuid.Keys) { if ($guidName[$gid] -in $oc) { $script:eigeneKlassen[$gid] = 1 } }
    if (-not $obj.objectClass) { foreach ($gid in $klassenGuid.Keys) { $script:eigeneKlassen[$gid] = 1 } }
    foreach ($ace in $sd.GetAccessRules($true, [bool]$AuchGeerbt, [System.Security.Principal.SecurityIdentifier])) {
        if ("$($ace.AccessControlType)" -ne 'Allow') { continue }
        $rechte = RechteText $ace
        if (-not $rechte.Count) { continue }
        $s = $ace.IdentityReference.Value
        $w = Wer $s
        [pscustomobject]@{
            Objekt = $bezeichnung; Sid = $s; Konto = $w.Name; Art = $w.Art; Rechte = ($rechte -join '; '); Gilt = GiltFuer $ace
            Vertraut = ($s -in $vertraut); Jedermann = $jedermann.ContainsKey($s); Roh = $ace
            AmObjekt = (-not ("$($ace.PropagationFlags)" -match 'InheritOnly'))
            Exchange = $exchangeSids.ContainsKey($s)
        }
    }
}
function Zeile($e) { [pscustomobject][ordered]@{ Objekt = $e.Objekt; Konto = $e.Konto; Art = $e.Art; Rechte = $e.Rechte; 'Gilt für' = $e.Gilt } }

Write-Host ' Berechtigungen auf Domäne, Organisationseinheiten und Gruppenrichtlinien ...'
$domObj = Get-ADObject -Identity $dn -Properties nTSecurityDescriptor @ad
$ous = @(Get-ADOrganizationalUnit -Filter * -Properties nTSecurityDescriptor @ad | Sort-Object { $_.DistinguishedName.Length }, DistinguishedName)
$container = @()
foreach ($c in "CN=Users,$dn", "CN=Computers,$dn") { try { $container += Get-ADObject -Identity $c -Properties nTSecurityDescriptor @ad } catch { } }
$gpos = @(Get-ADObject -SearchBase "CN=Policies,CN=System,$dn" -LDAPFilter '(objectClass=groupPolicyContainer)' -Properties displayName, nTSecurityDescriptor @ad)
$sdh = $null
try { $sdh = Get-ADObject -Identity "CN=AdminSDHolder,CN=System,$dn" -Properties nTSecurityDescriptor @ad } catch { }

function Pfad($o) { if ($o.DistinguishedName -eq $dn) { "Domäne $($dom.DNSRoot)" } else { ($o.DistinguishedName -replace ",$([regex]::Escape($dn))$", '') } }
$unlesbar = @()
foreach ($o in @($domObj) + $ous + $container + $gpos + @($sdh)) { if ($o -and -not $o.nTSecurityDescriptor) { $unlesbar += [pscustomobject]@{ Objekt = Pfad $o; Feststellung = 'Berechtigungen nicht lesbar - nicht geprüft' } } }

$eDom = @(Eintraege $domObj (Pfad $domObj))
$eOu = @(foreach ($o in $ous + $container) { Eintraege $o (Pfad $o) })
$eGpo = @(foreach ($g in $gpos) { Eintraege $g "Richtlinie $($g.displayName)" })
$eSdh = @(if ($sdh) { Eintraege $sdh 'AdminSDHolder' })
$alleE = $eDom + $eOu

# ------------------------------------------------------------------ Gruppen
Write-Host ' Gruppen und Mitgliedschaften ...'
$gruppen = @(Get-ADGroup -Filter * -Properties member, GroupScope, GroupCategory @ad)
$objekt = @{}   # DN -> Kurzinfo
foreach ($g in $gruppen) { $objekt[$g.DistinguishedName] = [pscustomobject]@{ Name = $g.SamAccountName; Art = 'Gruppe' } }
Get-ADUser -Filter * @ad | ForEach-Object { $objekt[$_.DistinguishedName] = [pscustomobject]@{ Name = $_.SamAccountName; Art = 'Benutzer' } }
Get-ADComputer -Filter * @ad | ForEach-Object { $objekt[$_.DistinguishedName] = [pscustomobject]@{ Name = $_.SamAccountName; Art = 'Computer' } }
$direkt = @{}
foreach ($g in $gruppen) { $direkt[$g.DistinguishedName] = @($g.member) }

# Effektive Mitglieder je Gruppe: DN des Mitglieds -> Weg ("direkt" oder "über Gruppe A > Gruppe B")
$effektiv = @{}
$kreise = New-Object System.Collections.Generic.List[string]
function Aufloesen($gdn, [string[]]$weg) {
    if ($effektiv.ContainsKey($gdn)) { return $effektiv[$gdn] }
    $erg = @{}
    foreach ($m in $direkt[$gdn]) { $erg[$m] = 'direkt' }
    foreach ($m in $direkt[$gdn]) {
        if ($direkt.ContainsKey($m)) {
            if ($m -eq $gdn -or $weg -contains $m) {
                $kreis = (@($weg + $gdn + $m) | ForEach-Object { $objekt[$_].Name }) -join ' > '
                if (-not $kreise.Contains($kreis)) { $kreise.Add($kreis) }
                continue
            }
            $unter = Aufloesen $m ($weg + $gdn)
            foreach ($u in $unter.Keys) {
                if ($u -ne $gdn -and -not $erg.ContainsKey($u)) { $erg[$u] = 'über ' + $objekt[$m].Name + $(if ($unter[$u] -ne 'direkt') { ' > ' + ($unter[$u] -replace '^über ') }) }
            }
        }
    }
    # Nur vollständig aufgelöste Gruppen merken (innerhalb eines Kreises wäre das Ergebnis unvollständig)
    if (-not $weg.Count) { $effektiv[$gdn] = $erg }
    $erg
}
foreach ($g in $gruppen) { [void](Aufloesen $g.DistinguishedName @()) }

$mitgliedschaften = foreach ($g in ($gruppen | Sort-Object SamAccountName)) {
    $e = $effektiv[$g.DistinguishedName]
    foreach ($m in $e.Keys) {
        $o = $objekt[$m]
        [pscustomobject][ordered]@{
            Gruppe = $g.SamAccountName; Mitglied = $(if ($o) { $o.Name } else { ($m -split ',')[0] -replace '^CN=' })
            Art = $(if ($o) { $o.Art } else { 'Sonstiges' }); Weg = $e[$m]
        }
    }
}
$mitgliedschaften = @($mitgliedschaften)
$jeKonto = @{}
foreach ($m in ($mitgliedschaften | Where-Object { $_.Art -ne 'Gruppe' })) { $jeKonto[$m.Mitglied] = 1 + [int]$jeKonto[$m.Mitglied] }

$privSids = [ordered]@{ "$sid-512" = 1; "$stammSid-519" = 1; "$stammSid-518" = 1; 'S-1-5-32-544' = 1; 'S-1-5-32-548' = 1; 'S-1-5-32-549' = 1; 'S-1-5-32-551' = 1; 'S-1-5-32-550' = 1; "$sid-520" = 1 }
$privNamen = @($gruppen | Where-Object { $privSids.Contains($_.SID.Value) } | ForEach-Object { $_.SamAccountName })
$privListe = @($mitgliedschaften | Where-Object { $_.Gruppe -in $privNamen -and $_.Art -ne 'Gruppe' } | Sort-Object Gruppe, Mitglied)
# Konten, die über die privilegierten Gruppen ohnehin Administratoren sind (auch verschachtelt)
$adminKonten = @{}
foreach ($m in $privListe) { $adminKonten[$m.Mitglied] = 1 }

# ------------------------------------------------------------------ Prüfungen
Write-Host ' Auswertung ...'
Befund 'B1' 'Domäne' 'Replikationsrechte (DCSync) außerhalb der Standardkonten' 'Hoch' `
($eDom | Where-Object { -not $_.Vertraut -and -not (ExchangeUeblich $_) -and $_.AmObjekt -and ("$($_.Roh.ObjectType)" -eq $dcsync -or $_.Rechte -match '^Vollzugriff|Alle erweiterten Rechte') } | ForEach-Object { Zeile $_ }) `
    'Wer am Domänenobjekt "Alle Verzeichnisänderungen replizieren" darf, kann die Kennwort-Hashwerte aller Konten abrufen - auch die der Administratoren (DCSync). Im Auslieferungszustand haben das nur Domänencontroller und Administratoren. Das Synchronisationskonto von Microsoft Entra Connect (MSOL_...) braucht dieses Recht und erscheint hier zu Recht.' `
    'Jeden Eintrag prüfen. Unbekannte Einträge sofort entfernen und als Sicherheitsvorfall behandeln; das Konto von Entra Connect wie ein Administratorkonto schützen.'

Befund 'B4' 'Delegierung' 'Schreibrechte für Jeder, Authentifizierte Benutzer oder Domänen-Benutzer' 'Hoch' `
(($alleE + $eGpo + $eSdh) | Where-Object { $_.Jedermann -and $_.Rechte -notmatch '^Bestätigtes Schreiben' } | ForEach-Object { Zeile $_ }) `
    'Diese Rechte gelten praktisch für jedes Konto der Domäne. Jeder Mitarbeiter und jedes übernommene Benutzerkonto kann die genannten Objekte verändern.' `
    'Eintrag entfernen und das Recht gezielt an eine eigene Gruppe vergeben.'

Befund 'B7' 'Delegierung' 'Rechte für gelöschte Konten (verwaiste SIDs)' 'Niedrig' `
(($alleE + $eGpo + $eSdh) | Where-Object { $_.Art -eq 'Verwaist' } | ForEach-Object { Zeile $_ }) `
    'Das Konto, dem dieses Recht gehörte, gibt es nicht mehr. Der Eintrag ist wirkungslos, macht die Berechtigungen aber unübersichtlich. SIDs aus vertrauten fremden Domänen können hier ebenfalls erscheinen, wenn sie sich nicht auflösen lassen.' `
    'Einträge entfernen.'

Befund 'B11' 'Gruppen' 'Gruppen, die sich im Kreis enthalten' 'Niedrig' `
($kreise | ForEach-Object { [pscustomobject]@{ Kreis = $_ } }) `
    'Gruppe A ist Mitglied von B und B wieder von A. Technisch funktioniert das, macht Mitgliedschaften aber unübersichtlich und bringt manche Anwendungen in Endlosschleifen.' `
    'Eine der Mitgliedschaften entfernen.'

# ------------------------------------------------------------------ Bericht
$gruppenListe = $gruppen | Where-Object { $effektiv[$_.DistinguishedName].Count } | Sort-Object SamAccountName | ForEach-Object {
    $e = $effektiv[$_.DistinguishedName]
    [pscustomobject][ordered]@{
        Gruppe = $_.SamAccountName; Bereich = "$($_.GroupScope)"; 'Direkte Mitglieder' = @($direkt[$_.DistinguishedName]).Count
        'Benutzer und Computer (auch verschachtelt)' = @($e.Keys | Where-Object { -not $direkt.ContainsKey($_) }).Count
        'Enthaltene Gruppen' = @($e.Keys | Where-Object { $direkt.ContainsKey($_) }).Count; Ort = Ort $_.DistinguishedName
    }
}
$umgebung = [ordered]@{
    'Domäne (NetBIOS)'                 = "$($dom.DNSRoot) ($($dom.NetBIOSName))"
    'Gruppen (mit Mitgliedern / gesamt)' = "$(@($gruppenListe).Count) / $($gruppen.Count)"
    'Ausgewertete Mitgliedschaften'    = $mitgliedschaften.Count
    'Geprüfte Objekte'                 = "Domänenobjekt, $($ous.Count) Organisationseinheiten, $($container.Count) Standardcontainer, $($gpos.Count) Gruppenrichtlinien, AdminSDHolder"
    'Delegierte Rechte (ohne Standard)' = $deleg.Count
    'Schwellen dieses Berichts'        = "Konten in mehr als $MaxGruppen Gruppen"
    'Hinweis'                          = 'Mitglieder über die primäre Gruppe (z. B. Domänen-Benutzer) sind nicht einzeln aufgeführt.'
}
$basis = BerichtSchreiben -Name 'Berechtigungsbericht Light' -Datei 'Berechtigungsbericht' -Bezug $dom.DNSRoot -Umgebung $umgebung `
    -Abschnitte ([ordered]@{ 'Mitglieder der privilegierten Gruppen (auch verschachtelt)' = $privListe; 'Gruppen im Überblick' = $gruppenListe }) `
    -Fuss 'Light-Version mit 4 von 14 Prüfpunkten der Vollversion (https://mueller86.gumroad.com/l/xyjmdr). Ausgewertet wurden Gruppenmitgliedschaften und Berechtigungen im Active Directory, nicht die Berechtigungen auf Dateifreigaben. Der Bericht zeigt Auffälligkeiten anhand fester Regeln; er ersetzt keine Sicherheitsprüfung und erhebt keinen Anspruch auf Vollständigkeit. Er enthält Konto- und Gruppennamen - bitte vertraulich behandeln.'
if ($Csv) {
    $mitgliedschaften | Export-Csv -Path "${basis}_Gruppenmitglieder.csv" -NoTypeInformation -Delimiter ';' -Encoding UTF8
    ($alleE + $eGpo + $eSdh) | ForEach-Object { $z = Zeile $_; $z | Add-Member -NotePropertyName Standard -NotePropertyValue (JaNein ($_.Vertraut -or $_.Jedermann)) -PassThru } |
        Export-Csv -Path "${basis}_Rechte.csv" -NoTypeInformation -Delimiter ';' -Encoding UTF8
}
if ($PassThru) { $Befunde }