Compare commits

...

2 Commits

Author SHA1 Message Date
Daniel Barranquero
6df8261a7e feat: add changelog 2026-03-23 10:43:26 +01:00
Daniel Barranquero
b447f64ab0 feat(s3): add new check s3_bucket_website_hosting_disabled 2026-03-23 09:34:31 +01:00
6 changed files with 263 additions and 0 deletions

View File

@@ -2,6 +2,14 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.22.0] (Prowler UNRELEASED)
### 🚀 Added
- `s3_bucket_website_hosting_disabled` check for AWS provider [(#10415)](https://github.com/prowler-cloud/prowler/pull/10415)
---
## [5.21.0] (Prowler v5.21.0)
### 🚀 Added

View File

@@ -0,0 +1,39 @@
{
"Provider": "aws",
"CheckID": "s3_bucket_website_hosting_disabled",
"CheckTitle": "S3 bucket does not have static website hosting enabled",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices"
],
"ServiceName": "s3",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsS3Bucket",
"ResourceGroup": "storage",
"Description": "**Amazon S3 buckets** are evaluated to ensure **static website hosting** is not enabled. When website hosting is enabled, objects in the bucket may be publicly accessible via an HTTP endpoint, potentially exposing sensitive data.",
"Risk": "Enabling static website hosting on an S3 bucket creates an HTTP endpoint that serves bucket content directly. If bucket permissions are misconfigured, sensitive data could be exposed to the internet. Attackers can discover these endpoints through DNS enumeration or brute-force and access unprotected objects, leading to data leakage.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/AmazonS3/latest/userguide/WebsiteHosting.html",
"https://docs.aws.amazon.com/AmazonS3/latest/userguide/EnableWebsiteHosting.html"
],
"Remediation": {
"Code": {
"CLI": "aws s3api delete-bucket-website --bucket <BUCKET_NAME>",
"NativeIaC": "```yaml\nResources:\n S3Bucket:\n Type: AWS::S3::Bucket\n Properties:\n BucketName: <example_bucket_name>\n # CRITICAL: Remove WebsiteConfiguration to disable static website hosting\n```",
"Other": "1. Open the AWS Management Console and go to S3\n2. Select the bucket with the finding\n3. Go to the Properties tab\n4. In Static website hosting, click Edit\n5. Select Disable and Save changes",
"Terraform": "```hcl\n# CRITICAL: Remove the aws_s3_bucket_website_configuration resource to disable website hosting\n# resource \"aws_s3_bucket_website_configuration\" \"example\" {\n# bucket = aws_s3_bucket.example.id\n# index_document { suffix = \"index.html\" }\n# }\n```"
},
"Recommendation": {
"Text": "Disable **static website hosting** on S3 buckets that do not require it. If website hosting is needed, ensure the bucket policy restricts access appropriately and consider using **Amazon CloudFront** with an Origin Access Identity (OAI) instead of direct S3 website endpoints for better security controls.",
"Url": "https://hub.prowler.com/check/s3_bucket_website_hosting_disabled"
}
},
"Categories": [
"internet-exposed"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}

View File

@@ -0,0 +1,27 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.s3.s3_client import s3_client
class s3_bucket_website_hosting_disabled(Check):
"""Ensure that S3 buckets do not have static website hosting enabled."""
def execute(self) -> list[Check_Report_AWS]:
"""Execute the check logic.
Returns:
A list of reports containing the result of the check.
"""
findings = []
for bucket in s3_client.buckets.values():
report = Check_Report_AWS(metadata=self.metadata(), resource=bucket)
if bucket.website_hosting_enabled:
report.status = "FAIL"
report.status_extended = (
f"S3 Bucket {bucket.name} has static website hosting enabled."
)
else:
report.status = "PASS"
report.status_extended = f"S3 Bucket {bucket.name} does not have static website hosting enabled."
findings.append(report)
return findings

View File

@@ -36,6 +36,7 @@ class S3(AWSService):
self.__threading_call__(
self._get_bucket_notification_configuration, self.buckets.values()
)
self.__threading_call__(self._get_bucket_website, self.buckets.values())
def _list_buckets(self, provider):
logger.info("S3 - Listing buckets...")
@@ -487,6 +488,28 @@ class S3(AWSService):
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _get_bucket_website(self, bucket):
logger.info("S3 - Get bucket's website configuration...")
try:
regional_client = self.regional_clients[bucket.region]
regional_client.get_bucket_website(Bucket=bucket.name)
bucket.website_hosting_enabled = True
except ClientError as error:
if error.response["Error"]["Code"] == "NoSuchWebsiteConfiguration":
bucket.website_hosting_enabled = False
elif error.response["Error"]["Code"] == "NoSuchBucket":
logger.warning(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
else:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _head_bucket(self, bucket_name):
logger.info("S3 - Checking if bucket exists...")
try:
@@ -703,3 +726,4 @@ class Bucket(BaseModel):
lifecycle: List[LifeCycleRule] = Field(default_factory=list)
replication_rules: List[ReplicationRule] = Field(default_factory=list)
notification_config: Dict = Field(default_factory=dict)
website_hosting_enabled: bool = False

View File

@@ -0,0 +1,165 @@
from unittest import mock
from boto3 import client
from moto import mock_aws
from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider
class Test_s3_bucket_website_hosting_disabled:
@mock_aws
def test_no_buckets(self):
from prowler.providers.aws.services.s3.s3_service import S3
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
with mock.patch(
"prowler.providers.aws.services.s3.s3_bucket_website_hosting_disabled.s3_bucket_website_hosting_disabled.s3_client",
new=S3(aws_provider),
):
from prowler.providers.aws.services.s3.s3_bucket_website_hosting_disabled.s3_bucket_website_hosting_disabled import (
s3_bucket_website_hosting_disabled,
)
check = s3_bucket_website_hosting_disabled()
result = check.execute()
assert len(result) == 0
@mock_aws
def test_bucket_no_website_hosting(self):
s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1)
bucket_name_us = "bucket_test_us"
s3_client_us_east_1.create_bucket(Bucket=bucket_name_us)
from prowler.providers.aws.services.s3.s3_service import S3
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
with mock.patch(
"prowler.providers.aws.services.s3.s3_bucket_website_hosting_disabled.s3_bucket_website_hosting_disabled.s3_client",
new=S3(aws_provider),
):
from prowler.providers.aws.services.s3.s3_bucket_website_hosting_disabled.s3_bucket_website_hosting_disabled import (
s3_bucket_website_hosting_disabled,
)
check = s3_bucket_website_hosting_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"S3 Bucket {bucket_name_us} does not have static website hosting enabled."
)
assert result[0].resource_id == bucket_name_us
assert (
result[0].resource_arn
== f"arn:{aws_provider.identity.partition}:s3:::{bucket_name_us}"
)
@mock_aws
def test_bucket_with_website_hosting(self):
s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1)
bucket_name_us = "bucket_test_us"
s3_client_us_east_1.create_bucket(Bucket=bucket_name_us)
s3_client_us_east_1.put_bucket_website(
Bucket=bucket_name_us,
WebsiteConfiguration={
"IndexDocument": {"Suffix": "index.html"},
"ErrorDocument": {"Key": "error.html"},
},
)
from prowler.providers.aws.services.s3.s3_service import S3
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
with mock.patch(
"prowler.providers.aws.services.s3.s3_bucket_website_hosting_disabled.s3_bucket_website_hosting_disabled.s3_client",
new=S3(aws_provider),
):
from prowler.providers.aws.services.s3.s3_bucket_website_hosting_disabled.s3_bucket_website_hosting_disabled import (
s3_bucket_website_hosting_disabled,
)
check = s3_bucket_website_hosting_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"S3 Bucket {bucket_name_us} has static website hosting enabled."
)
assert result[0].resource_id == bucket_name_us
assert (
result[0].resource_arn
== f"arn:{aws_provider.identity.partition}:s3:::{bucket_name_us}"
)
@mock_aws
def test_multiple_buckets_mixed_website_hosting(self):
s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1)
bucket_fail = "bucket_with_website"
bucket_pass = "bucket_without_website"
s3_client_us_east_1.create_bucket(Bucket=bucket_fail)
s3_client_us_east_1.create_bucket(Bucket=bucket_pass)
s3_client_us_east_1.put_bucket_website(
Bucket=bucket_fail,
WebsiteConfiguration={
"IndexDocument": {"Suffix": "index.html"},
},
)
from prowler.providers.aws.services.s3.s3_service import S3
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
with mock.patch(
"prowler.providers.aws.services.s3.s3_bucket_website_hosting_disabled.s3_bucket_website_hosting_disabled.s3_client",
new=S3(aws_provider),
):
from prowler.providers.aws.services.s3.s3_bucket_website_hosting_disabled.s3_bucket_website_hosting_disabled import (
s3_bucket_website_hosting_disabled,
)
check = s3_bucket_website_hosting_disabled()
result = check.execute()
assert len(result) == 2
by_id = {finding.resource_id: finding for finding in result}
assert by_id[bucket_fail].status == "FAIL"
assert (
by_id[bucket_fail].status_extended
== f"S3 Bucket {bucket_fail} has static website hosting enabled."
)
assert by_id[bucket_pass].status == "PASS"
assert (
by_id[bucket_pass].status_extended
== f"S3 Bucket {bucket_pass} does not have static website hosting enabled."
)