Framework/Core/SVT/Services/AppService.ps1

#using namespace Microsoft.Azure.Commands.AppService.Models
Set-StrictMode -Version Latest
class AppService: SVTBase
{
    hidden [PSObject] $ResourceObject;
    hidden [PSObject] $WebAppDetails;
    hidden [PSObject] $AuthenticationSettings;
    hidden [bool] $IsReaderRole;

    AppService([string] $subscriptionId, [string] $resourceGroupName, [string] $resourceName):
        Base($subscriptionId, $resourceGroupName, $resourceName)
    {
        $this.GetResourceObject();
    }

    AppService([string] $subscriptionId, [SVTResource] $svtResource):
        Base($subscriptionId, $svtResource)
    {
        $this.GetResourceObject();
    }

    hidden [PSObject] GetResourceObject()
    {
        if (-not $this.ResourceObject)
        {
            # Get App Service details
            $this.ResourceObject = Get-AzureRmResource -ResourceName $this.ResourceContext.ResourceName  `
                                        -ResourceType $this.ResourceContext.ResourceType `
                                        -ResourceGroupName $this.ResourceContext.ResourceGroupName

            if(-not $this.ResourceObject)
            {
                throw ([SuppressedException]::new(("Resource '$($this.ResourceContext.ResourceName)' not found under Resource Group '$($this.ResourceContext.ResourceGroupName)'"), [SuppressedExceptionType]::InvalidOperation))
            }

            # Get web sites details
            $this.WebAppDetails = Get-AzureRmWebApp -Name $this.ResourceContext.ResourceName `
                                    -ResourceGroupName $this.ResourceContext.ResourceGroupName

            try
            { 
                $this.AuthenticationSettings = Invoke-AzureRmResourceAction -ResourceType "Microsoft.Web/sites/config/authsettings" `
                                                                                    -ResourceGroupName $this.ResourceContext.ResourceGroupName `
                                                                                    -ResourceName $this.ResourceContext.ResourceName `
                                                                                    -Action list `
                                                                                    -ApiVersion $this.ControlSettings.AppService.AADAuthAPIVersion `
                                                                                    -Force `
                                                                                    -ErrorAction Stop
                $this.IsReaderRole = $false;
            }
            catch
            {
                if(($_.Exception | Get-Member -Name "HttpStatus" ) -and $_.Exception.HttpStatus -eq "Forbidden")
                {
                    $this.IsReaderRole = $true;
                }    
            }
        }

        return $this.ResourceObject;
    }
    
    [ControlItem[]] ApplyServiceFilters([ControlItem[]] $controls)
    {
        $serviceFilterTag = "AppService";
        if([Helpers]::CheckMember($this.ResourceObject, "Kind"))
        {
            if($this.ResourceObject.Kind -eq "functionapp")
            {
                $serviceFilterTag = "FunctionApp";
            }
        }
        
        $result = @();
        $result += $controls | Where-Object { $_.Tags -contains $serviceFilterTag };
        return $result;
    }

    hidden [ControlResult] CheckAppServiceCustomDomainWithSSLConfig([ControlResult] $controlResult)
    {
        # Get custom domain URLs
        $customHostNames = $this.ResourceObject.Properties.HostNames |
                                Where-Object {
                                     -not $_.EndsWith(".azurewebsites.net")
                                };

        # Combine custom domain name and SSL configuration TCP

        if(($customHostNames | Measure-Object).Count -gt 0)
        {
            $controlResult.AddMessage([MessageData]::new("Custom domains are configured for resource " + $this.ResourceContext.ResourceName), $customHostNames);

            $SSLStateNotEnabled = $this.ResourceObject.Properties.hostNameSslStates | Where-Object { (($customHostNames | Measure-Object) -contains $_.name) -and  ($_.sslState -eq 'Disabled')} | Select-Object -Property Name
            if($null -eq $SSLStateNotEnabled)
            {
                $controlResult.AddMessage([VerificationResult]::Passed,
                                            [MessageData]::new("SSL configuration for resource " + $this.ResourceContext.ResourceName + " is enabled for all custom domains", $this.ResourceObject.Properties.hostNameSslStates));
            }
            else
            {
                $controlResult.AddMessage([VerificationResult]::Failed,
                                            [MessageData]::new("SSL configuration for resource " + $this.ResourceContext.ResourceName + " is not enabled for all custom domains", $this.ResourceObject.Properties.hostNameSslStates));
            }
        }
        else
        {
            $controlResult.AddMessage([VerificationResult]::Failed,
                                    [MessageData]::new("Custom domains are not configured for resource " + $this.ResourceContext.ResourceName, $this.ResourceObject.Properties.HostNames));
        }

        return $controlResult;
    }

    hidden [ControlResult] CheckAppServiceADAuthentication([ControlResult] $controlResult)
    {
        if($this.IsReaderRole)
        {
            #Setting this property ensures that this control result wont be considered for the central telemetry. As control doesnt have the required permissions
            $controlResult.CurrentSessionContext.Permissions.HasRequiredAccess = $false;
            $controlResult.AddMessage([VerificationResult]::Manual,
                                    [MessageData]::new("Control can not be validated due to insufficient access permission on resource"));
        }
        else
        {
            $AADEnabled = $false
            if([Helpers]::CheckMember($this.AuthenticationSettings,"Properties"))
            {
                $aadSettings = New-Object PSObject
                if([Helpers]::CheckMember($this.AuthenticationSettings.Properties,"enabled"))
                {
                    Add-Member -InputObject $aadSettings -MemberType NoteProperty -Name "Enabled" -Value $this.AuthenticationSettings.Properties.enabled
                }
                
                if([Helpers]::CheckMember($this.AuthenticationSettings.Properties,"ClientId"))
                {
                    Add-Member -InputObject $aadSettings -MemberType NoteProperty -Name "ClientId" -Value $this.AuthenticationSettings.Properties.ClientId

                    if(([Helpers]::CheckMember($this.AuthenticationSettings.Properties,"enabled")) -and ($this.AuthenticationSettings.Properties.enabled -eq $True))
                    {
                        if($null -ne $this.AuthenticationSettings.Properties.ClientId)
                        {
                            $AADEnabled = $True;
                        }
                    }
                }                
                
                if($AADEnabled)
                {
                    $controlResult.AddMessage([VerificationResult]::Passed,
                                            [MessageData]::new("AAD Authentication for resource " + $this.ResourceContext.ResourceName + " is enabled", $aadSettings));
                }
                else
                {
                    $controlResult.AddMessage([VerificationResult]::Verify,
                                            [MessageData]::new("Verify that AAD Authentication for resource " + $this.ResourceContext.ResourceName + " is enabled", $aadSettings));
                    $controlResult.SetStateData("App Service AAD settings", $aadSettings);
                }                
            }
            else
            {
                $controlResult.AddMessage([VerificationResult]::Verify,
                                            [MessageData]::new("Verify that AAD Authentication for resource " + $this.ResourceContext.ResourceName + " is enabled"));
            }

        }

      return $controlResult;
    }

    hidden [ControlResult] CheckAppServiceRemoteDebuggingConfiguration([ControlResult] $controlResult)
    {
        if([Helpers]::CheckMember($this.WebAppDetails,"SiteConfig"))
        {
            if([Helpers]::CheckMember($this.WebAppDetails.SiteConfig,"RemoteDebuggingEnabled") -and $this.WebAppDetails.SiteConfig.RemoteDebuggingEnabled)
            {
                $controlResult.AddMessage([VerificationResult]::Failed,
                                            [MessageData]::new("Remote debugging for resource " + $this.ResourceContext.ResourceName + " is turned on", ($this.WebAppDetails.SiteConfig | Select-Object RemoteDebuggingEnabled)));
            }
            else
            {
                $controlResult.AddMessage([VerificationResult]::Passed,
                                            [MessageData]::new("Remote debugging for resource " + $this.ResourceContext.ResourceName + " is turned off", ($this.WebAppDetails.SiteConfig | Select-Object RemoteDebuggingEnabled)));
            }
        }
        else
        {
            $controlResult.AddMessage([VerificationResult]::Manual,
                                            [MessageData]::new("Could not validate remote debugging settings on the AppService: " + $this.ResourceContext.ResourceName + "."));
        }

      return $controlResult;
    }

    hidden [ControlResult] CheckAppServiceWebSocketsConfiguration([ControlResult] $controlResult)
    {
        if($this.WebAppDetails.SiteConfig.WebSocketsEnabled)
        {
            $controlResult.EnableFixControl = $true;
            $controlResult.AddMessage([VerificationResult]::Failed,
                                     [MessageData]::new("Web sockets for resource " + $this.ResourceContext.ResourceName + " is enabled", ($this.WebAppDetails.SiteConfig | Select-Object WebSocketsEnabled)));
        }
        else
        {
           $controlResult.AddMessage([VerificationResult]::Passed,
                                     [MessageData]::new("Web sockets for resource " + $this.ResourceContext.ResourceName + " is not enabled", ($this.WebAppDetails.SiteConfig | Select-Object WebSocketsEnabled)));
        }

      return $controlResult;
    }

    hidden [ControlResult] CheckAppServiceAlwaysOnConfiguration([ControlResult] $controlResult)
    {
        if($this.WebAppDetails.SiteConfig.AlwaysOn)
        {
           $controlResult.AddMessage([VerificationResult]::Passed,
                                     [MessageData]::new("Always On feature for resource " + $this.ResourceContext.ResourceName + " is enabled", ($this.WebAppDetails.SiteConfig | Select-Object AlwaysOn)));
        }
        else
        {
           $controlResult.AddMessage([VerificationResult]::Failed,
                                     [MessageData]::new("Always On feature for resource " + $this.ResourceContext.ResourceName + " is not enabled", ($this.WebAppDetails.SiteConfig | Select-Object AlwaysOn)));
        }

      return $controlResult;
    }

    hidden [ControlResult] CheckAppService64BitPlatformConfiguration([ControlResult] $controlResult)
    {
        if($this.WebAppDetails.SiteConfig.Use32BitWorkerProcess)
        {
            $controlResult.EnableFixControl = $true;
           $controlResult.AddMessage([VerificationResult]::Failed,
                                     [MessageData]::new("32-bit platform is used for resource " + $this.ResourceContext.ResourceName, ($this.WebAppDetails.SiteConfig | Select-Object Use32BitWorkerProcess)));
        }
        else
        {
           $controlResult.AddMessage([VerificationResult]::Passed,
                                     [MessageData]::new("64-bit platform is used for resource " + $this.ResourceContext.ResourceName, ($this.WebAppDetails.SiteConfig | Select-Object Use32BitWorkerProcess)));
        }

      return $controlResult;
    }

    hidden [ControlResult] CheckAppServiceDotNetFrameworkVersion([ControlResult] $controlResult)
    {
        $dotNetFrameworkVersion = $this.WebAppDetails.SiteConfig.NetFrameworkVersion
        $splitVersionNumber = $this.ControlSettings.AppService.LatestDotNetFrameworkVersionNumber.split(".")

        # Compare App Service Net Framework version with latest Net Framework version from configuration
        $isCompliant =  $dotNetFrameworkVersion.CompareTo($splitVersionNumber[0] + "." + $splitVersionNumber[1]) -eq 0

        if($isCompliant)
        {
            $controlResult.AddMessage([VerificationResult]::Passed,
                                        [MessageData]::new("Latest .Net Framework version is used for resource " + $this.ResourceContext.ResourceName, ($this.WebAppDetails.SiteConfig | Select-Object NetFrameworkVersion)));
        }
        else
        {
            $controlResult.EnableFixControl = $true;
            $controlResult.AddMessage([VerificationResult]::Failed,
                                        [MessageData]::new("Latest .Net Framework version is not used for resource " + $this.ResourceContext.ResourceName, ($this.WebAppDetails.SiteConfig | Select-Object NetFrameworkVersion)));
        }

        return $controlResult;
    }

    hidden [ControlResult] CheckAppServiceInstanceCount([ControlResult] $controlResult)
    {
        # Get number of instances
        $sku = (Get-AzureRmResource -ResourceId $this.ResourceObject.Properties.ServerFarmId).Sku

        if($sku.Capacity -ge $this.ControlSettings.AppService.Minimum_Instance_Count)
        {
            $controlResult.AddMessage([VerificationResult]::Passed, [MessageData]::new("SKU for resource " + $this.ResourceContext.ResourceName + " is :", $sku));
        }
        else
        {
            $controlResult.EnableFixControl = $true;
            $controlResult.AddMessage([VerificationResult]::Failed, [MessageData]::new("SKU for resource " + $this.ResourceContext.ResourceName + " is :", $sku));
        }

      return $controlResult;
    }

    hidden [ControlResult] CheckAppServiceBackupConfiguration([ControlResult] $controlResult)
    {
        try
        {
            if($this.IsReaderRole)
            {
                #Setting this property ensures that this control result wont be considered for the central telemetry. As control doesnt have the required permissions
                $controlResult.CurrentSessionContext.Permissions.HasRequiredAccess = $false;
                $controlResult.AddMessage([VerificationResult]::Manual,
                                        [MessageData]::new("Control can not be validated due to insufficient access permission on resource"));
            }
            else
            {
                $backupConfiguration = Get-AzureRmWebAppBackupConfiguration `
                                                    -ResourceGroupName $this.ResourceContext.ResourceGroupName `
                                                    -Name $this.ResourceContext.ResourceName `
                                                    -ErrorAction Stop

                $isCompliant = $False
                If ($null -ne $backupConfiguration)
                {
                    # Backup must be enabled and retention period days must be more than 365 and backup start date is less than current time (backup has been already started)
                    # and at least one backup is available
                    If($backupConfiguration.Enabled -eq $True -and `
                    ($backupConfiguration.RetentionPeriodInDays -eq $this.ControlSettings.AppService.Backup_RetentionPeriod_Forever -or $backupConfiguration.RetentionPeriodInDays -ge $this.ControlSettings.AppService.Backup_RetentionPeriod_Min) -and`
                        $backupConfiguration.StartTime -le $(Get-Date) -and $backupConfiguration.KeepAtLeastOneBackup -eq $True)
                    {
                        $isCompliant = $True
                    }
                }

                if(-not $isCompliant)
                {
                    $controlResult.VerificationResult = [VerificationResult]::Failed;
                    if($null -ne $backupConfiguration)
                    {
                        $controlResult.AddMessage([MessageData]::new("Configured backup for resource " + $this.ResourceContext.ResourceName + " is not as per the security guidelines. Please make sure that the configured backup is inline with below settings:-"));
                        $controlResult.AddMessage([MessageData]::new("Enabled=True, StorageAccountEncryption=Enabled, RetentionPeriodInDays=0 or RetentionPeriodInDays>=365, BackupStartTime<=CurrentTime, KeepAtLeastOneBackup=True", $backupConfiguration));
                    }
                    else
                    {
                        $controlResult.AddMessage([MessageData]::new("Backup for resource " + $this.ResourceContext.ResourceName + " is not configured", $backupConfiguration));
                    }
                }
                else
                {
                    $controlResult.AddMessage([VerificationResult]::Passed,
                                        [MessageData]::new("Backup for resource " + $this.ResourceContext.ResourceName + " is enabled", $backupConfiguration));
                }
            }
            
        }
        catch
        {
            if(($_.Exception).Response.StatusCode.value__ -eq 404)
            {
                    $controlResult.AddMessage([VerificationResult]::Failed,
                                        [MessageData]::new("Backup for resource " + $this.ResourceContext.ResourceName + " is not configured"));
            }
            else
            {
                    throw $_
            }
        }

