modules/Invoke-DefenderForCloud.ps1

#requires -Version 7.0
<#
.SYNOPSIS
    Wrapper for Microsoft Defender for Cloud — per-subscription Secure Score + non-healthy assessments.
 
.DESCRIPTION
    Queries two REST endpoints under Microsoft.Security:
      - /providers/Microsoft.Security/secureScores/ascScore (current/max/percentage)
      - /providers/Microsoft.Security/assessments (paged, filtered to non-healthy)
 
    Emits a v1 tool-result shape that the Normalize-DefenderForCloud normalizer downstream
    converts into v2 FindingRows. The Secure Score lands on the Subscription entity;
    each non-healthy assessment lands on its target AzureResource so the EntityStore folds
    the Defender recommendation next to existing azqr/PSRule findings on the same resource.
 
    Uses Invoke-WithRetry for transient 429/503/timeout handling. Gracefully skips when
    Defender for Cloud is not enabled on the subscription (404/Conflict on secureScores).
 
.PARAMETER SubscriptionId
    Azure subscription ID (GUID). Required.
 
.PARAMETER OutputPath
    Optional directory for raw API JSON (for audit).
#>

[CmdletBinding()]
param (
    [Parameter(Mandatory)] [string] $SubscriptionId,
    [string] $OutputPath
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$retryPath = Join-Path $PSScriptRoot 'shared' 'Retry.ps1'
if (Test-Path $retryPath) { . $retryPath }
if (-not (Get-Command Invoke-WithRetry -ErrorAction SilentlyContinue)) {
    function Invoke-WithRetry { param([scriptblock]$ScriptBlock, [int]$MaxAttempts = 3) & $ScriptBlock }
}

$sanitizePath = Join-Path $PSScriptRoot 'shared' 'Sanitize.ps1'
if (Test-Path $sanitizePath) { . $sanitizePath }
if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) {
    function Remove-Credentials { param([string]$Text) return $Text }
}

$errorsPath = Join-Path $PSScriptRoot 'shared' 'Errors.ps1'
if (Test-Path $errorsPath) { . $errorsPath }

$envelopePath = Join-Path $PSScriptRoot 'shared' 'New-WrapperEnvelope.ps1'
if (Test-Path $envelopePath) { . $envelopePath }
if (-not (Get-Command New-WrapperEnvelope -ErrorAction SilentlyContinue)) { function New-WrapperEnvelope { param([string]$Source,[string]$Status='Failed',[string]$Message='',[object[]]$FindingErrors=@()) return [PSCustomObject]@{ Source=$Source; SchemaVersion='1.0'; Status=$Status; Message=$Message; Findings=@(); Errors=@($FindingErrors) } } }
if (-not (Get-Command New-FindingError -ErrorAction SilentlyContinue)) {
    function New-FindingError { param([string]$Source,[string]$Category,[string]$Reason,[string]$Remediation,[string]$Details) return [pscustomobject]@{ Source=$Source; Category=$Category; Reason=$Reason; Remediation=$Remediation; Details=$Details } }
}
if (-not (Get-Command Format-FindingErrorMessage -ErrorAction SilentlyContinue)) {
    function Format-FindingErrorMessage {
        param([Parameter(Mandatory)]$FindingError)
        $line = "[{0}] {1}: {2}" -f $FindingError.Source, $FindingError.Category, $FindingError.Reason
        if ($FindingError.Remediation) { $line += " Action: $($FindingError.Remediation)" }
        return $line
    }
}

$toolVersion = 'microsoft.security/rest-2020-01-01+2022-01-01-preview'

function ConvertTo-StringArray {
    param (
        [AllowNull()]
        [object] $Value
    )
    if ($null -eq $Value) { return @() }
    $items = [System.Collections.Generic.List[string]]::new()
    foreach ($item in @($Value)) {
        if ($null -eq $item) { continue }
        if ($item -is [string]) {
            foreach ($part in ($item -split '[,;]')) {
                $v = $part.Trim()
                if ($v) { $items.Add($v) | Out-Null }
            }
            continue
        }
        $s = [string]$item
        if ($s) { $items.Add($s.Trim()) | Out-Null }
    }
    return @($items | Sort-Object -Unique)
}

