Public/Invoke-Watchtower.ps1

# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/
# AI/LLM use: see AI-USAGE.md for required attribution
function Invoke-Watchtower {
    <#
    .SYNOPSIS
        Continuous Active Directory baseline-change monitoring.
 
    .DESCRIPTION
        Invoke-Watchtower is the AD theater of PSGuerrilla's continuous-monitoring
        suite (alongside Invoke-Surveillance for Entra sign-in risk and Invoke-Wiretap
        for M365 audit logs). It snapshots security-relevant AD state — privileged
        group membership, AdminSDHolder, GPO/ACL changes, trusts, krbtgt, delegation,
        and other Tier-0 indicators — and compares each run against a stored baseline,
        emitting the changes detected since the last sweep.
 
        The first run establishes the baseline (no changes reported). Subsequent runs
        diff against it and surface additions/removals/modifications with severity.
        Connects to the current domain by default; use -Server / -Credential to target
        another domain controller. Pair with Register-Patrol to run it on a schedule.
 
    .PARAMETER Server
        Target domain controller. Defaults to the current domain's DC.
 
    .PARAMETER Credential
        Alternate credentials for the directory connection.
 
    .PARAMETER DaysBack
        How far back to look on the first (baseline) run. Default: 1.
 
    .PARAMETER ScanMode
        Fast (core Tier-0 objects) or Full (broader object coverage). Default: Fast.
 
    .PARAMETER Force
        Re-establish the baseline instead of diffing against the stored one.
 
    .EXAMPLE
        Invoke-Watchtower
        # First run establishes the AD baseline for the current domain.
 
    .EXAMPLE
        Invoke-Watchtower -ScanMode Full
        # Subsequent run; reports Tier-0 changes since the last baseline.
 
    .NOTES
        Baseline state is stored under the per-user PSGuerrilla data root (theater 'ad').
    #>

    [CmdletBinding()]
    param(
        [string]$Server,

        [pscredential]$Credential,

        [ValidateRange(1, 180)]
        [int]$DaysBack = 1,

        [ValidateSet('Fast', 'Full')]
        [string]$ScanMode = 'Fast',

        [string]$OutputDirectory,

        [switch]$Force,

        [switch]$NoReports,

        [switch]$Quiet,

        [Alias('RuntimeConfig')]
        [string]$ConfigPath,

        [Alias('MissionConfig')]
        [string]$ConfigFile
    )

    # --- 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 {
                # Mission config asked for a service account but the vault lookup failed.
                # Warn loudly — silent fallback to the current user changes the security
                # context of every subsequent query and is almost never what the user wanted.
                Write-Warning "Mission config requested AD service account credentials but vault lookup failed: $_`nFalling back to current user context."
            }
        }

        # Apply monitoring interval from mission config
        $adEnv = $missionCfg.EnabledEnvironments['activeDirectory']
        if ($adEnv -and $adEnv.monitoring -and $adEnv.monitoring.intervalMinutes) {
            $script:MissionMonitorInterval = $adEnv.monitoring.intervalMinutes
        }

        # Extract detection filter from mission config
        if ($adEnv -and $adEnv.monitoring -and $adEnv.monitoring.detections) {
            $script:DetectionFilter = $adEnv.monitoring.detections
        }
    }

    # ── 1. Load config and resolve paths ───────────────────────────────
    $cfgPath = if ($ConfigPath) { $ConfigPath } else { $script:ConfigPath }
    $config = @{}
    if ($cfgPath -and (Test-Path $cfgPath)) {
        try {
            $config = Get-Content -Path $cfgPath -Raw | ConvertFrom-Json -AsHashtable
        } catch {
            Write-Warning "Failed to load config from $cfgPath — using defaults."
        }
    }

    $adConfig = if ($config.ContainsKey('ad')) { $config['ad'] } else { @{} }
    $targetServer = if ($Server) { $Server }
                    elseif ($adConfig.ContainsKey('server') -and $adConfig['server']) { $adConfig['server'] }
                    else { $null }

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

    if (-not (Test-Path $outDir)) {
        New-Item -Path $outDir -ItemType Directory -Force | Out-Null
    }

    $scanId = [guid]::NewGuid().ToString('N').Substring(0, 12)
    $timestamp = [datetime]::UtcNow
    $timestampStr = $timestamp.ToString('yyyy-MM-dd_HHmmss')

    # ── 2. Operation header ────────────────────────────────────────────
    if (-not $Quiet) {
        $targetDisplay = if ($targetServer) { $targetServer } else { '(auto-detect)' }
        Write-OperationHeader -Operation 'WATCHTOWER SWEEP' -Mode $ScanMode -Target $targetDisplay -DaysBack $DaysBack
    }

    # ── 3. Load theater state ──────────────────────────────────────────
    $theaterState = Get-TheaterState -Theater 'ad' -ConfigPath $cfgPath

    $isFirstRun = $null -eq $theaterState
    if ($isFirstRun) {
        $theaterState = @{
            schemaVersion  = 1
            theater        = 'ad'
            baseline       = $null
            alertedChanges = @{}
            scanHistory    = @()
        }
        if (-not $Quiet) {
            Write-ProgressLine -Phase SCANNING -Message 'No previous baseline found' -Detail '(first run — will establish baseline)'
        }
    } else {
        if (-not $Quiet) {
            $lastScan = if ($theaterState.scanHistory -and $theaterState.scanHistory.Count -gt 0) {
                $theaterState.scanHistory[-1].timestamp
            } else { 'unknown' }
            Write-ProgressLine -Phase SCANNING -Message 'Previous baseline loaded' -Detail "last scan: $lastScan"
        }
    }

    # ── 4. Connect to Active Directory ─────────────────────────────────
    if (-not $Quiet) {
        Write-ProgressLine -Phase SCANNING -Message 'Establishing LDAP connection'
    }

    $connParams = @{}
    if ($targetServer) { $connParams['Server'] = $targetServer }
    if ($Credential)   { $connParams['Credential'] = $Credential }

    try {
        $ldapConnection = New-LdapConnection @connParams
    } catch {
        throw "WATCHTOWER: Failed to connect to Active Directory: $_"
    }

    $domainName = ($ldapConnection.DomainDN -replace '^DC=', '' -replace ',DC=', '.').ToLower()

    if (-not $Quiet) {
        Write-ProgressLine -Phase SCANNING -Message "Connected to domain: $domainName"
    }

    # ── 5. Collect current AD state ────────────────────────────────────
    if (-not $Quiet) {
        Write-ProgressLine -Phase SCANNING -Message "Collecting AD state snapshot" -Detail "($ScanMode mode)"
    }

    $currentData = Get-ADMonitorData -LdapConnection $ldapConnection -ScanMode $ScanMode -Quiet:$Quiet

    # ── 6. Build baseline from current data ────────────────────────────
    $currentBaseline = Get-ADBaseline -CurrentData $currentData

    # ── 7. First run or Force: save baseline and return ────────────────
    if ($isFirstRun -or $Force) {
        $theaterState.baseline = $currentBaseline
        $theaterState.alertedChanges = @{}
        $theaterState.scanHistory = @($theaterState.scanHistory) + @(@{
            scanId    = $scanId
            timestamp = $timestamp.ToString('o')
            mode      = $ScanMode
            domain    = $domainName
            result    = 'baseline_established'
            changes   = 0
        })

        Save-TheaterState -Theater 'ad' -State $theaterState -ConfigPath $cfgPath

        if (-not $Quiet) {
            $reason = if ($Force) { 'Force flag set' } else { 'First run' }
            Write-ProgressLine -Phase SCANNING -Message "Baseline established ($reason)" -Detail "domain: $domainName"
            Write-Host ''
            Write-GuerrillaText ('=' * 62) -Color Dim
            Write-GuerrillaText ' WATCHTOWER: Baseline saved. No comparison performed.' -Color Sage
            Write-GuerrillaText ' Run again to detect changes against this baseline.' -Color Dim
            Write-GuerrillaText ('=' * 62) -Color Dim
        }

        return [PSCustomObject]@{
            PSTypeName           = 'PSGuerrilla.WatchtowerResult'
            ScanId               = $scanId
            Timestamp            = $timestamp
            Theater              = 'ActiveDirectory'
            DomainName           = $domainName
            ScanMode             = $ScanMode
            BaselineEstablished  = $true
            TotalChangesDetected = 0
            CriticalCount        = 0
            HighCount            = 0
            MediumCount          = 0
            LowCount             = 0
            FlaggedChanges       = @()
            NewThreats           = @()
            ReportPaths          = @{}
        }
    }

    # ── 8. Compare current state against baseline ──────────────────────
    if (-not $Quiet) {
        Write-ProgressLine -Phase ANALYZING -Message 'Comparing current state against baseline'
    }

    $changes = Compare-ADBaseline -PreviousBaseline $theaterState.baseline -CurrentData $currentData

    # ── 9. Build detection config from ad config ───────────────────────
    $detectionConfig = @{}
    if ($adConfig.ContainsKey('detectionWeights')) {
        $detectionConfig = $adConfig['detectionWeights']
    }

    # ── 10. Build change profile and score ─────────────────────────────
    $profileParams = @{
        Changes         = $changes
        DetectionConfig = $detectionConfig
        DomainName      = $domainName
    }
    if ($script:DetectionFilter) {
        $profileParams['DetectionFilter'] = $script:DetectionFilter
    }
    $changeProfile = New-ADChangeProfile @profileParams

    # ── 11. Determine new vs already-alerted changes ───────────────────
    $previousAlerted = if ($theaterState.ContainsKey('alertedChanges') -and $theaterState.alertedChanges) {
        $theaterState.alertedChanges
    } else { @{} }

    $newThreats = [System.Collections.Generic.List[PSCustomObject]]::new()
    $allFlagged = [System.Collections.Generic.List[PSCustomObject]]::new()
    $updatedAlerted = @{}

    foreach ($indicator in $changeProfile.Indicators) {
        $indicatorKey = $indicator.DetectionId

        $flaggedObj = [PSCustomObject]@{
            DetectionId   = $indicator.DetectionId
            DetectionName = $indicator.DetectionName
            Severity      = $indicator.Severity
            Score         = $indicator.Score
            Description   = $indicator.Description
            Details       = $indicator.Details
            IsNew         = $false
        }

        if (-not $previousAlerted.ContainsKey($indicatorKey)) {
            $flaggedObj.IsNew = $true
            $newThreats.Add($flaggedObj)
        }

        $allFlagged.Add($flaggedObj)
        $updatedAlerted[$indicatorKey] = $timestamp.ToString('o')
    }

    # Prune alerted changes older than 30 days
    $pruneThreshold = $timestamp.AddDays(-30)
    foreach ($key in @($updatedAlerted.Keys)) {
        try {
            $alertedTime = [datetime]::Parse($updatedAlerted[$key])
            if ($alertedTime -lt $pruneThreshold) {
                $updatedAlerted.Remove($key)
            }
        } catch {
            # Keep entries that fail to parse
        }
    }

    # ── 12. Count by severity ──────────────────────────────────────────
    $criticalCount = @($allFlagged | Where-Object { $_.Severity -eq 'CRITICAL' }).Count
    $highCount     = @($allFlagged | Where-Object { $_.Severity -eq 'HIGH' }).Count
    $mediumCount   = @($allFlagged | Where-Object { $_.Severity -eq 'MEDIUM' }).Count
    $lowCount      = @($allFlagged | Where-Object { $_.Severity -eq 'LOW' }).Count

    # ── 13. Save state ─────────────────────────────────────────────────
    $theaterState.baseline = $currentBaseline
    $theaterState.alertedChanges = $updatedAlerted
    $theaterState.scanHistory = @($theaterState.scanHistory) + @(@{
        scanId    = $scanId
        timestamp = $timestamp.ToString('o')
        mode      = $ScanMode
        domain    = $domainName
        result    = if ($allFlagged.Count -gt 0) { 'changes_detected' } else { 'clean' }
        changes   = $allFlagged.Count
        critical  = $criticalCount
        high      = $highCount
        medium    = $mediumCount
        low       = $lowCount
    })

    Save-TheaterState -Theater 'ad' -State $theaterState -ConfigPath $cfgPath

    # ── 14. Console report ─────────────────────────────────────────────
    $reportPaths = @{}

    if (-not $Quiet) {
        Write-WatchtowerReport `
            -TotalChanges $allFlagged.Count `
            -CriticalCount $criticalCount `
            -HighCount $highCount `
            -MediumCount $mediumCount `
            -LowCount $lowCount `
            -NewThreats @($newThreats) `
            -FlaggedChanges @($allFlagged) `
            -DomainName $domainName `
            -ScanMode $ScanMode `
            -ChangeProfile $changeProfile
    }

    # New threats intercept alert
    if (-not $Quiet -and $newThreats.Count -gt 0) {
        $interceptThreats = @($newThreats | ForEach-Object {
            [PSCustomObject]@{
                Email       = $_.DetectionName
                ThreatScore = $_.Score
                ThreatLevel = $_.Severity
                Indicators  = @($_.Description)
            }
        })
        Write-InterceptAlert -NewThreats $interceptThreats
    }

    # ── 15. Export reports ─────────────────────────────────────────────
    if (-not $NoReports -and $allFlagged.Count -gt 0) {
        $baseFileName = "watchtower_${domainName}_${timestampStr}"

        # JSON
        $jsonPath = Join-Path $outDir "$baseFileName.json"
        Export-WatchtowerReportJson -ChangeProfile $changeProfile -FlaggedChanges @($allFlagged) `
            -DomainName $domainName -ScanId $scanId -Timestamp $timestamp -FilePath $jsonPath
        $reportPaths['json'] = $jsonPath

        # CSV
        $csvPath = Join-Path $outDir "$baseFileName.csv"
        Export-WatchtowerReportCsv -FlaggedChanges @($allFlagged) -FilePath $csvPath
        $reportPaths['csv'] = $csvPath

        # HTML
        $htmlPath = Join-Path $outDir "$baseFileName.html"
        Export-WatchtowerReportHtml -ChangeProfile $changeProfile -FlaggedChanges @($allFlagged) `
            -DomainName $domainName -ScanId $scanId -Timestamp $timestamp `
            -ScanMode $ScanMode -FilePath $htmlPath
        $reportPaths['html'] = $htmlPath

        if (-not $Quiet) {
            Write-ProgressLine -Phase REPORTING -Message 'Reports exported' -Detail ($reportPaths.Values -join ', ')
        }
    }

    # ── 16. Return result object ───────────────────────────────────────
    return [PSCustomObject]@{
        PSTypeName           = 'PSGuerrilla.WatchtowerResult'
        ScanId               = $scanId
        Timestamp            = $timestamp
        Theater              = 'ActiveDirectory'
        DomainName           = $domainName
        ScanMode             = $ScanMode
        BaselineEstablished  = $false
        TotalChangesDetected = $allFlagged.Count
        CriticalCount        = $criticalCount
        HighCount            = $highCount
        MediumCount          = $mediumCount
        LowCount             = $lowCount
        FlaggedChanges       = @($allFlagged)
        NewThreats           = @($newThreats)
        ChangeProfile        = $changeProfile
        ReportPaths          = $reportPaths
    }
}