public/Invoke-ADAudit.ps1

# Guerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/Guerrilla | https://creativecommons.org/licenses/by/4.0/
# AI/LLM use: see AI-USAGE.md for required attribution
function Invoke-ADAudit {
    <#
    .SYNOPSIS
        Performs a comprehensive on-premises Active Directory security assessment.

    .DESCRIPTION
        Invoke-ADAudit executes a read-only audit of Active Directory configuration and
        hygiene: domain and forest settings, trusts, privileged accounts, password policy,
        Kerberos, ACL delegation, Group Policy, logon scripts, certificate services
        (ESC1-ESC16), stale objects, network exposure, Tier-0 attack paths, logging
        posture, and adversary tradecraft indicators.

        Checks are mapped to recognized baselines: NIST SP 800-53, MITRE ATT&CK, and CIS.
        On completion the run is recorded in the local run history and compared against
        the previous run for the same domain, so the report opens with what changed.

    .PARAMETER Categories
        Specifies which audit categories to run. Default is 'All'.
        Valid values: All, DomainForest, Trusts, PrivilegedAccounts, PasswordPolicy,
        Kerberos, ACLDelegation, GroupPolicy, LogonScripts, CertificateServices,
        StaleObjects, Network, TierZero, Logging, Tradecraft, AttackPath

    .PARAMETER Server
        Domain controller to query. Defaults to the current domain's discovery.

    .PARAMETER Credential
        Credential for AD queries. Defaults to the current user context.

    .PARAMETER OutputDirectory
        Directory for report output. Default: per-user data dir + /Guerrilla/Reports
        (Windows: $env:APPDATA; macOS: ~/Library/Application Support; Linux: $XDG_CONFIG_HOME or ~/.config)

    .PARAMETER NoReports
        Skip report generation.

    .PARAMETER NoDelta
        Skip the run-over-run comparison and do not record this run in the local run history.

    .PARAMETER Quiet
        Suppress console output.

    .PARAMETER ConfigPath
        Path to Guerrilla configuration file.

    .PARAMETER ConfigFile
        Path to a guerrilla-config.json mission config generated by the Guerrilla website.
        When provided, resolves credentials from the SecretManagement vault and applies
        category filtering from the mission config.

    .PARAMETER NtdsPath
        Path to an offline ntds.dit for password-quality analysis.

    .PARAMETER WeakPasswordList
        Path to a custom weak-password list for password-quality checks.

    .PARAMETER InactiveDays
        Days without logon before an account counts as inactive. Default 90.

    .PARAMETER PasswordAgeDays
        Password age threshold in days for stale-password checks. Default 365.

    .PARAMETER ReportStyle
        Initial theme for the HTML report: Auto (default; follows the viewer's OS setting, with an in-report light/dark toggle), Light, or Dark. Legacy values Guerrilla, Professional, and Slate remain accepted (Professional maps to Light; Guerrilla and Slate map to Dark).

    .PARAMETER TestMode
        Run against fixture data instead of a live directory.

    .PARAMETER BloodHoundPath
        Also write a BloodHound OpenGraph export of the collected AD graph to this path.

    .PARAMETER FullDomainAcl
        Opt-in deep scan: collect dangerous ACLs across every domain object, not just the
        critical Tier-0 objects. Slower on large domains, far richer attack-path output.

    .EXAMPLE
        Invoke-ADAudit

        Audits the current domain with all categories using the current user context.

    .EXAMPLE
        Invoke-ADAudit -Server dc01.contoso.com -Credential (Get-Credential) -Categories PrivilegedAccounts,Kerberos

    .EXAMPLE
        Invoke-ADAudit -BloodHoundPath ./ad-graph.json -FullDomainAcl
    #>

    [CmdletBinding()]
    param(
        [ValidateSet('All', 'DomainForest', 'Trusts', 'PrivilegedAccounts', 'PasswordPolicy',
                     'Kerberos', 'ACLDelegation', 'GroupPolicy', 'LogonScripts',
                     'CertificateServices', 'StaleObjects', 'Network', 'TierZero', 'Logging', 'Tradecraft', 'AttackPath')]
        [string[]]$Categories = @('All'),

        [string]$Server,
        [pscredential]$Credential,

        # Student-OU designation: distinguished name(s) of the OU subtree(s) that
        # contain student accounts, e.g. 'OU=Students,DC=district,DC=org'. A
        # designation for OU-scoped K12 checks, not a collection filter; part of
        # the run's comparison identity (a student-scoped run is never diffed
        # against a whole-domain run). Stored in the audit data for the AD K12
        # checks; when omitted, those checks report Not Assessed.
        [AllowEmptyCollection()][string[]]$StudentOU = @(),

        [string]$OutputDirectory,
        [switch]$NoReports,
        [switch]$NoDelta,
        [switch]$Quiet,
        [Alias('RuntimeConfig')]
        [string]$ConfigPath,
        [Alias('MissionConfig')]
        [string]$ConfigFile,

        [string]$NtdsPath,
        [string]$WeakPasswordList,

        [int]$InactiveDays = 90,
        [int]$PasswordAgeDays = 365,

        [ValidateSet('Auto', 'Light', 'Dark', 'Guerrilla', 'Professional', 'Slate')]
        [string]$ReportStyle = 'Auto',

        # Report language for HTML exports (report shell + translated check content).
        # Empty resolves to the GUI language / OS culture / English. See Get-GuerrillaReportLanguages.
        [string]$ReportLanguage = '',

        [switch]$TestMode,

        # When set, also writes a BloodHound OpenGraph export of the collected AD graph to this path.
        [string]$BloodHoundPath,

        # Opt-in deep scan: collect dangerous ACLs across EVERY domain object, not just the six
        # critical Tier-0 objects. Unlocks deep low-priv -> Domain Admins transitive chains and a
        # far richer BloodHound export. Significantly slower on large domains.
        [switch]$FullDomainAcl
    )

    # --- Resolve mission config (guerrilla-config.json) ---
    if ($ConfigFile) {
        $missionCfg = Read-MissionConfig -Path $ConfigFile
        $vaultName = $missionCfg.VaultName

        # Resolve AD credentials from vault
        $adRef = $missionCfg.Config.credentials.references.activeDirectory
        if ($adRef -and $adRef.type -eq 'serviceAccount' -and -not $PSBoundParameters.ContainsKey('Credential')) {
            try {
                $Credential = Get-GuerrillaCredential -VaultKey ($adRef.vaultKey ?? 'GUERRILLA_AD_CREDENTIAL') -VaultName $vaultName
            } catch {
                Write-Verbose "AD credential not found in vault — will use current user context."
            }
        }

        # Apply categories from mission config
        if (-not $PSBoundParameters.ContainsKey('Categories')) {
            $adEnv = $missionCfg.EnabledEnvironments['activeDirectory']
            if ($adEnv -and $adEnv.audit -and $adEnv.audit.categories) {
                $missionCats = @($adEnv.audit.categories.GetEnumerator() | Where-Object { $_.Value } | ForEach-Object { $_.Key })
                if ($missionCats.Count -gt 0) { $Categories = $missionCats }
            }
        }
    }

    $scanId = [guid]::NewGuid().ToString()
    $scanStart = [datetime]::UtcNow

    # --- Load config ---
    $cfgPath = if ($ConfigPath) { $ConfigPath } else { $script:ConfigPath }
    $config = $null
    if ($cfgPath -and (Test-Path $cfgPath)) {
        $config = Get-Content -Path $cfgPath -Raw | ConvertFrom-Json -AsHashtable
    }

    $outDir = if ($OutputDirectory) { $OutputDirectory }
              elseif ($config -and $config.output.directory) { $config.output.directory }
              else { Join-Path (Get-GuerrillaDataRoot) 'Reports' }

    # Test mode renders zeroed timestamps for deterministic demo/sample output. Set here
    # (before any console output) and self-healing — a real run resets it to $false.
    $script:GuerrillaTestMode = [bool]$TestMode

    # --- Operation header ---
    if (-not $Quiet) {
        $targetLabel = if ($Server) { $Server } else { 'Current Domain' }
        Write-OperationHeader -Operation 'AD AUDIT' -Mode 'AD Security' -Target $targetLabel -DaysBack 0
    }

    # --- Test mode: synthesize an all-FAIL report without touching a real domain ---
    if ($TestMode) {
        if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message 'TEST MODE — simulating an all-fail report' }
        $domainName = 'testmode.local'
        $allFindings = Get-GuerrillaSimulatedFindings -Platform ActiveDirectory
    }
    else {

    # --- Connect to AD ---
    if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message 'Connecting to Active Directory' }
    try {
        $connParams = @{}
        if ($Server) { $connParams['Server'] = $Server }
        if ($Credential) { $connParams['Credential'] = $Credential }
        $connection = New-LdapConnection @connParams
    } catch {
        throw "Failed to connect to Active Directory: $_"
    }

    $domainName = $connection.DomainDN -replace 'DC=', '' -replace ',', '.'
    if (-not $Quiet) {
        Write-ProgressLine -Phase RECON -Message "Connected to $domainName"
    }

    # --- Collect data ---
    if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message 'Beginning data collection' }
    $auditData = Get-ADAuditData `
        -Connection $connection `
        -Categories $Categories `
        -InactiveDays $InactiveDays `
        -PasswordAgeDays $PasswordAgeDays `
        -NtdsPath $NtdsPath `
        -WeakPasswordList $WeakPasswordList `
        -FullDomainAcl:$FullDomainAcl `
        -Quiet:$Quiet
    $auditData.StudentOUs = @(ConvertTo-GuerrillaStudentOuList -StudentOu $StudentOU)

    # Report collection errors
    if ($auditData.Errors.Count -gt 0 -and -not $Quiet) {
        Write-ProgressLine -Phase INFO -Message "Data collection had $($auditData.Errors.Count) error(s)"
        foreach ($errKey in $auditData.Errors.Keys) {
            Write-ProgressLine -Phase INFO -Message " $errKey" -Detail $auditData.Errors[$errKey]
        }
    }

    # --- Run checks ---
    if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message 'Evaluating security checks' }
    $allFindings = [System.Collections.Generic.List[PSCustomObject]]::new()

    $categoryMap = @{
        DomainForest       = 'Invoke-ADDomainForestChecks'
        Trusts             = 'Invoke-ADTrustChecks'
        PrivilegedAccounts = 'Invoke-ADPrivilegedAccountChecks'
        PasswordPolicy     = 'Invoke-ADPasswordPolicyChecks'
        Kerberos           = 'Invoke-ADKerberosChecks'
        ACLDelegation      = 'Invoke-ADAclDelegationChecks'
        GroupPolicy        = 'Invoke-ADGroupPolicyChecks'
        LogonScripts       = 'Invoke-ADLogonScriptChecks'
        CertificateServices = 'Invoke-ADCertificateServicesChecks'
        StaleObjects       = 'Invoke-ADStaleObjectChecks'
        Network            = 'Invoke-ADNetworkChecks'
        TierZero           = 'Invoke-TierZeroChecks'
        Logging            = 'Invoke-ADLoggingChecks'
        Tradecraft         = 'Invoke-ADTradecraftChecks'
        AttackPath         = 'Invoke-ADAttackPathChecks'
    }

    $categoriesToRun = if ($Categories -contains 'All') { $categoryMap.Keys } else { $Categories }

    foreach ($cat in $categoriesToRun) {
        $funcName = $categoryMap[$cat]
        if (-not $funcName) { continue }

        if (Get-Command $funcName -ErrorAction SilentlyContinue) {
            if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message $cat }
            try {
                $catFindings = & $funcName -AuditData $auditData
                foreach ($f in @($catFindings)) {
                    $allFindings.Add($f)
                }
                $passed = @($catFindings | Where-Object Status -eq 'PASS').Count
                $failed = @($catFindings | Where-Object Status -eq 'FAIL').Count
                if (-not $Quiet) {
                    Write-ProgressLine -Phase RECON -Message $cat -Detail "P:$passed F:$failed / $($catFindings.Count)"
                }
            } catch {
                Write-Warning "Category $cat failed: $_"
                # A thrown category must not vanish from the run record: synthesize a
                # Not-Assessed (ERROR) finding per check so the next comparison shows
                # lost visibility instead of a benign "retired" check set.
                foreach ($na in @(Get-GuerrillaFailedCategoryFinding -CategoryFunction $funcName -Reason "$_")) {
                    $allFindings.Add($na)
                }
            }
        }
    }

    } # end if (-not $TestMode)

    # --- Score ---
    if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message 'Calculating posture score' }
    $scoreResult = Get-AuditPostureScore -Findings @($allFindings)
    $overallScore = $scoreResult.OverallScore
    $scoreLabel = Get-AuditScoreLabel -Score $overallScore

    # --- Run-over-run comparison against the local run history ---
    # The record is built now (it needs the findings and score) but persisted
    # only after reports are written, so a crashed run never becomes a baseline.
    $runRecord = $null
    $runDiff = $null
    if (-not $NoDelta -and -not $TestMode) {
        if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message 'Comparing against previous run' }
        try {
            $adStudentOus = @(ConvertTo-GuerrillaStudentOuList -StudentOu $StudentOU)
            $runRecord = New-GuerrillaRunRecord -Findings @($allFindings) -Platforms @('AD') `
                -TargetId @($domainName) -ScanId $scanId -OverallScore $overallScore `
                -StudentOu $adStudentOus
            $previousRun = Get-GuerrillaPreviousRun -Platforms @('AD') -TargetHash $runRecord.scope.targetHash `
                -StudentOu $adStudentOus
            $runDiff = Compare-GuerrillaRun -Previous $previousRun -Current $runRecord
        } catch {
            Write-Warning "Run comparison unavailable: $_"
        }
    }

    # --- Severity counts ---
    $failFindings = @($allFindings | Where-Object Status -eq 'FAIL')
    $critCount = @($failFindings | Where-Object Severity -eq 'Critical').Count
    $highCount = @($failFindings | Where-Object Severity -eq 'High').Count
    $medCount  = @($failFindings | Where-Object Severity -eq 'Medium').Count
    $lowCount  = @($failFindings | Where-Object Severity -eq 'Low').Count
    $passCount = @($allFindings | Where-Object Status -eq 'PASS').Count
    $failCount = $failFindings.Count
    $warnCount = @($allFindings | Where-Object Status -eq 'WARN').Count
    $skipCount = @($allFindings | Where-Object Status -in @('SKIP', 'ERROR')).Count

    # --- Console report ---
    if (-not $Quiet) {
        Write-ADReport `
            -OverallScore $overallScore `
            -ScoreLabel $scoreLabel `
            -CategoryScores $scoreResult.CategoryScores `
            -TotalChecks $allFindings.Count `
            -PassCount $passCount `
            -FailCount $failCount `
            -WarnCount $warnCount `
            -SkipCount $skipCount `
            -CriticalCount $critCount `
            -HighCount $highCount `
            -MediumCount $medCount `
            -LowCount $lowCount `
            -TopFindings @($allFindings) `
            -DomainName $domainName
    }

    # --- Optional BloodHound OpenGraph export of the collected AD graph ---
    # Runs before report generation so the HTML report can reference the written file.
    $bloodHoundWritten = $null
    if ($BloodHoundPath -and -not $TestMode -and $auditData) {
        try {
            $bh = Export-BloodHoundData -AuditData $auditData -OutputPath $BloodHoundPath
            $bloodHoundWritten = $BloodHoundPath
            if (-not $Quiet) { Write-ProgressLine -Phase REPORTING -Message 'BloodHound export' -Detail $bh.Message }
        } catch {
            Write-Warning "BloodHound export failed: $_"
        }
    }

    # --- Generate reports ---
    $csvPath = $null; $htmlPath = $null; $jsonPath = $null
    if (-not $NoReports) {
        if (-not (Test-Path $outDir)) {
            New-Item -Path $outDir -ItemType Directory -Force | Out-Null
        }
        # Test mode uses a zeroed timestamp so report filenames are deterministic.
        $timestamp = if ($script:GuerrillaTestMode) { '00000000_000000' } else { Get-Date -Format 'yyyyMMdd_HHmmss' }

        $genCsv  = if ($config -and $null -ne $config.output.generateCsv) { $config.output.generateCsv } else { $true }
        $genHtml = if ($config -and $null -ne $config.output.generateHtml) { $config.output.generateHtml } else { $true }
        $genJson = if ($config -and $null -ne $config.output.generateJson) { $config.output.generateJson } else { $true }

        if ($genCsv) {
            $csvPath = Join-Path $outDir "ad_report_$timestamp.csv"
            Export-ADReportCsv -Findings @($allFindings) -FilePath $csvPath
            if (-not $Quiet) { Write-ProgressLine -Phase REPORTING -Message 'CSV report' -Detail $csvPath }
        }
        if ($genHtml) {
            if (-not $PSBoundParameters.ContainsKey('ReportStyle') -and $config -and $config.output -and ($config.output.reportStyle -in 'Auto', 'Light', 'Dark', 'Guerrilla', 'Professional', 'Slate')) {
                $ReportStyle = [string]$config.output.reportStyle
            }
            if (-not $PSBoundParameters.ContainsKey('ReportLanguage') -and $config -and $config.output -and $config.output.reportLanguage) {
                $ReportLanguage = [string]$config.output.reportLanguage
            }
            $reportLang = Resolve-GuerrillaReportLanguage -Configured $ReportLanguage
            $reportBranding = Get-GuerrillaBranding -Config $config
            $htmlPath = Join-Path $outDir "ad_report_$timestamp.html"
            Export-ADReportHtml `
                -Findings @($allFindings) `
                -OverallScore $overallScore `
                -ScoreLabel $scoreLabel `
                -CategoryScores $scoreResult.CategoryScores `
                -DomainName $domainName `
                -RunDiff $runDiff `
                -FilePath $htmlPath `
                -Style $ReportStyle `
                -Branding $reportBranding `
                -Language $reportLang `
                -BloodHoundPath $bloodHoundWritten
            if (-not $Quiet) { Write-ProgressLine -Phase REPORTING -Message 'HTML report' -Detail $htmlPath }
        }
        if ($genJson) {
            $jsonPath = Join-Path $outDir "ad_report_$timestamp.json"
            Export-ADReportJson `
                -Findings @($allFindings) `
                -OverallScore $overallScore `
                -ScoreLabel $scoreLabel `
                -CategoryScores $scoreResult.CategoryScores `
                -DomainName $domainName `
                -ScanId $scanId `
                -RunDiff $runDiff `
                -FilePath $jsonPath
            if (-not $Quiet) { Write-ProgressLine -Phase REPORTING -Message 'JSON report' -Detail $jsonPath }
        }
    }

    # --- Record this completed run: it becomes the next run's baseline ---
    if ($runRecord) {
        try {
            $null = Save-GuerrillaRunRecord -Record $runRecord
        } catch {
            Write-Warning "Run history not updated: $_"
        }
    }

    # --- Emit result object ---
    $result = [PSCustomObject]@{
        PSTypeName     = 'Guerrilla.ReconResult'
        ScanId         = $scanId
        Timestamp      = $scanStart
        DomainName     = $domainName
        OverallScore   = $overallScore
        ScoreLabel     = $scoreLabel
        CategoryScores = $scoreResult.CategoryScores
        TotalChecks    = $allFindings.Count
        PassCount      = $passCount
        FailCount      = $failCount
        WarnCount      = $warnCount
        SkipCount      = $skipCount
        CriticalCount  = $critCount
        HighCount      = $highCount
        MediumCount    = $medCount
        LowCount       = $lowCount
        Findings       = @($allFindings)
        RunComparison  = $runDiff
        HtmlReportPath = $htmlPath
        CsvReportPath  = $csvPath
        JsonReportPath = $jsonPath
        BloodHoundPath = if ($BloodHoundPath -and (Test-Path $BloodHoundPath)) { $BloodHoundPath } else { $null }
    }

    return $result
}