function Get-FrameworksFromObject {
    param (
        [AllowNull()]
        [object] $InputObject
    )
    if ($null -eq $InputObject) { return @() }

    try {
        $json = $InputObject | ConvertTo-Json -Depth 30 -Compress
    } catch {
        $json = [string]$InputObject
    }
    if (-not $json) { return @() }

    $out = [System.Collections.Generic.List[hashtable]]::new()
    $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

    $kinds = @(
        @{ Kind = 'MCSB'; Pattern = '(?i)\b(mcsb|microsoft cloud security benchmark)\b' },
        @{ Kind = 'ISO27001'; Pattern = '(?i)\biso[\s-]?27001\b' },
        @{ Kind = 'PCI'; Pattern = '(?i)\bpci(\s*dss)?\b' },
        @{ Kind = 'CIS'; Pattern = '(?i)\bcis\b' },
        @{ Kind = 'NIST'; Pattern = '(?i)\bnist\b' },
        @{ Kind = 'SOC2'; Pattern = '(?i)\bsoc[\s-]?2\b' }
    )

    foreach ($k in $kinds) {
        if ($json -match $k.Pattern) {
            $token = "$($k.Kind)|"
            if ($seen.Add($token)) {
                $out.Add(@{ kind = $k.Kind; controlId = '' }) | Out-Null
            }
        }
    }

    foreach ($m in [regex]::Matches($json, '(?i)\bMCSB[-\s:_]*([A-Za-z0-9\.\-]+)\b')) {
        $cid = [string]$m.Groups[1].Value
        if (-not $cid) { continue }
        $token = "MCSB|$cid"
        if ($seen.Add($token)) {
            $out.Add(@{ kind = 'MCSB'; controlId = $cid }) | Out-Null
        }
    }

    return @($out)
}

function Get-UriStringsFromObject {
    param (
        [AllowNull()]
        [object] $InputObject
    )
    if ($null -eq $InputObject) { return @() }

    try {
        $json = $InputObject | ConvertTo-Json -Depth 30 -Compress
    } catch {
        $json = [string]$InputObject
    }
    if (-not $json) { return @() }

    $matches = [regex]::Matches($json, '(?i)https://[^"''\s\\]+')
    $uris = [System.Collections.Generic.List[string]]::new()
    foreach ($m in $matches) {
        $u = [string]$m.Value
        if ($u) { $uris.Add($u) | Out-Null }
    }
    return @($uris | Sort-Object -Unique)
}

$result = [ordered]@{
    SchemaVersion = '1.0'
    Source        = 'defender-for-cloud'
    Status        = 'Success'
    Message       = ''
    Findings      = @()
    Errors        = @()
    Subscription  = $SubscriptionId
    Timestamp     = (Get-Date).ToUniversalTime().ToString('o')
}

if (-not (Get-Module -ListAvailable -Name Az.Accounts)) {
    $result.Status  = 'Skipped'
    $result.Message = 'Az.Accounts module not installed. Run: Install-Module Az.Accounts -Scope CurrentUser'
    return [pscustomobject]$result
}
Import-Module Az.Accounts -ErrorAction SilentlyContinue

try {
    $ctx = Get-AzContext -ErrorAction Stop
    if (-not $ctx) {
        throw (Format-FindingErrorMessage (New-FindingError -Source 'wrapper:defender-for-cloud' -Category 'AuthenticationFailed' -Reason 'No Az context.' -Remediation 'Run Connect-AzAccount and select the target subscription context.'))
    }
} catch {
    $result.Status  = 'Skipped'
    $result.Message = 'Not signed in. Run Connect-AzAccount first.'
    return [pscustomobject]$result
}

$findings = [System.Collections.Generic.List[object]]::new()

