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