Public/Invoke-CIEMScan.ps1

function Invoke-CIEMScan {
    <#
    .SYNOPSIS
        Executes CIEM security checks against Azure resources.

    .DESCRIPTION
        Main entry point for running CIEM security scans. Authenticates to Azure,
        initializes service data, and executes selected checks in parallel.

        Returns an array of finding objects with pass/fail/manual/skipped status.

    .PARAMETER Provider
        Cloud provider to scan. Currently only 'Azure' is supported.

    .PARAMETER TenantId
        Optional Azure tenant ID. If not specified, uses current context or config.

    .PARAMETER CheckId
        Optional array of check IDs to run. If not specified, runs all checks.

    .PARAMETER Service
        Optional service filter. Only runs checks for specified service(s).

    .PARAMETER ThrottleLimit
        Maximum parallel check execution threads. Default from config (10).

    .OUTPUTS
        [PSCustomObject[]] Array of finding objects with properties:
        - CheckId: The check identifier
        - Status: PASS, FAIL, MANUAL, or SKIPPED
        - StatusExtended: Detailed explanation
        - ResourceId: Azure resource ID
        - ResourceName: Resource display name
        - Location: Resource location
        - Severity: Check severity level

    .EXAMPLE
        $findings = Invoke-CIEMScan
        # Runs all 46 checks

    .EXAMPLE
        $findings = Invoke-CIEMScan -CheckId 'entra_security_defaults_enabled'
        # Runs single check

    .EXAMPLE
        $findings = Invoke-CIEMScan -Service Entra -Verbose
        # Runs all Entra checks with verbose output

    .EXAMPLE
        $findings = Invoke-CIEMScan | Where-Object Status -eq 'FAIL'
        # Get only failed findings
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'ThrottleLimit', Justification = 'Reserved for future parallel implementation')]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter()]
        [ValidateSet('Azure')]
        [string]$Provider = 'Azure',

        [Parameter()]
        [string]$TenantId,

        [Parameter()]
        [string[]]$CheckId,

        [Parameter()]
        [ValidateSet('Entra', 'IAM', 'KeyVault', 'Storage')]
        [string[]]$Service,

        [Parameter()]
        [ValidateRange(1, 100)]
        [int]$ThrottleLimit = $(if ($script:Config.scan.throttleLimit) { $script:Config.scan.throttleLimit } else { 10 })
    )

    $ErrorActionPreference = 'Stop'

    # Note: ThrottleLimit reserved for future parallel implementation

    Write-Verbose "Starting CIEM scan for provider: $Provider"

    # Step 1: Verify authentication (must call Connect-CIEM first)
    $authContext = Assert-CIEMAuthenticated -Provider $Provider

    Write-Verbose "Authenticated as: $($authContext.AccountId) ($($authContext.AccountType))"
    Write-Verbose "Tenant: $($authContext.TenantId)"
    Write-Verbose "Subscriptions: $($authContext.SubscriptionIds.Count)"

    # Step 2: Initialize services
    Write-Verbose "Initializing Entra service..."
    Initialize-EntraService

    Write-Verbose "Initializing IAM service..."
    Initialize-IAMService -SubscriptionIds $authContext.SubscriptionIds

    Write-Verbose "Initializing KeyVault service..."
    Initialize-KeyVaultService -SubscriptionIds $authContext.SubscriptionIds

    Write-Verbose "Initializing Storage service..."
    Initialize-StorageService -SubscriptionIds $authContext.SubscriptionIds

    # Step 3: Load check metadata
    $checks = Get-CheckMetadata

    # Step 4: Filter checks
    if ($CheckId) {
        $checks = $checks | Where-Object { $CheckId -contains $_.id }
    }

    if ($Service) {
        $checks = $checks | Where-Object { $Service -contains $_.service }
    }

    Write-Verbose "Checks to execute: $(@($checks).Count)"

    # Step 5: Load check scripts
    $checkScriptsPath = Join-Path -Path $PSScriptRoot -ChildPath '../Checks/Azure'
    $checkScripts = Get-ChildItem -Path "$checkScriptsPath/*.ps1"

    if (-not $checkScripts -or $checkScripts.Count -eq 0) {
        throw "No check scripts found in $checkScriptsPath"
    }

    # Dot-source all check scripts
    foreach ($script in $checkScripts) {
        . $script.FullName
    }

    # Build a hashtable of available check functions
    $availableFunctions = @{}
    foreach ($check in $checks) {
        $functionName = $check.checkScript -replace '\.ps1$', ''
        if (Get-Command -Name $functionName -ErrorAction SilentlyContinue) {
            $availableFunctions[$check.id] = $functionName
        }
        else {
            Write-Warning "Check function not found: $functionName for check $($check.id)"
        }
    }

    # Step 6: Execute checks and stream findings to pipeline
    # Note: ForEach-Object -Parallel creates new runspaces, so we need to pass service data
    # For simplicity in V1, we'll use sequential execution with the singleton pattern
    # A more robust parallel implementation would serialize service data

    Write-Verbose "Executing checks..."
    $findingCount = 0
    $statusCounts = @{ PASS = 0; FAIL = 0; MANUAL = 0; SKIPPED = 0 }

    foreach ($check in $checks) {
        $functionName = $availableFunctions[$check.id]

        if (-not $functionName) {
            Write-Verbose "Skipping check $($check.id) - function not available"
            $statusCounts['SKIPPED']++
            $findingCount++
            [PSCustomObject]@{
                CheckId        = $check.id
                Status         = 'SKIPPED'
                StatusExtended = 'Check function not implemented'
                ResourceId     = 'N/A'
                ResourceName   = 'N/A'
                Location       = 'N/A'
                Severity       = $check.severity
            }
        }
        else {
            Write-Verbose "Running check: $($check.id)"

            try {
                # Convert check metadata to hashtable for the function
                $checkMetadata = @{
                    id       = $check.id
                    service  = $check.service
                    title    = $check.title
                    severity = $check.severity
                }

                # Execute check and stream each finding to the pipeline
                foreach ($finding in (& $functionName -CheckMetadata $checkMetadata)) {
                    $findingCount++
                    if ($statusCounts.ContainsKey($finding.Status)) {
                        $statusCounts[$finding.Status]++
                    }
                    $finding
                }
            }
            catch {
                if ($script:Config.scan.continueOnError) {
                    Write-Warning "Check $($check.id) failed: $($_.Exception.Message)"
                    $statusCounts['SKIPPED']++
                    $findingCount++
                    [PSCustomObject]@{
                        CheckId        = $check.id
                        Status         = 'SKIPPED'
                        StatusExtended = "Check execution failed: $($_.Exception.Message)"
                        ResourceId     = 'N/A'
                        ResourceName   = 'N/A'
                        Location       = 'N/A'
                        Severity       = $check.severity
                    }
                }
                else {
                    throw
                }
            }
        }
    }

    # Step 7: Log summary (findings already streamed to pipeline)
    Write-Verbose "Scan complete. Total findings: $findingCount"
    $statusSummary = $statusCounts.GetEnumerator() | Where-Object { $_.Value -gt 0 } | ForEach-Object { "$($_.Key): $($_.Value)" }
    Write-Verbose "Status summary: $($statusSummary -join ', ')"
}