# --- 1. Secure Score (subscription-level roll-up) ---
$scoreUri = "https://management.azure.com/subscriptions/$SubscriptionId/providers/Microsoft.Security/secureScores/ascScore?api-version=2020-01-01"
try {
    $scoreResp = Invoke-WithRetry -MaxAttempts 3 -ScriptBlock {
        Invoke-AzRestMethod -Method GET -Uri $scoreUri -ErrorAction Stop
    }
    if ($scoreResp -and $scoreResp.StatusCode -in 404, 409) {
        $result.Status  = 'Skipped'
        $result.Message = "Defender for Cloud not enabled on subscription (HTTP $($scoreResp.StatusCode))."
        return [pscustomobject]$result
    }
    if (-not $scoreResp -or $scoreResp.StatusCode -ge 400) {
        throw (Format-FindingErrorMessage (New-FindingError -Source 'wrapper:defender-for-cloud' -Category 'UnexpectedFailure' -Reason "Secure Score API returned status $($scoreResp.StatusCode)." -Remediation 'Verify Defender for Cloud API access and rerun the scan.' -Details ([string]$scoreResp.Content)))
    }
    $scoreBody = $scoreResp.Content | ConvertFrom-Json -Depth 20
    $current = [double]$scoreBody.properties.score.current
    $max     = [double]$scoreBody.properties.score.max
    $pct     = if ($max -gt 0) { [math]::Round(100.0 * $current / $max, 1) } else { 0.0 }

    $findings.Add([pscustomobject]@{
        Id           = "defender/securescore/$SubscriptionId"
        Source       = 'defender-for-cloud'
        Category     = 'SecurityPosture'
        Severity     = 'Info'
        Compliant    = $true
        Title        = "Defender Secure Score: $current / $max ($pct%)"
        Detail       = "Secure Score for subscription $SubscriptionId. Current=$current, Max=$max, Percentage=$pct%."
        ResourceId   = "/subscriptions/$SubscriptionId"
        ResourceType = 'Microsoft.Resources/subscriptions'
        ScoreCurrent = $current
        ScoreMax     = $max
        ScorePercent = $pct
        Pillar       = 'Security'
        ToolVersion  = $toolVersion
        LearnMoreUrl = 'https://learn.microsoft.com/azure/defender-for-cloud/secure-score-security-controls'
    }) | Out-Null
} catch {
    $result.Status  = 'Failed'
    $result.Message = "Secure Score query failed: $(Remove-Credentials -Text ([string]$_.Exception.Message))"
    return [pscustomobject]$result
}

# --- 2. Assessments (resource-level recommendations) ---
$assessUri = "https://management.azure.com/subscriptions/$SubscriptionId/providers/Microsoft.Security/assessments?api-version=2020-01-01"
$nextLink  = $assessUri
$pageCount = 0
$maxPages  = 20
$nonHealthy = 0
$alertCount = 0

