Public/Invoke-BsiCompliance.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
    Main entry point: validates Azure 3-tier deployment against BSI IT-Grundschutz++.

.DESCRIPTION
    Runs compliance checks in either Local mode (static script analysis) or
    Remote mode (live Azure resource inspection). Returns results and optionally
    exports to JSON, SARIF, JUnit XML, or HTML.

.PARAMETER Local
    Validate a deployment script via regex pattern matching.

.PARAMETER Remote
    Validate deployed Azure infrastructure via Azure CLI.

.PARAMETER ScriptPath
    Path to deployment script for Local mode.

.PARAMETER ConfigPath
    Path to config.ps1 for Remote mode.

.PARAMETER SubscriptionId
    Optional Azure subscription ID.

.PARAMETER Sync
    Force re-download of BSI catalog before validation.

.PARAMETER OutputFormat
    Output format(s): Console, Json, Sarif, JUnitXml, Html. Can be comma-separated.

.PARAMETER OutputPath
    Path to write the output file(s). Suffix is appended per format.

.PARAMETER NsgNames
    NSG names to check (default: NSG-Web, NSG-App, NSG-DB).

.PARAMETER DenyPorts
    Ports to check for Deny rules (default: 22, 3389).

.EXAMPLE
    Invoke-BsiCompliance -Local -ScriptPath .\create_3-tier-webapp_VM_v2.ps1

.EXAMPLE
    Invoke-BsiCompliance -Remote -ConfigPath .\config.ps1 -OutputFormat Sarif,Html -OutputPath .\reports\compliance

.EXAMPLE
    Invoke-BsiCompliance -Sync # Just sync catalog, no validation
#>

