Analyze/tests/13-AI.ps1

#AI services: Azure AI Services / Azure OpenAI (Foundry) accounts and Azure Machine Learning workspaces

$aiType = @('Microsoft.CognitiveServices/accounts')
$mlType = @('Microsoft.MachineLearningServices/workspaces')

Add-AzTest @{
    Id            = 'AZ-AI-001'
    Title         = 'Azure AI Services accounts disable key access'
    Category      = 'Identity management'
    Service       = 'Azure AI Services'
    Severity      = 'High'
    Description   = 'Checks disableLocalAuth on Azure AI Services, Azure OpenAI and Foundry accounts.'
    Rationale     = 'API keys are shared secrets that end up in code and prompts tooling; anyone holding a key can use the models and data connections at your cost and under your identity, without RBAC or Conditional Access.'
    Remediation   = 'Move clients to Entra ID (Cognitive Services OpenAI User and similar roles with managed identities) and disable local authentication (az cognitiveservices account update --custom-domain ... --api-properties disableLocalAuth=true, or set properties.disableLocalAuth).'
    References    = @('https://learn.microsoft.com/azure/ai-services/disable-local-auth')
    Frameworks    = @{ MCSB = @('IM-1', 'IM-3'); WAF = 'SE:05'; ALZ = @('Enforce-GR-CogServ0', 'Enforce-GR-OpenAI0') }
    Policy        = @{ '71ef260a-8f18-47b7-abcb-62d0673d94dc' = 'Azure AI Services resources should have key access disabled (disable local authentication)' }
    ResourceTypes = $aiType
    Evaluate      = {
        param($Record)
        $value = $Record.resource.properties.disableLocalAuth
        if ($value -eq $true) { return New-Pass 'Key access disabled' ([ordered]@{ disableLocalAuth = $true; kind = $Record.resource.kind }) }
        New-Fail 'Key access enabled' ([ordered]@{ disableLocalAuth = $value; kind = $Record.resource.kind })
    }
}

Add-AzTest @{
    Id            = 'AZ-AI-002'
    Title         = 'Azure AI Services accounts restrict network access'
    Category      = 'Network security'
    Service       = 'Azure AI Services'
    Severity      = 'Medium'
    Description   = 'Checks that public network access is disabled or the network ACL denies access by default.'
    Rationale     = 'An open endpoint lets a leaked key or token be used from anywhere and exposes the models and any grounded data to the Internet.'
    Remediation   = 'Use private endpoints and disable public network access, or set the default network action to Deny with specific IP and virtual network rules.'
    References    = @('https://learn.microsoft.com/azure/ai-services/cognitive-services-virtual-networks')
    Frameworks    = @{ MCSB = 'NS-2'; WAF = 'SE:06'; ALZ = @('Enforce-GR-CogServ0', 'Enforce-GR-OpenAI0', 'Deny-Public-Endpoints') }
    Policy        = @{ '037eea7a-bd0a-46c5-9a66-03aea78705d3' = 'Azure AI Services resources should restrict network access'; 'd6759c02-b87f-42b7-892e-71b3f471d782' = 'Azure AI Services resources should use Azure Private Link' }
    ResourceTypes = $aiType
    Evaluate      = {
        param($Record)
        $p = $Record.resource.properties
        $evidence = [ordered]@{ publicNetworkAccess = $p.publicNetworkAccess; defaultAction = $p.networkAcls.defaultAction }
        if ($p.publicNetworkAccess -eq 'Disabled') { return New-Pass 'Public network access disabled' $evidence }
        if ($p.networkAcls.defaultAction -eq 'Deny') { return New-Pass 'Default network action Deny' $evidence }
        New-Fail 'Open to all networks' $evidence
    }
}

Add-AzTest @{
    Id            = 'AZ-AI-003'
    Title         = 'Azure AI Services accounts restrict outbound access'
    Category      = 'AI security'
    Service       = 'Azure AI Services'
    Severity      = 'Low'
    Description   = 'Checks restrictOutboundNetworkAccess with an allowed FQDN list, which limits the endpoints the service can reach (for example for grounding, tools and data sources).'
    Rationale     = 'Unrestricted egress lets prompt injection or a misconfigured data connection send data to arbitrary destinations.'
    Remediation   = 'Enable outbound restrictions and list only the required FQDNs (properties.restrictOutboundNetworkAccess and allowedFqdnList).'
    References    = @('https://learn.microsoft.com/azure/ai-services/cognitive-services-data-loss-prevention')
    Frameworks    = @{ MCSB = @('AI-4', 'DP-2'); WAF = 'SE:06' }
    ResourceTypes = $aiType
    Evaluate      = {
        param($Record)
        $p = $Record.resource.properties
        $evidence = [ordered]@{ restrictOutboundNetworkAccess = [bool]$p.restrictOutboundNetworkAccess; allowedFqdns = @($p.allowedFqdnList).Count }
        if ($p.restrictOutboundNetworkAccess) { return New-Pass "Outbound access limited to $(@($p.allowedFqdnList).Count) FQDN(s)" $evidence }
        New-Fail 'Outbound access unrestricted' $evidence
    }
}

