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 } |