function Invoke-BsiCompliance {
    [CmdletBinding(DefaultParameterSetName = 'Remote')]
    param(
        [Parameter(ParameterSetName = 'Local')]
        [switch]$Local,

        [Parameter(ParameterSetName = 'Remote')]
        [switch]$Remote,

        [Parameter(ParameterSetName = 'Sync')]
        [switch]$Sync,

        [string]$ScriptPath = '',
        [string]$ConfigPath = '.\config.ps1',
        [string]$SubscriptionId = '',

        [string]$OutputFormat = 'Console',
        [string]$OutputPath = '',

        [string[]]$NsgNames = @('NSG-Web', 'NSG-App', 'NSG-DB'),
        [string[]]$DenyPorts = @('22', '3389')
    )

    $ErrorActionPreference = 'Stop'

    # --- Determine mode ---
    if (-not $Local -and -not $Remote -and -not $Sync) {
        $Remote = $true
    }

    # --- Resolve paths (self-contained: all data lives in ModuleRoot/Data/) ---
    # Fallback: if function is invoked outside module context, derive from function location
    if (-not $script:ModuleRoot) {
        $funcDir   = Split-Path -Parent $MyInvocation.MyCommand.Path
        $script:ModuleRoot = Split-Path -Parent $funcDir
    }
    $moduleRoot   = $script:ModuleRoot
    $dataDir      = Join-Path $moduleRoot 'Data'
    $cacheDir     = Join-Path $dataDir 'cache'
    $catalogPath  = Join-Path $cacheDir 'Grundschutz++-catalog.json'
    $mappingPath  = Join-Path $dataDir 'bsi-azure-mapping.json'

    # --- Sync mode ---
    if ($Sync) {
        if (-not (Test-Path -LiteralPath $mappingPath)) {
            throw "Mapping file not found: $mappingPath"
        }
        Sync-BsiCatalog -OutPath $catalogPath -MappingPath $mappingPath
        $mapping = Get-BsiMapping -Path $mappingPath
        Write-Host "[OK] BSI catalog synchronised." -ForegroundColor Green
        if (-not $Local -and -not $Remote) {
            return
        }
    }

    # --- Reset results ---
    Reset-BsiResults

    # --- Load mapping ---
    if (-not (Test-Path -LiteralPath $mappingPath)) {
        throw "Mapping file not found: $mappingPath. Run with -Sync first."
    }
    $mapping = Get-BsiMapping -Path $mappingPath

    # --- Load catalog ---
    $catalog = Get-BsiCatalog -Path $catalogPath
    if ($catalog) {
        $catalogControls = Get-BsiCatalogControls -Catalog $catalog
        $orphaned = Resolve-BsiOrphans -Mapping $mapping -CatalogControls $catalogControls
        if ($orphaned.Count -gt 0) {
            Write-Warning "Orphaned controls (not in catalog): $($orphaned -join ', ')"
        }
    }

    # --- Print header ---
    $modeStr = if ($Local) { 'Local' } elseif ($Remote) { 'Remote' } else { 'Sync' }
    Write-Host "`n=============================================" -ForegroundColor Cyan
    Write-Host "BSI IT-Grundschutz++ Compliance Validator" -ForegroundColor Cyan
    Write-Host "Mode: $modeStr | Checks: $($mapping.mappings.Count) controls" -ForegroundColor Cyan
    Write-Host "=============================================`n" -ForegroundColor Cyan

    # ===================== LOCAL MODE =====================
    if ($Local) {
        if (-not $ScriptPath) {
            throw "-ScriptPath is required for Local mode."
        }
        if (-not (Test-Path -LiteralPath $ScriptPath)) {
            throw "Script file not found: $ScriptPath"
        }

        $content = Get-Content -Raw -Path $ScriptPath

        foreach ($entry in $mapping.mappings) {
            $lc = $entry.localCheck
            if (-not $lc) { continue }

            $controlId = $entry.controlId
            $catalogEntry = if ($catalogControls) {
                $catalogControls | Where-Object { $_.id -eq $controlId } | Select-Object -First 1
            }
            $title = if ($catalogEntry -and $catalogEntry.title) { $catalogEntry.title } else { $entry.title }

            $matched = 0
            $required = $lc.mustMatchAtLeast
            $maxAllowed = if ($null -ne $lc.maxAllowed) { $lc.maxAllowed } else { [int]::MaxValue }

            foreach ($pattern in $lc.patterns) {
                if ([regex]::IsMatch($content, $pattern)) {
                    $matched++
                }
            }

            $pass = ($matched -ge $required) -and ($matched -le $maxAllowed)
            $status = if ($pass) { [BsiCheckStatus]::Pass } else { [BsiCheckStatus]::Fail }

            Add-BsiResult -ControlId $controlId -Title $title -Category $entry.category `
                -Mode ([BsiCheckMode]::Local) -Status $status `
                -Severity ([BsiSeverity]::Medium) `
                -Details "$($lc.description) (matched $matched / required $required, max $maxAllowed)" `
                -CheckFunction 'Invoke-BsiCompliance'
        }
    }

    # ===================== REMOTE MODE =====================
    if ($Remote) {
        # Load config
        $configFullPath = Resolve-Path -Path $ConfigPath -ErrorAction SilentlyContinue
        if (-not $configFullPath -or -not (Test-Path -LiteralPath $configFullPath)) {
            throw "Config file not found: $ConfigPath"
        }
        . $configFullPath

        if (-not $rgName) { throw "`$rgName is not defined in config.ps1" }

        # Verify Azure CLI
        Test-AzCliReady | Out-Null
        if ($SubscriptionId) {
            $script:moduleSubscriptionId = $SubscriptionId
        }

        # --- Network checks ---
        $nsgResults = Test-NsgDenyInbound -ResourceGroup $rgName -NsgNames $NsgNames -Ports $DenyPorts -SubscriptionId $SubscriptionId
        foreach ($r in $nsgResults) { Add-BsiResultObject -Result $r }

        # VNet subnets
        $vnetSubnetConfig = @{}
        if ($subnetWeb) { $vnetSubnetConfig[$subnetWeb] = @{ AddressPrefix = $subnetWebAddress; NsgName = 'NSG-Web' } }
        if ($subnetApp) { $vnetSubnetConfig[$subnetApp] = @{ AddressPrefix = $subnetAppAddress; NsgName = 'NSG-App' } }
        if ($subnetDb)  { $vnetSubnetConfig[$subnetDb]  = @{ AddressPrefix = $subnetDBAddress; NsgName = 'NSG-DB' } }

        $vnetResults = Test-VNetSubnets -ResourceGroup $rgName -VNetName $vnetSpoke1Name -ExpectedSubnets $vnetSubnetConfig -SubscriptionId $SubscriptionId
        foreach ($r in $vnetResults) { Add-BsiResultObject -Result $r }

        # VNet Peering
        if ($vnetHubName) {
            $peerResults = Test-VNetPeering -ResourceGroup $rgName -HubVNetName $vnetHubName -SpokeVNetName $vnetSpoke1Name -SubscriptionId $SubscriptionId
            foreach ($r in $peerResults) { Add-BsiResultObject -Result $r }
        }

        # No Public IP on backend
        $backendTiers = @('db', 'app')
        $noPubIpResults = Test-NoPublicIp -ResourceGroup $rgName -BackendTiers $backendTiers -WebTier 'web' -SubscriptionId $SubscriptionId
        foreach ($r in $noPubIpResults) { Add-BsiResultObject -Result $r }

        # Bastion
        if ($bastionHostName) {
            $bastionResults = Test-BastionExists -ResourceGroup $rgName -BastionHostName $bastionHostName -SubscriptionId $SubscriptionId
            foreach ($r in $bastionResults) { Add-BsiResultObject -Result $r }
        }

        # NAT Gateway
        $natResults = Test-NatGateway -ResourceGroup $rgName -NatGatewayName 'nat-gateway-prod' -SubscriptionId $SubscriptionId
        foreach ($r in $natResults) { Add-BsiResultObject -Result $r }

        # --- VM checks ---
        $vmNames = @('VM-web', 'VM-app', 'VM-db')
        $diskResults = Test-DiskEncryption -ResourceGroup $rgName -VmNames $vmNames -SubscriptionId $SubscriptionId
        foreach ($r in $diskResults) { Add-BsiResultObject -Result $r }

        $tlsResults = Test-TlsVersion -ResourceGroup $rgName -StorageAccountName $storageaccountName -SubscriptionId $SubscriptionId
        foreach ($r in $tlsResults) { Add-BsiResultObject -Result $r }

        $imgResults = Test-VmImageVersion -ResourceGroup $rgName -VmNames $vmNames -SubscriptionId $SubscriptionId
        foreach ($r in $imgResults) { Add-BsiResultObject -Result $r }

        # --- Identity checks ---
        $idResults = Test-ManagedIdentity -ResourceGroup $rgName -VmNames $vmNames -SubscriptionId $SubscriptionId
        foreach ($r in $idResults) { Add-BsiResultObject -Result $r }

        $kvResults = Test-KeyVaultConfig -ResourceGroup $rgName -KeyVaultName $keyVaultName -SecretName $secretName -SubscriptionId $SubscriptionId
        foreach ($r in $kvResults) { Add-BsiResultObject -Result $r }

        $rbacResults = Test-RbacRoles -ResourceGroup $rgName -KeyVaultName $keyVaultName -SubscriptionId $SubscriptionId
        foreach ($r in $rbacResults) { Add-BsiResultObject -Result $r }

        # --- Backup checks ---
        $backupResults = Test-AzureBackup -ResourceGroup $rgName -VaultName $recoveryServicesVaultName -VmNames $vmNames -SubscriptionId $SubscriptionId
        foreach ($r in $backupResults) { Add-BsiResultObject -Result $r }

        $rsvResults = Test-RecoveryServicesVault -ResourceGroup $rgName -VaultName $recoveryServicesVaultName -SubscriptionId $SubscriptionId
        foreach ($r in $rsvResults) { Add-BsiResultObject -Result $r }

        # --- Monitoring checks ---
        if ($location) {
            $nwResults = Test-NetworkWatcher -Location $location -SubscriptionId $SubscriptionId
            foreach ($r in $nwResults) { Add-BsiResultObject -Result $r }

            $flowResults = Test-FlowLogs -Location $location -NsgNames $NsgNames -SubscriptionId $SubscriptionId
            foreach ($r in $flowResults) { Add-BsiResultObject -Result $r }
        }

        $diagResults = Test-DiagnosticSettings -ResourceGroup $rgName -ResourceName $keyVaultName -SubscriptionId $SubscriptionId
        foreach ($r in $diagResults) { Add-BsiResultObject -Result $r }

        # --- Policy checks ---
        $polResults = Test-AzurePolicy -SubscriptionId $SubscriptionId
        foreach ($r in $polResults) { Add-BsiResultObject -Result $r }
    }

    # --- Get results and export ---
    $results = @($script:BsiResults)
    $summary = Get-BsiSummary

    # Export
    $formats = $OutputFormat -split ',' | ForEach-Object { $_.Trim() }
    foreach ($fmt in $formats) {
        $fmtLower = $fmt.ToLower()
        $basePath = if ($OutputPath) { $OutputPath } else { ".\bsi-compliance" }

        switch ($fmtLower) {
            'console'  { Export-Console -Results $results }
            'json'     { Export-Json -Results $results -OutputPath "$basePath.json" }
            'sarif'    { Export-Sarif -Results $results -OutputPath "$basePath.sarif" }
            'junitxml' { Export-JUnitXml -Results $results -OutputPath "$basePath.junit.xml" }
            'html'     { Export-Html -Results $results -OutputPath "$basePath.html" }
        }
    }

    # Return summary for pipeline use
    $exitCode = [Math]::Min($summary.Failed, 255)
    return $exitCode
}