try {
    while ($nextLink -and $pageCount -lt $maxPages) {
        $pageCount++
        $resp = Invoke-WithRetry -MaxAttempts 3 -ScriptBlock {
            Invoke-AzRestMethod -Method GET -Uri $nextLink -ErrorAction Stop
        }
        if (-not $resp -or $resp.StatusCode -ge 400) {
            if ($resp -and $resp.StatusCode -in 404) { break }
            throw (Format-FindingErrorMessage (New-FindingError -Source 'wrapper:defender-for-cloud' -Category 'UnexpectedFailure' -Reason "Assessments API returned status $($resp.StatusCode)." -Remediation 'Verify Microsoft.Security assessment API access and rerun the scan.' -Details ([string]$resp.Content)))
        }
        $body = $resp.Content | ConvertFrom-Json -Depth 20
        $items = @()
        try { $items = @($body.value) } catch { $items = @() }
        if ($items.Count -gt 0) {
            foreach ($a in $items) {
                $statusCode = ''
                try { $statusCode = [string]$a.properties.status.code } catch {}
                if ($statusCode -ne 'Unhealthy') { continue }
                $nonHealthy++

                $rid = ''
                try { $rid = [string]$a.properties.resourceDetails.id } catch {}
                if (-not $rid) { $rid = "/subscriptions/$SubscriptionId" }

                $displayName = ''
                $sev         = 'Medium'
                $description = ''
                $remediation = ''
                try { $displayName = [string]$a.properties.displayName } catch {}
                try { $sev         = [string]$a.properties.metadata.severity } catch {}
                try { $description = [string]$a.properties.metadata.description } catch {}
                try { $remediation = [string]$a.properties.metadata.remediationDescription } catch {}
                if (-not $sev) { $sev = 'Medium' }

                $deepLink = ''
                try { $deepLink = [string]$a.properties.links.azurePortal } catch {}
                if (-not $deepLink) {
                    $deepLink = "https://portal.azure.com/#view/Microsoft_Azure_Security/RecommendationsBlade/assessmentKey/$([uri]::EscapeDataString([string]$a.name))"
                }

                $metadata = $null
                $additionalData = $null
                $regulatoryStandards = $null
                try { $metadata = $a.properties.metadata } catch {}
                try { $additionalData = $a.properties.additionalData } catch {}
                try { $regulatoryStandards = $additionalData.regulatoryComplianceStandards } catch {}

                $frameworks = Get-FrameworksFromObject -InputObject @($metadata, $additionalData, $regulatoryStandards)
                $evidenceUris = @($deepLink) + (Get-UriStringsFromObject -InputObject $additionalData)
                $evidenceUris = @($evidenceUris | Where-Object { $_ } | Sort-Object -Unique)

                $findings.Add([pscustomobject]@{
                    Id           = "defender/assessment/$($a.name)/$rid"
                    Source       = 'defender-for-cloud'
                    Category     = 'SecurityPosture'
                    Severity     = $sev
                    Compliant    = $false
                    Title        = $displayName
                    Detail       = $description
                    Remediation  = $remediation
                    ResourceId   = $rid
                    AssessmentId = [string]$a.name
                    RuleId       = [string]$a.name
                    Pillar       = 'Security'
                    Frameworks   = @($frameworks)
                    DeepLinkUrl  = $deepLink
                    EvidenceUris = @($evidenceUris)
                    ToolVersion  = $toolVersion
                    LearnMoreUrl = 'https://learn.microsoft.com/azure/defender-for-cloud/review-security-recommendations'
                }) | Out-Null
            }
        }
        $nextLink = $null
        try { $nextLink = [string]$body.nextLink } catch {}
        if (-not $nextLink) { $nextLink = $null }
    }
} catch {
    $result.Status  = 'Failed'
    $result.Message = "Assessments query failed: $(Remove-Credentials -Text ([string]$_.Exception.Message))"
    return [pscustomobject]$result
}

