Analyze/tests/09-AppService.ps1

#App Service: web apps, function apps and their deployment slots

$siteTypes = @('Microsoft.Web/sites', 'Microsoft.Web/sites/slots')

function Get-SiteConfig {
    param($Record)
    $config = Get-Child $Record 'config/web'
    if ($config) { return $config.properties }
    return $null
}

Add-AzTest @{
    Id            = 'AZ-APP-001'
    Title         = 'App Service apps are only accessible over HTTPS'
    Category      = 'Data protection'
    Service       = 'App Service'
    Severity      = 'High'
    Description   = "Checks the 'HTTPS Only' setting of web apps, function apps and slots."
    Rationale     = 'Without HTTPS Only, clients can use plain HTTP, exposing session cookies, tokens and data to interception and manipulation.'
    Remediation   = 'Enable HTTPS Only (az webapp update --https-only true ...).'
    References    = @('https://learn.microsoft.com/azure/app-service/configure-ssl-bindings#enforce-https')
    Frameworks    = @{ MCSB = 'DP-3'; WAF = 'SE:07'; ALZ = @('Enforce-TLS-SSL-Q225', 'Enforce-GR-AppServices0') }
    Defender      = @{ '1b351b29-41ca-6df5-946c-c190a56be5fe' = 'Web Application should only be accessible over HTTPS'; 'cb0acdc6-0846-fd48-debe-9905af151b6d' = 'Function App should only be accessible over HTTPS' }
    Policy        = @{ 'a4af4a39-4135-47fb-b175-47fbdf85311d' = 'App Service apps should only be accessible over HTTPS'; '6d555dd1-86f2-4f1c-8ed7-5abae7c6cbab' = 'Function apps should only be accessible over HTTPS' }
    ResourceTypes = $siteTypes
    Evaluate      = {
        param($Record)
        $value = [bool]$Record.resource.properties.httpsOnly
        if ($value) { return New-Pass 'HTTPS only' ([ordered]@{ httpsOnly = $true }) }
        New-Fail 'HTTP allowed' ([ordered]@{ httpsOnly = $false })
    }
}

Add-AzTest @{
    Id            = 'AZ-APP-002'
    Title         = 'App Service apps require TLS 1.2 or higher'
    Category      = 'Data protection'
    Service       = 'App Service'
    Severity      = 'High'
    Description   = 'Checks the minimum TLS version of the app and of its SCM (Kudu) site.'
    Rationale     = 'TLS 1.0 and 1.1 have known weaknesses; the SCM site handles deployment credentials and code and needs the same protection as the app.'
    Remediation   = 'Set the minimum inbound TLS version and SCM minimum TLS version to 1.2 or 1.3 (az webapp config set --min-tls-version 1.2 ...).'
    References    = @('https://learn.microsoft.com/azure/app-service/overview-tls')
    Frameworks    = @{ MCSB = @('DP-3', 'NS-8'); WAF = 'SE:07'; ALZ = @('Enforce-TLS-SSL-Q225', 'Enforce-GR-AppServices0') }
    Defender      = @{ '2a54c352-7ca4-4bae-ad46-47ecd9595bd2' = 'TLS should be updated to the latest version for web apps'; '15be5f3c-e0a4-c0fa-fbff-8e50339b4b22' = 'TLS should be updated to the latest version for function apps' }
    Policy        = @{ 'f0e6e85b-9b9f-4a4b-b67b-f730d42f1b0b' = 'App Service apps should use the latest TLS version' }
    ResourceTypes = $siteTypes
    Evaluate      = {
        param($Record)
        $config = Get-SiteConfig $Record
        if (-not $config) { return New-Unknown 'Site configuration could not be read' }
        $evidence = [ordered]@{ minTlsVersion = $config.minTlsVersion; scmMinTlsVersion = $config.scmMinTlsVersion }
        $problems = @()
        if (-not (Test-VersionAtLeast $config.minTlsVersion '1.2')) { $problems += "app minimum TLS $($config.minTlsVersion)" }
        if ($config.scmMinTlsVersion -and -not (Test-VersionAtLeast $config.scmMinTlsVersion '1.2')) { $problems += "SCM minimum TLS $($config.scmMinTlsVersion)" }
        if ($problems) { return New-Fail ($problems -join ', ') $evidence }
        New-Pass "Minimum TLS $($config.minTlsVersion)" $evidence
    }
}

