Back to Checks
aws

CloudTrail trail S3 bucket is not publicly accessible

cloudtrail_logs_s3_bucket_is_not_publicly_accessible
Service: cloudtrailSeverity: critical
OfficialBuilt-inVerified
Categories:internet-exposed
Resource Type:AwsS3Bucket

CloudTrail log destination S3 buckets are inspected for ACL grants that expose data to the public AllUsers group.

Buckets hosted in other accounts are flagged for out-of-scope review.

Risk

Exposed CloudTrail logs erode confidentiality and integrity.

Adversaries can harvest API activity to map accounts, roles, and keys, enabling reconnaissance and evasion. If write is allowed, logs can be poisoned or deleted, thwarting investigations and compromising incident timelines.

Run this check with Prowler

Run in Prowler Cloud
Run
prowler aws --checks cloudtrail_logs_s3_bucket_is_not_publicly_accessible

Recommendation

Apply least privilege to the log bucket:

  • Enable S3 Block Public Access (account and bucket)
  • Remove AllUsers/AuthenticatedUsers ACLs; avoid wildcard principals
  • Permit only CloudTrail and constrain with aws:SourceArn

Use a dedicated private bucket and monitor for permission changes.

Remediation

CLI

aws s3api put-bucket-acl --bucket <example_resource_name> --acl private

Terraform

# Ensure the CloudTrail S3 bucket ACL is private
resource "aws_s3_bucket_acl" "fix_cloudtrail_logs_bucket" {
  bucket = "<example_resource_name>"
  acl    = "private"  # CRITICAL: removes any public (AllUsers) ACL grants
}

NativeIaC

# CloudFormation: ensure the CloudTrail S3 bucket ACL is not public
Resources:
  CloudTrailLogsBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: <example_resource_name>
      AccessControl: Private  # CRITICAL: sets bucket ACL to private, removing any AllUsers (public) grants

Other

1Open the AWS S3 Console
2Select the bucket used by CloudTrail
3Go to Permissions > Access control list (ACL)
4Click Edit under Public access, remove any grants to "Everyone (public access)" (uncheck Read/Write)
5Save changes

Check Code

from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.cloudtrail.cloudtrail_client import (
    cloudtrail_client,
)
from prowler.providers.aws.services.s3.s3_client import s3_client


class cloudtrail_logs_s3_bucket_is_not_publicly_accessible(Check):
    def execute(self):
        findings = []
        if cloudtrail_client.trails is not None:
            for trail in cloudtrail_client.trails.values():
                if trail.name:
                    trail_bucket_is_in_account = False
                    trail_bucket = trail.s3_bucket
                    report = Check_Report_AWS(metadata=self.metadata(), resource=trail)
                    report.region = trail.home_region
                    report.status = "PASS"
                    if trail.is_multiregion:
                        report.status_extended = f"S3 Bucket {trail_bucket} from multiregion trail {trail.name} is not publicly accessible."
                    else:
                        report.status_extended = f"S3 Bucket {trail_bucket} from single region trail {trail.name} is not publicly accessible."
                    for bucket in s3_client.buckets.values():
                        # Here we need to ensure that acl_grantee is filled since if we don't have permissions to query the api for a concrete region
                        # (for example due to a SCP) we are going to try access an attribute from a None type
                        if trail_bucket == bucket.name:
                            trail_bucket_is_in_account = True
                            if bucket.acl_grantees:
                                for grant in bucket.acl_grantees:
                                    if (
                                        grant.URI
                                        == "http://acs.amazonaws.com/groups/global/AllUsers"
                                    ):
                                        report.status = "FAIL"
                                        if trail.is_multiregion:
                                            report.status_extended = f"S3 Bucket {trail_bucket} from multiregion trail {trail.name} is publicly accessible."
                                        else:
                                            report.status_extended = f"S3 Bucket {trail_bucket} from single region trail {trail.name} is publicly accessible."
                                        break
                    # check if trail bucket is a cross account bucket
                    if not trail_bucket_is_in_account:
                        report.status = "MANUAL"
                        report.status_extended = f"Trail {trail.name} bucket ({trail_bucket}) is a cross-account bucket or out of Prowler's audit scope, please check it manually."
                    findings.append(report)

        return findings