        return $controlResult;
    }

    hidden [ControlResult] CheckAppServiceDiagnosticLogsConfiguration([ControlResult] $controlResult)
    {
        $diagnosticLogsConfig = New-Object PSObject
        Add-Member -InputObject $diagnosticLogsConfig -MemberType NoteProperty -Name "HttpLoggingEnabled" -Value $this.WebAppDetails.SiteConfig.HttpLoggingEnabled
        Add-Member -InputObject $diagnosticLogsConfig -MemberType NoteProperty  -Name "DetailedErrorLoggingEnabled" -Value $this.WebAppDetails.SiteConfig.DetailedErrorLoggingEnabled
        Add-Member -InputObject $diagnosticLogsConfig -MemberType NoteProperty  -Name "RequestTracingEnabled" -Value $this.WebAppDetails.SiteConfig.RequestTracingEnabled

        $isCompliant =  $diagnosticLogsConfig.HttpLoggingEnabled -eq $true -and `
                        $diagnosticLogsConfig.DetailedErrorLoggingEnabled -eq $true -and `
                        $diagnosticLogsConfig.RequestTracingEnabled -eq $true

        if($isCompliant)
        {
           $controlResult.AddMessage([VerificationResult]::Passed,
                                     [MessageData]::new("Diagnostics logs for resource " + $this.ResourceContext.ResourceName + " are enabled", $diagnosticLogsConfig));
        }
        else
        {
            $controlResult.EnableFixControl = $true;
            $controlResult.AddMessage([VerificationResult]::Failed,
                                     [MessageData]::new("All configurations of diagnostics logs for resource " + $this.ResourceContext.ResourceName + " are not enabled", $diagnosticLogsConfig));
        }

        return $controlResult;
    }

    hidden [ControlResult] CheckAppServiceHttpCertificateSSL([ControlResult] $controlResult)
    {    
        $hostNames = $this.ResourceObject.Properties.HostNames |
                                Where-Object {
                                     -not $_.EndsWith(".scm.azurewebsites.net")
                                };

        $isPass = 0
        if(($hostNames | Measure-Object).Count -gt 0)
        {
            $hostNames | ForEach-Object{
                try
                {       
                    $request = [System.Net.HttpWebRequest][System.Net.WebRequest]::Create('http://'+$_)
                    $scheme = $request.RequestUri.Scheme
                    #Turn off auto redirect as in the case of AAD auth, it would go to a login url instead of checking the url scheme for the originate website url
                    $request.AllowAutoRedirect = $false;
                    $response = $request.GetResponse()

                    #Since we have turned of the redirection, we need to check from location header instead from the reponse.originaluri.
                    $responseURL = $response.Headers["Location"];
                    $response.Close()

                    if($null -ne $responseURL -and [Helpers]::CheckMember(([System.Uri]$responseURL), "Scheme"))
                    {
                        $scheme = ([System.Uri]$responseURL).Scheme    
                    }

                    if($scheme -eq "https")
                    {
                        $controlResult.AddMessage([MessageData]::new("HTTP requests are not allowed for resource " + $this.ResourceContext.ResourceName, $responseURL));
                    }
                    else
                    {
                        $isPass = If ($isPass -lt 2) { 2 } Else { $isPass }
                        $controlResult.AddMessage([MessageData]::new("HTTP requests are allowed for resource " + $this.ResourceContext.ResourceName, $responseURL));
                    }
                }
                catch [System.Management.Automation.MethodInvocationException]
                {
                    $isPass = If ($isPass -lt 1) { 1 } Else { $isPass }
                    $controlResult.AddMessage([MessageData]::new("Default Host URL is not reachable or self-signed certificate is used", $request.Address.OriginalString));
                }
            }
        }

        if($isPass -eq 0)
        {
            $controlResult.VerificationResult = [VerificationResult]::Passed
        }
        ElseIf ($isPass -eq 1)
        {
            $controlResult.VerificationResult = [VerificationResult]::Verify
            $controlResult.SetStateData("Host urls", $hostNames);
        }
        else
        {
            $controlResult.VerificationResult = [VerificationResult]::Failed
            $controlResult.SetStateData("Host urls", $hostNames);
        }
        
    
        return $controlResult;
    }

    hidden [ControlResult] CheckAppServiceLoadCertAppSettings([ControlResult] $controlResult)
    {
        if($this.IsReaderRole)
        {
            $controlResult.AddMessage([VerificationResult]::Manual,
                                        [MessageData]::new("Control can not be validated due to insufficient access permission on resource"));
        }
        else
        {
            $appSettings = $this.WebAppDetails.SiteConfig.AppSettings
               $appSettingParameterList = $null

            if(($appSettings| Measure-Object).Count -gt 0)
            {
                $appSettingParameterList = $appSettings | Where-Object { $_.Name -eq $this.ControlSettings.AppService.LoadCertAppSettings -and $_.Value -eq "*"}
            }

            if($null -ne $appSettingParameterList)
            {
               $controlResult.AddMessage([VerificationResult]::Failed,
                                         [MessageData]::new("'WEBSITE_LOAD_CERTIFICATES' parameter defined equal to '*' is found in App Settings for resource " + $this.ResourceContext.ResourceName));
            }
            else
            {
               $controlResult.AddMessage([VerificationResult]::Passed,
                                         [MessageData]::new("'WEBSITE_LOAD_CERTIFICATES' parameter defined equal to '*' is not found in App Settings for resource " + $this.ResourceContext.ResourceName));
            }
        }
        return $controlResult;
    }

    hidden [ControlResult] CheckFunctionsEditMode([ControlResult] $controlResult)
    {
        if($this.IsReaderRole)
        {
            $controlResult.AddMessage([VerificationResult]::Manual,
                                        [MessageData]::new("Control can not be validated due to insufficient access permission on resource"));
        }
        else
        {
            $appSettings = $this.WebAppDetails.SiteConfig.AppSettings
               $editModeReadOnly = $null

            if(($appSettings| Measure-Object).Count -gt 0)
            {
                $editModeReadOnly = $appSettings | Where-Object { $_.Name -eq "FUNCTION_APP_EDIT_MODE" -and $_.Value -eq "readonly"}
            }

            if($null -ne $editModeReadOnly)
            {
               $controlResult.AddMessage([VerificationResult]::Passed,
                                         [MessageData]::new("Functions app edit mode is defined as 'readonly' for resource " + $this.ResourceContext.ResourceName));
            }
            else
            {
               $controlResult.AddMessage([VerificationResult]::Failed,
                                         [MessageData]::new("Functions app edit mode is defined as 'readwrite' for resource " + $this.ResourceContext.ResourceName));
            }
        }
        return $controlResult;
    }
    
    hidden [ControlResult] CheckFunctionsAuthorizationLevel([ControlResult] $controlResult)
    {
            
        $resourceAppIdURI =[WebRequestHelper]::ClassicManagementUri;
        $accessToken = [Helpers]::GetAccessToken($ResourceAppIdURI)
        $authorisationToken = "Bearer " + $accessToken
        $headers = @{"Authorization"=$authorisationToken;"Content-Type"="application/json"}
        $apiFunctionsUrl = [string]::Format("https://{0}.scm.azurewebsites.net/api/functions",$this.ResourceContext.ResourceName)
        $functionDetail = [WebRequestHelper]::InvokeGetWebRequest($apiFunctionsUrl, $headers)
        
            #check if functions are present in FunctionApp
            if([Helpers]::CheckMember($functionDetail,"config"))
            {
            $bindingsDetail =$functionDetail.config.bindings
               $authorizationLevel = $null

            if(($bindingsDetail| Measure-Object).Count -gt 0)
            {
             $bindingsDetail |     ForEach-Object{
                if($_.type -eq "httpTrigger" )
                     {
                        if([Helpers]::CheckMember($_,"authLevel"))
                            {
                                if($_.authLevel -ne 'function')
                                {
                                    $authorizationLevel=$_.authLevel
                                }
                            } 
                        }
                }
            }
            
            if($null -ne $authorizationLevel)
            {
               $controlResult.AddMessage([VerificationResult]::Failed,
                                         [MessageData]::new("Authorization level for all functions in a Functions app is not defined as 'Function' for resource " + $this.ResourceContext.ResourceName));
            }
            else
            {
               $controlResult.AddMessage([VerificationResult]::Passed,
                                         [MessageData]::new("Authorization level for all functions in a Functions app is defined as 'Function' for resource " + $this.ResourceContext.ResourceName));
            }
            }
          else
            {
               $controlResult.AddMessage([VerificationResult]::Passed,
                                         [MessageData]::new("No functions are found in Functions app resource " + $this.ResourceContext.ResourceName));
            }
        
        return $controlResult;
    }

}