diff --git a/prowler/changelog.d/app-function-https-only-check.added.md b/prowler/changelog.d/app-function-https-only-check.added.md new file mode 100644 index 0000000000..a7b3bafd95 --- /dev/null +++ b/prowler/changelog.d/app-function-https-only-check.added.md @@ -0,0 +1 @@ +`app_function_ensure_http_is_redirected_to_https` check for Azure provider, verifying that Function Apps enforce HTTPS-only traffic diff --git a/prowler/providers/azure/services/app/app_function_ensure_http_is_redirected_to_https/__init__.py b/prowler/providers/azure/services/app/app_function_ensure_http_is_redirected_to_https/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/app/app_function_ensure_http_is_redirected_to_https/app_function_ensure_http_is_redirected_to_https.metadata.json b/prowler/providers/azure/services/app/app_function_ensure_http_is_redirected_to_https/app_function_ensure_http_is_redirected_to_https.metadata.json new file mode 100644 index 0000000000..7696bec34f --- /dev/null +++ b/prowler/providers/azure/services/app/app_function_ensure_http_is_redirected_to_https/app_function_ensure_http_is_redirected_to_https.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "app_function_ensure_http_is_redirected_to_https", + "CheckTitle": "Function app redirects HTTP to HTTPS", + "CheckType": [], + "ServiceName": "app", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.web/sites", + "ResourceGroup": "serverless", + "Description": "**Azure Function apps** redirect `HTTP` traffic to `HTTPS` when the `HTTPS Only` setting is enabled. This evaluation identifies Function apps that do not force secure transport by checking whether plaintext requests are automatically redirected to encrypted endpoints.", + "Risk": "Leaving **HTTP accessible** on a Function app enables **man-in-the-middle** interception, credential and token theft, and response tampering. This undermines **confidentiality** and **integrity**, and can lead to session hijacking or downgrade attacks that bypass TLS.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/app-service/configure-ssl-bindings#enforce-https", + "https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-data-protection#dp-3-encrypt-sensitive-data-in-transit" + ], + "Remediation": { + "Code": { + "CLI": "az functionapp update --resource-group --name --https-only true", + "NativeIaC": "```bicep\n// Enable HTTPS-only redirect on an existing Function App\nresource functionApp 'Microsoft.Web/sites@2022-09-01' = {\n name: ''\n location: resourceGroup().location\n properties: {\n httpsOnly: true // Critical: forces redirect from HTTP to HTTPS\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and go to Function Apps\n2. Select your Function app\n3. Go to TLS/SSL settings and set HTTPS Only to On\n4. Click Save", + "Terraform": "```hcl\n# Enforce HTTPS-only on a Function App\nresource \"azurerm_linux_function_app\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n service_plan_id = \"\"\n storage_account_name = \"\"\n storage_account_access_key = \"\"\n\n https_only = true # Critical: redirects HTTP to HTTPS\n}\n```" + }, + "Recommendation": { + "Text": "Enforce **HTTPS-only** for all Function apps.\n- Use trusted certificates and require `TLS 1.2` or later\n- Enable **HSTS** to prevent downgrade/mixed-content\n- Redirect legacy `http` links to `https`\n- Minimize HTTP exposure via WAF/CDN or private access\nApply **defense in depth** to protect data in transit.", + "Url": "https://hub.prowler.com/check/app_function_ensure_http_is_redirected_to_https" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check mirrors app_ensure_http_is_redirected_to_https but evaluates Function apps (Microsoft.Web/sites with kind starting with 'functionapp') via app_client.functions. When HTTPS Only is enabled, every incoming HTTP request is redirected to the HTTPS port." +} diff --git a/prowler/providers/azure/services/app/app_function_ensure_http_is_redirected_to_https/app_function_ensure_http_is_redirected_to_https.py b/prowler/providers/azure/services/app/app_function_ensure_http_is_redirected_to_https/app_function_ensure_http_is_redirected_to_https.py new file mode 100644 index 0000000000..17090ac2cf --- /dev/null +++ b/prowler/providers/azure/services/app/app_function_ensure_http_is_redirected_to_https/app_function_ensure_http_is_redirected_to_https.py @@ -0,0 +1,35 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.app.app_client import app_client + + +class app_function_ensure_http_is_redirected_to_https(Check): + """Ensure Function Apps redirect HTTP traffic to HTTPS.""" + + def execute(self) -> list[Check_Report_Azure]: + """Execute the check logic. + + Returns: + A list of reports for Function Apps HTTPS-only enforcement. + """ + findings = [] + + for ( + subscription_id, + functions, + ) in app_client.functions.items(): + subscription_name = app_client.subscriptions.get( + subscription_id, subscription_id + ) + for function in functions.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=function) + report.subscription = subscription_id + report.status = "PASS" + report.status_extended = f"HTTP is redirected to HTTPS for Function app '{function.name}' in subscription '{subscription_name} ({subscription_id})'." + + if not function.https_only: + report.status = "FAIL" + report.status_extended = f"HTTP is not redirected to HTTPS for Function app '{function.name}' in subscription '{subscription_name} ({subscription_id})'." + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/app/app_service.py b/prowler/providers/azure/services/app/app_service.py index 5b090bf1f2..68c683caa1 100644 --- a/prowler/providers/azure/services/app/app_service.py +++ b/prowler/providers/azure/services/app/app_service.py @@ -178,6 +178,7 @@ class App(AzureService): ftps_state=getattr( function_config, "ftps_state", None ), + https_only=getattr(function, "https_only", False), ) } ) @@ -301,3 +302,4 @@ class FunctionApp: public_access: bool vnet_subnet_id: str ftps_state: Optional[str] + https_only: bool = False diff --git a/tests/providers/azure/services/app/app_function_ensure_http_is_redirected_to_https/app_function_ensure_http_is_redirected_to_https_test.py b/tests/providers/azure/services/app/app_function_ensure_http_is_redirected_to_https/app_function_ensure_http_is_redirected_to_https_test.py new file mode 100644 index 0000000000..c4b5c7266a --- /dev/null +++ b/tests/providers/azure/services/app/app_function_ensure_http_is_redirected_to_https/app_function_ensure_http_is_redirected_to_https_test.py @@ -0,0 +1,157 @@ +from unittest import mock +from uuid import uuid4 + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_app_function_ensure_http_is_redirected_to_https: + def test_function_no_subscriptions(self): + app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + app_client.functions = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.app.app_function_ensure_http_is_redirected_to_https.app_function_ensure_http_is_redirected_to_https.app_client", + new=app_client, + ), + ): + from prowler.providers.azure.services.app.app_function_ensure_http_is_redirected_to_https.app_function_ensure_http_is_redirected_to_https import ( + app_function_ensure_http_is_redirected_to_https, + ) + + check = app_function_ensure_http_is_redirected_to_https() + result = check.execute() + assert len(result) == 0 + + def test_function_subscriptions_empty(self): + app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + app_client.functions = {AZURE_SUBSCRIPTION_ID: {}} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.app.app_function_ensure_http_is_redirected_to_https.app_function_ensure_http_is_redirected_to_https.app_client", + new=app_client, + ), + ): + from prowler.providers.azure.services.app.app_function_ensure_http_is_redirected_to_https.app_function_ensure_http_is_redirected_to_https import ( + app_function_ensure_http_is_redirected_to_https, + ) + + check = app_function_ensure_http_is_redirected_to_https() + result = check.execute() + assert len(result) == 0 + + def test_function_http_not_redirected(self): + resource_id = f"/subscriptions/{uuid4()}" + app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.app.app_function_ensure_http_is_redirected_to_https.app_function_ensure_http_is_redirected_to_https.app_client", + new=app_client, + ), + ): + from prowler.providers.azure.services.app.app_function_ensure_http_is_redirected_to_https.app_function_ensure_http_is_redirected_to_https import ( + app_function_ensure_http_is_redirected_to_https, + ) + from prowler.providers.azure.services.app.app_service import FunctionApp + + app_client.functions = { + AZURE_SUBSCRIPTION_ID: { + resource_id: FunctionApp( + id=resource_id, + name="function-1", + location="West Europe", + kind="functionapp", + function_keys=None, + environment_variables=None, + identity=None, + public_access=True, + vnet_subnet_id="", + ftps_state="Disabled", + https_only=False, + ) + } + } + check = app_function_ensure_http_is_redirected_to_https() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"HTTP is not redirected to HTTPS for Function app 'function-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." + ) + assert result[0].resource_name == "function-1" + assert result[0].resource_id == resource_id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "West Europe" + + def test_function_http_redirected(self): + resource_id = f"/subscriptions/{uuid4()}" + app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.app.app_function_ensure_http_is_redirected_to_https.app_function_ensure_http_is_redirected_to_https.app_client", + new=app_client, + ), + ): + from prowler.providers.azure.services.app.app_function_ensure_http_is_redirected_to_https.app_function_ensure_http_is_redirected_to_https import ( + app_function_ensure_http_is_redirected_to_https, + ) + from prowler.providers.azure.services.app.app_service import FunctionApp + + app_client.functions = { + AZURE_SUBSCRIPTION_ID: { + resource_id: FunctionApp( + id=resource_id, + name="function-1", + location="West Europe", + kind="functionapp", + function_keys=None, + environment_variables=None, + identity=None, + public_access=True, + vnet_subnet_id="", + ftps_state="Disabled", + https_only=True, + ) + } + } + check = app_function_ensure_http_is_redirected_to_https() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"HTTP is redirected to HTTPS for Function app 'function-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." + ) + assert result[0].resource_name == "function-1" + assert result[0].resource_id == resource_id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "West Europe" diff --git a/tests/providers/azure/services/app/app_service_test.py b/tests/providers/azure/services/app/app_service_test.py index ccd396cc0d..f517f39d34 100644 --- a/tests/providers/azure/services/app/app_service_test.py +++ b/tests/providers/azure/services/app/app_service_test.py @@ -570,3 +570,38 @@ class Test_App_get_functions: mock_client.web_apps.list_by_resource_group.assert_called_once_with( resource_group_name="RG" ) + + def test_get_functions_sets_https_only(self): + from prowler.providers.azure.services.app.app_service import App + + mock_client = MagicMock() + mock_function = MagicMock() + mock_function.id = "/subscriptions/resource_id" + mock_function.name = "functionapp-1" + mock_function.location = "West Europe" + mock_function.kind = "functionapp" + mock_function.resource_group = RESOURCE_GROUP + mock_function.identity = None + mock_function.public_network_access = "Enabled" + mock_function.virtual_network_subnet_id = "" + mock_function.https_only = True + mock_client.web_apps.list.return_value = [mock_function] + + app = object.__new__(App) + app.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + app.resource_groups = None + app._get_function_host_keys = MagicMock(return_value=None) + app._list_application_settings = MagicMock( + return_value=MagicMock(properties={}) + ) + app._get_function_config = MagicMock( + return_value=MagicMock(ftps_state="FtpsOnly") + ) + + result = app._get_functions() + + function = result[AZURE_SUBSCRIPTION_ID][mock_function.id] + assert function.https_only is True + app._get_function_host_keys.assert_called_once_with( + AZURE_SUBSCRIPTION_ID, RESOURCE_GROUP, "functionapp-1" + )