Add-AzTest @{
    Id          = 'AZ-AI-004'
    Version     = 2
    Title       = 'AI model deployments use content filtering and prompt shields'
    Category    = 'AI security'
    Service     = 'Azure AI Services'
    Severity    = 'High'
    Description = 'Checks each model deployment for a content filter (RAI) policy. Default Microsoft policies pass; custom policies fail when a harm category filter or the jailbreak (prompt shield) filter is disabled or not blocking.'
    Rationale   = 'Content filters and prompt shields block harmful output and jailbreak or prompt injection attempts; disabling them removes a primary AI safety layer.'
    Remediation = 'Assign Microsoft.DefaultV2 or a custom content filter with all harm categories and jailbreak detection enabled in blocking mode. Filter modifications require an approved exception from Microsoft and should be documented.'
    References  = @('https://learn.microsoft.com/azure/ai-foundry/openai/concepts/content-filter')
    Frameworks  = @{ MCSB = @('AI-2', 'AI-3') }
    Policy      = @{ 'af253d37-136a-42f8-a1fc-30010c083d41' = '[Preview]: Cognitive Services Deployments should only use allowed completion content filtering'; 'f3a9c2e0-7b4d-4d8f-9c3a-2e1f6b9a8d4e' = '[Preview]: Cognitive Services Deployments should only use allowed prompt content filtering' }
    Run         = {
        foreach ($account in (Get-AzResourceRecords -Type 'Microsoft.CognitiveServices/accounts')) {
            if (-not (Test-ChildCollected $account 'deployments')) { New-Finding -Record $account -Result (New-Unknown 'Model deployments could not be listed'); continue }
            $policiesCollected = Test-ChildCollected $account 'raiPolicies'
            $policies = @{}
            foreach ($policy in @(Get-Child $account 'raiPolicies' | Where-Object { $_ })) { $policies[$policy.name.ToLowerInvariant()] = $policy }
            foreach ($deployment in @(Get-Child $account 'deployments' | Where-Object { $_ })) {
                $name = $deployment.properties.raiPolicyName
                $evidence = [ordered]@{ account = $account.resource.name; model = "$($deployment.properties.model.name) $($deployment.properties.model.version)"; raiPolicyName = $name }
                $policy = if ($name) { $policies[$name.ToLowerInvariant()] } else { $null }
                if (-not $name -or $name -like 'Microsoft.*') {
                    $result = New-Pass "Default content filter ($(if ($name) { $name } else { 'Microsoft.Default' }))" $evidence
                } elseif (-not $policy) {
                    #a custom policy name whose definition was not read says nothing about how strict it is
                    $result = if ($policiesCollected) { New-Unknown "Custom content filter '$name' is not among the account's policies" $evidence } else { New-Unknown "Custom content filter '$name' could not be read" $evidence }
                } else {
                    $weak = @($policy.properties.contentFilters | Where-Object { $_ -and ($_.enabled -eq $false -or ($_.PSObject.Properties.Name -contains 'blocking' -and $_.blocking -eq $false)) } | ForEach-Object { "$($_.source) $($_.name)" } | Sort-Object)
                    $jailbreak = @($policy.properties.contentFilters | Where-Object { $_ -and $_.name -match 'jailbreak' -and $_.enabled -and $_.blocking -ne $false })
                    $evidence.weakenedFilters = $weak
                    $evidence.jailbreakBlocking = [bool]$jailbreak
                    $result = if ($weak -or -not $jailbreak) { New-Fail "Custom filter $name weakens protection$(if ($weak) { ": $($weak -join ', ')" })$(if (-not $jailbreak) { ' (no blocking prompt shield)' })" $evidence } else { New-Pass "Custom filter $name keeps all filters blocking" $evidence }
                }
                New-Finding -ResourceId $deployment.id -ResourceType 'Microsoft.CognitiveServices/accounts/deployments' -ResourceName "$($account.resource.name)/$($deployment.name)" -Result $result
            }
        }
    }
}

Add-AzTest @{
    Id            = 'AZ-AI-005'
    Title         = 'Machine Learning and Foundry hub workspaces disable public network access'
    Category      = 'Network security'
    Service       = 'Azure Machine Learning'
    Severity      = 'Medium'
    Description   = 'Checks public network access of Azure Machine Learning and Foundry hub workspaces.'
    Rationale     = 'Workspaces hold data connections, credentials for datastores and model artifacts; a public endpoint exposes them to token theft from any network.'
    Remediation   = 'Use private endpoints and set public network access to Disabled (az ml workspace update --public-network-access Disabled ...).'
    References    = @('https://learn.microsoft.com/azure/machine-learning/how-to-configure-private-link')
    Frameworks    = @{ MCSB = 'NS-2'; WAF = 'SE:06'; ALZ = @('Enforce-GR-MachLearn0', 'Deny-Public-Endpoints') }
    Policy        = @{ '438c38d2-3772-465a-a9cc-7a6666a275ce' = 'Azure Machine Learning Workspaces should disable public network access'; '45e05259-1eb5-4f70-9574-baf73e9d219b' = 'Azure Machine Learning workspaces should use private link' }
    ResourceTypes = $mlType
    Evaluate      = {
        param($Record)
        $value = $Record.resource.properties.publicNetworkAccess
        if ($value -eq 'Disabled') { return New-Pass 'Public network access disabled' ([ordered]@{ publicNetworkAccess = $value }) }
        New-Fail 'Public network access enabled' ([ordered]@{ publicNetworkAccess = $value })
    }
}