Add-AzTest @{
    Id            = 'AZ-APP-003'
    Title         = 'App Service apps disable FTP or require FTPS'
    Category      = 'Data protection'
    Service       = 'App Service'
    Severity      = 'High'
    Description   = 'Checks that the FTP state is Disabled or FtpsOnly.'
    Rationale     = 'Plain FTP sends deployment credentials and code in clear text.'
    Remediation   = 'Set the FTP state to Disabled (preferred) or FtpsOnly (az webapp config set --ftps-state Disabled ...).'
    References    = @('https://learn.microsoft.com/azure/app-service/deploy-ftp#enforce-ftps')
    Frameworks    = @{ MCSB = @('DP-3', 'NS-8'); WAF = 'SE:08'; ALZ = 'Enforce-GR-AppServices0' }
    Defender      = @{ '19beaa2a-a126-b4dd-6d35-617f6cc83fca' = 'FTPS should be required in web apps'; '972a6579-f38f-c0b9-1b4b-a5bbeba3ab5b' = 'FTPS should be required in function apps' }
    Policy        = @{ '4d24b6d4-5e53-4a4f-a7f4-618fa573ee4b' = 'App Service apps should require FTPS only' }
    ResourceTypes = $siteTypes
    Evaluate      = {
        param($Record)
        $config = Get-SiteConfig $Record
        if (-not $config) { return New-Unknown 'Site configuration could not be read' }
        $evidence = [ordered]@{ ftpsState = $config.ftpsState }
        if ($config.ftpsState -in 'Disabled', 'FtpsOnly') { return New-Pass "FTP state $($config.ftpsState)" $evidence }
        New-Fail "FTP state $($config.ftpsState)" $evidence
    }
}

Add-AzTest @{
    Id            = 'AZ-APP-004'
    Title         = 'App Service basic authentication for FTP and SCM is disabled'
    Category      = 'Identity management'
    Service       = 'App Service'
    Severity      = 'Medium'
    Description   = 'Checks the basic publishing credentials policies (ftp and scm) of apps and slots.'
    Rationale     = 'Basic authentication uses publishing profile passwords that are not bound to a user, bypass MFA and Conditional Access, and are easily leaked through publish profiles.'
    Remediation   = "Disable 'SCM Basic Auth Publishing Credentials' and 'FTP Basic Auth Publishing Credentials' and deploy with Entra authenticated methods (GitHub Actions OIDC, az webapp deploy)."
    References    = @('https://learn.microsoft.com/azure/app-service/configure-basic-auth-disable')
    Frameworks    = @{ MCSB = @('IM-1', 'IM-3'); WAF = 'SE:05'; ALZ = 'Enforce-GR-AppServices0' }
    Policy        = @{ '871b205b-57cf-4e1e-a234-492616998bf7' = 'App Service apps should have local authentication methods disabled for FTP deployments'; 'aede300b-d67f-480a-ae26-4b3dfb1a1fdc' = 'App Service apps should have local authentication methods disabled for SCM site deployments' }
    ResourceTypes = $siteTypes
    Evaluate      = {
        param($Record)
        if (-not (Test-ChildCollected $Record 'basicPublishingCredentialsPolicies')) { return New-Unknown 'Basic publishing credential policies could not be read' }
        $allowed = @(Get-Child $Record 'basicPublishingCredentialsPolicies' | Where-Object { $_ -and $_.properties.allow -ne $false } | ForEach-Object name | Sort-Object)
        $evidence = [ordered]@{ basicAuthAllowed = $allowed }
        if ($allowed) { return New-Fail "Basic authentication allowed for $($allowed -join ', ')" $evidence }
        New-Pass 'Basic authentication disabled for FTP and SCM' $evidence
    }
}

Add-AzTest @{
    Id            = 'AZ-APP-005'
    Title         = 'Remote debugging is turned off'
    Category      = 'Posture and vulnerability management'
    Service       = 'App Service'
    Severity      = 'Medium'
    Description   = 'Checks the remote debugging setting of apps and slots.'
    Rationale     = 'Remote debugging opens additional inbound ports and debugging endpoints; it should only be on temporarily during troubleshooting.'
    Remediation   = 'Turn off remote debugging (az webapp config set --remote-debugging-enabled false ...).'
    Frameworks    = @{ MCSB = @('PV-2', 'NS-8'); WAF = 'SE:08'; ALZ = 'Enforce-GR-AppServices0' }
    Defender      = @{ '64b8637e-4e1d-76a9-0fc9-c1e487a97ed8' = 'Remote debugging should be turned off for Web Applications'; '093c685b-56dd-13a3-8ed5-887a001837a2' = 'Remote debugging should be turned off for Function App' }
    Policy        = @{ 'cb510bfd-1cba-4d9f-a230-cb0976f4bb71' = 'App Service apps should have remote debugging turned off'; '0e60b895-3786-45da-8377-9c6b4b6ac5f9' = 'Function apps should have remote debugging turned off' }
    ResourceTypes = $siteTypes
    Evaluate      = {
        param($Record)
        $config = Get-SiteConfig $Record
        if (-not $config) { return New-Unknown 'Site configuration could not be read' }
        $evidence = [ordered]@{ remoteDebuggingEnabled = [bool]$config.remoteDebuggingEnabled; remoteDebuggingVersion = $config.remoteDebuggingVersion }
        if ($config.remoteDebuggingEnabled) { return New-Fail 'Remote debugging enabled' $evidence }
        New-Pass 'Remote debugging off' $evidence
    }
}