$alertsUri = "https://management.azure.com/subscriptions/$SubscriptionId/providers/Microsoft.Security/alerts?api-version=2022-01-01-preview"
$alertsNext = $alertsUri
$alertsPages = 0
try {
    while ($alertsNext -and $alertsPages -lt $maxPages) {
        $alertsPages++
        $resp = Invoke-WithRetry -MaxAttempts 3 -ScriptBlock {
            Invoke-AzRestMethod -Method GET -Uri $alertsNext -ErrorAction Stop
        }
        if (-not $resp -or $resp.StatusCode -ge 400) {
            if ($resp -and $resp.StatusCode -in 404) { break }
            throw (Format-FindingErrorMessage (New-FindingError -Source 'wrapper:defender-for-cloud' -Category 'UnexpectedFailure' -Reason "Alerts API returned status $($resp.StatusCode)." -Remediation 'Verify Microsoft.Security alerts API access and rerun the scan.' -Details ([string]$resp.Content)))
        }

        $body = $resp.Content | ConvertFrom-Json -Depth 30
        $alertItems = @()
        try { $alertItems = @($body.value) } catch { $alertItems = @() }
        foreach ($a in $alertItems) {
            $status = ''
            try { $status = [string]$a.properties.status } catch {}
            if ($status -match '^(?i)(dismissed|resolved)$') { continue }

            $rid = ''
            try { $rid = [string]$a.properties.resourceIdentifiers.AzureResourceId } catch {}
            if (-not $rid) {
                try { $rid = [string]$a.properties.compromisedEntity } catch {}
            }
            if (-not $rid -or $rid -notmatch '^/subscriptions/') { $rid = "/subscriptions/$SubscriptionId" }

            $sev = 'Medium'
            try { $sev = [string]$a.properties.severity } catch {}
            if (-not $sev) { $sev = 'Medium' }

            $title = ''
            try { $title = [string]$a.properties.alertDisplayName } catch {}
            if (-not $title) { try { $title = [string]$a.properties.displayName } catch {} }
            if (-not $title) { $title = 'Defender alert' }

            $detail = ''
            try { $detail = [string]$a.properties.description } catch {}
            if (-not $detail) { try { $detail = [string]$a.properties.extendedProperties.description } catch {} }

            $alertArmId = ''
            try { $alertArmId = [string]$a.id } catch {}
            $deepLink = ''
            if ($alertArmId) {
                $deepLink = "https://portal.azure.com/#view/Microsoft_Azure_Security/SecurityMenuBlade/~/0/id/$([uri]::EscapeDataString($alertArmId))"
            }

            $mitreTactics = @()
            $mitreTechniques = @()
            try { $mitreTactics = ConvertTo-StringArray -Value $a.properties.tactics } catch {}
            if (-not $mitreTactics) { try { $mitreTactics = ConvertTo-StringArray -Value $a.properties.extendedProperties.Tactics } catch {} }
            try { $mitreTechniques = ConvertTo-StringArray -Value $a.properties.techniques } catch {}
            if (-not $mitreTechniques) { try { $mitreTechniques = ConvertTo-StringArray -Value $a.properties.extendedProperties.Techniques } catch {} }

            $extendedProperties = $null
            try { $extendedProperties = $a.properties.extendedProperties } catch {}
            $frameworks = Get-FrameworksFromObject -InputObject @($a.properties, $extendedProperties)
            $evidenceUris = @(Get-UriStringsFromObject -InputObject @($a.properties, $extendedProperties))
            if ($deepLink) { $evidenceUris = @($evidenceUris + @($deepLink) | Sort-Object -Unique) }

            $alertName = ''
            try { $alertName = [string]$a.name } catch {}
            if (-not $alertName) { $alertName = [guid]::NewGuid().ToString() }

            $findings.Add([pscustomobject]@{
                    Id              = "defender/alert/$alertName/$rid"
                    Source          = 'defender-for-cloud'
                    Category        = 'ThreatDetection'
                    Severity        = $sev
                    Compliant       = $false
                    Title           = $title
                    Detail          = $detail
                    Remediation     = ''
                    ResourceId      = $rid
                    AlertId         = $alertName
                    RuleId          = $alertName
                    Pillar          = 'Security'
                    Frameworks      = @($frameworks)
                    DeepLinkUrl     = $deepLink
                    EvidenceUris    = @($evidenceUris)
                    MitreTactics    = @($mitreTactics)
                    MitreTechniques = @($mitreTechniques)
                    ToolVersion     = $toolVersion
                    LearnMoreUrl    = 'https://learn.microsoft.com/azure/defender-for-cloud/alerts-overview'
                }) | Out-Null
            $alertCount++
        }
        $alertsNext = $null
        try { $alertsNext = [string]$body.nextLink } catch {}
        if (-not $alertsNext) { $alertsNext = $null }
    }
} catch {
    $result.Status  = 'Failed'
    $result.Message = "Alerts query failed: $(Remove-Credentials -Text ([string]$_.Exception.Message))"
    return [pscustomobject]$result
}

$result.Findings = @($findings)
$result.Message  = "Emitted Secure Score + $nonHealthy non-healthy recommendations + $alertCount active alerts."

if ($OutputPath) {
    try {
        if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null }
        $raw = Join-Path $OutputPath "defender-$SubscriptionId-$(Get-Date -Format yyyyMMddHHmmss).json"
        Set-Content -Path $raw -Value (Remove-Credentials ($result | ConvertTo-Json -Depth 20)) -Encoding utf8
    } catch {
        Write-Warning "Failed to write raw Defender JSON: $(Remove-Credentials -Text ([string]$_.Exception.Message))"
    }
}

return [pscustomobject]$result