Add-AzTest @{
    Id            = 'AZ-AI-006'
    Title         = 'Machine Learning managed networks only allow approved outbound traffic'
    Category      = 'Network security'
    Service       = 'Azure Machine Learning'
    Severity      = 'Medium'
    Description   = "Checks that the workspace managed virtual network uses isolation mode 'AllowOnlyApprovedOutbound'."
    Rationale     = 'Without outbound restrictions, compute running untrusted code, notebooks or models can exfiltrate training data and credentials to any Internet destination.'
    Remediation   = "Set the managed network isolation mode to AllowOnlyApprovedOutbound and add outbound rules for required destinations."
    References    = @('https://learn.microsoft.com/azure/machine-learning/how-to-managed-network')
    Frameworks    = @{ MCSB = @('NS-2', 'DP-2'); ALZ = 'Enforce-GR-MachLearn0' }
    Policy        = @{ '6ddb1705-c8cf-450e-aa4b-19ad6703c440' = 'Azure Machine Learning and Ai Studio should use Allow Only Approved Outbound Managed Vnet mode' }
    ResourceTypes = $mlType
    Evaluate      = {
        param($Record)
        $mode = $Record.resource.properties.managedNetwork.isolationMode
        $evidence = [ordered]@{ isolationMode = $mode }
        if ($mode -eq 'AllowOnlyApprovedOutbound') { return New-Pass 'Only approved outbound traffic' $evidence }
        New-Fail "Isolation mode $(if ($mode) { $mode } else { 'Disabled' })" $evidence
    }
}

Add-AzTest @{
    Id          = 'AZ-AI-007'
    Title       = 'Machine Learning compute disables local authentication and public access'
    Category    = 'Identity management'
    Service     = 'Azure Machine Learning'
    Severity    = 'Medium'
    Description = 'Checks Machine Learning compute instances and clusters for local authentication, public SSH access and node public IP addresses.'
    Rationale   = 'Local accounts and public SSH give access to compute that holds workspace credentials, data and managed identity tokens, bypassing Entra ID.'
    Remediation = 'Recreate compute with local authentication disabled, SSH public access disabled and no public IP (in a managed virtual network).'
    References  = @('https://learn.microsoft.com/azure/machine-learning/how-to-secure-training-vnet')
    Frameworks  = @{ MCSB = @('IM-1', 'NS-2'); ALZ = 'Enforce-GR-MachLearn0' }
    Policy      = @{ 'e96a9a5f-07ca-471b-9bc5-6a0f33cbd68f' = 'Azure Machine Learning Computes should have local authentication methods disabled' }
    Run         = {
        foreach ($workspace in (Get-AzResourceRecords -Type 'Microsoft.MachineLearningServices/workspaces')) {
            #without the compute list, "no compute in scope" cannot be distinguished from "compute unknown"
            if (-not (Test-ChildCollected $workspace 'computes')) { New-Finding -Record $workspace -Result (New-Unknown 'Machine Learning compute could not be listed'); continue }
            foreach ($compute in @(Get-Child $workspace 'computes' | Where-Object { $_ -and $_.properties.computeType -in 'ComputeInstance', 'AmlCompute' })) {
                $p = $compute.properties
                $inner = $p.properties
                $problems = @()
                if ($p.disableLocalAuth -ne $true) { $problems += 'local authentication enabled' }
                if ($inner.sshSettings.sshPublicAccess -eq 'Enabled' -or $inner.remoteLoginPortPublicAccess -eq 'Enabled') { $problems += 'public SSH access' }
                if ($inner.enableNodePublicIp -ne $false) { $problems += 'public IP' }
                $evidence = [ordered]@{ workspace = $workspace.resource.name; computeType = $p.computeType; disableLocalAuth = $p.disableLocalAuth; sshPublicAccess = if ($inner.sshSettings) { $inner.sshSettings.sshPublicAccess } else { $inner.remoteLoginPortPublicAccess }; enableNodePublicIp = $inner.enableNodePublicIp }
                $result = if ($problems) { New-Fail ($problems -join ', ') $evidence } else { New-Pass 'Local authentication and public access disabled' $evidence }
                New-Finding -ResourceId $compute.id -ResourceType 'Microsoft.MachineLearningServices/workspaces/computes' -ResourceName "$($workspace.resource.name)/$($compute.name)" -Result $result
            }
        }
    }
}