Add-AzTest @{
    Id            = 'AZ-APP-006'
    Title         = 'App Service apps use a managed identity'
    Category      = 'Identity management'
    Service       = 'App Service'
    Severity      = 'Low'
    Description   = 'Checks that apps and slots have a system or user assigned managed identity.'
    Rationale     = 'Apps without a managed identity typically hold connection strings, keys or client secrets in their configuration to reach other services.'
    Remediation   = 'Enable a managed identity (az webapp identity assign ...), grant it RBAC roles on the target services and remove stored credentials.'
    References    = @('https://learn.microsoft.com/azure/app-service/overview-managed-identity')
    Frameworks    = @{ MCSB = 'IM-3'; WAF = 'SE:09' }
    Defender      = @{ '4a3d7cd3-f17c-637a-1ffc-614a01dd03cf' = 'Managed identity should be enabled on web apps'; '23aa9cbe-c2fb-6a2f-6c97-885a6d48c4d1' = 'Managed identity should be enabled on function apps' }
    Policy        = @{ '2b9ad585-36bc-4615-b300-fd4435808332' = 'App Service apps should use managed identity' }
    ResourceTypes = $siteTypes
    Evaluate      = {
        param($Record)
        $type = $Record.resource.identity.type
        $evidence = [ordered]@{ identityType = $type }
        if ($type -and $type -ne 'None') { return New-Pass "Managed identity ($type)" $evidence }
        New-Fail 'No managed identity' $evidence
    }
}

Add-AzTest @{
    Id            = 'AZ-APP-007'
    Title         = 'CORS does not allow every origin'
    Category      = 'Posture and vulnerability management'
    Service       = 'App Service'
    Severity      = 'Low'
    Description   = "Checks the CORS allowed origins of apps and slots for the wildcard '*'."
    Rationale     = 'A wildcard CORS policy lets any website call the API from a victim browser and read responses, weakening protections against cross-site data theft.'
    Remediation   = 'Replace * with the specific origins that need access (az webapp cors remove --allowed-origins * ...).'
    References    = @('https://learn.microsoft.com/azure/app-service/app-service-web-tutorial-rest-api#add-cors-functionality')
    Frameworks    = @{ MCSB = 'PV-2'; WAF = 'SE:08' }
    Defender      = @{ 'df4d1739-47f0-60c7-1706-3731fea6ab03' = 'CORS should not allow every resource to access Web Applications'; '7b3d4796-9400-2904-692b-4a5ede7f0a1e' = 'CORS should not allow every resource to access Function Apps' }
    Policy        = @{ '5744710e-cc2f-4ee8-8809-3b11e89f4bc9' = 'App Service apps should not have CORS configured to allow every resource to access your apps' }
    ResourceTypes = $siteTypes
    Evaluate      = {
        param($Record)
        $config = Get-SiteConfig $Record
        if (-not $config) { return New-Unknown 'Site configuration could not be read' }
        $origins = @($config.cors.allowedOrigins | Where-Object { $_ })
        $evidence = [ordered]@{ allowedOrigins = $origins; supportCredentials = [bool]$config.cors.supportCredentials }
        if ($origins -contains '*') { return New-Fail 'CORS allows every origin' $evidence }
        New-Pass $(if ($origins) { "CORS limited to $($origins.Count) origin(s)" } else { 'No CORS origins configured' }) $evidence
    }
}

Add-AzTest @{
    Id            = 'AZ-APP-008'
    Title         = 'App Service apps restrict public network access'
    Category      = 'Network security'
    Service       = 'App Service'
    Severity      = 'Low'
    Description   = 'Checks that apps disable public network access or deny access by default with access restrictions. Internet facing apps should be published through Front Door or Application Gateway with WAF.'
    Rationale     = 'An app that is reachable directly from the Internet bypasses the WAF and any network controls in front of it; internal apps should not be public at all.'
    Remediation   = "Disable public network access and use private endpoints for internal apps; for public apps restrict inbound access to the Front Door or Application Gateway (service tag and X-Azure-FDID header)."
    References    = @('https://learn.microsoft.com/azure/app-service/app-service-ip-restrictions')
    Frameworks    = @{ MCSB = 'NS-2'; WAF = 'SE:06'; ALZ = 'Deny-Public-Endpoints' }
    Policy        = @{ '1b5ef780-c53c-4a64-87f3-bb9c8c8094ba' = 'App Service apps should disable public network access'; '969ac98b-88a8-449f-883c-2e9adb123127' = 'Function apps should disable public network access' }
    ResourceTypes = $siteTypes
    Evaluate      = {
        param($Record)
        $config = Get-SiteConfig $Record
        $access = if ($Record.resource.properties.publicNetworkAccess) { $Record.resource.properties.publicNetworkAccess } else { $config.publicNetworkAccess }
        $restrictions = @($config.ipSecurityRestrictions | Where-Object { $_ -and $_.action -eq 'Allow' -and $_.ipAddress -ne 'Any' })
        $evidence = [ordered]@{ publicNetworkAccess = $access; defaultAction = $config.ipSecurityRestrictionsDefaultAction; allowRules = $restrictions.Count }
        if ($access -eq 'Disabled') { return New-Pass 'Public network access disabled' $evidence }
        if ($config.ipSecurityRestrictionsDefaultAction -eq 'Deny' -or $restrictions) { return New-Pass 'Access restrictions limit inbound traffic' $evidence }
        New-Fail 'Reachable from any network' $evidence
    }
}

Add-AzTest @{
    Id            = 'AZ-APP-009'
    Version       = 2
    Title         = 'HTTP triggered functions do not allow anonymous access'
    Category      = 'Identity management'
    Service       = 'Azure Functions'
    Severity      = 'Low'
    Description   = "Finds HTTP triggered functions with authLevel 'anonymous'."
    Rationale     = 'Anonymous functions accept calls from anyone who knows the URL. Unless the function authenticates callers itself (for example webhook signatures or App Service authentication), it is an open endpoint.'
    Remediation   = "Use authLevel 'function' or enable App Service authentication (Easy Auth) with Entra ID, and validate signatures for webhooks."
    References    = @('https://learn.microsoft.com/azure/azure-functions/security-concepts#authorization-scopes-function-level')
    Frameworks    = @{ MCSB = @('IM-7', 'IM-5'); WAF = 'SE:05' }
    ResourceTypes = $siteTypes
    Filter        = { param($Record) [string]$Record.resource.kind -match 'functionapp' }
    Evaluate      = {
        param($Record)
        if (-not (Test-ChildCollected $Record 'functions')) { return New-Unknown 'Functions could not be listed' }
        if (-not (Test-ChildCollected $Record 'config/authsettingsV2')) { return New-Unknown 'App Service authentication settings could not be read' }
        $auth = (Get-Child $Record 'config/authsettingsV2').properties
        $enforced = [bool]$auth.platform.enabled -and $auth.globalValidation.requireAuthentication -eq $true
        $excluded = @($auth.globalValidation.excludedPaths | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('/').ToLowerInvariant() })
        $anonymous = foreach ($function in @(Get-Child $Record 'functions' | Where-Object { $_ -and -not $_.properties.isDisabled })) {
            $trigger = @($function.properties.config.bindings) | Where-Object { $_.type -eq 'httpTrigger' -and $_.authLevel -eq 'anonymous' } | Select-Object -First 1
            if (-not $trigger) { continue }
            $name = ($function.name -split '/')[-1]
            $route = "/api/$(if ($trigger.route) { $trigger.route } else { $name })".ToLowerInvariant()
            [pscustomobject]@{ Name = $name; Unprotected = (-not $enforced) -or [bool]($excluded | Where-Object { $route -eq $_ -or $route.StartsWith("$_/") }) }
        }
        $unprotected = @($anonymous | Where-Object Unprotected | ForEach-Object Name | Sort-Object)
        $evidence = [ordered]@{ anonymousFunctions = @($anonymous | ForEach-Object Name | Sort-Object); appServiceAuthenticationRequired = $enforced; excludedPaths = $excluded; unprotectedFunctions = $unprotected }
        if ($unprotected) { return New-Fail "Anonymous HTTP function(s) reachable without authentication: $($unprotected -join ', ')" $evidence }
        if ($anonymous) { return New-Pass 'Anonymous functions are behind required App Service authentication' $evidence }
        New-Pass 'No anonymous HTTP functions' $evidence
    }
}