Build an AWS Cloud Security Scanner CLI

Build a Python CLI tool that audits your AWS account for security misconfigurations.

Introduction

30 Second Summary

Every day, AWS accounts get breached because of a single misconfigured Security Group or an S3 bucket left open to the public. Security teams use automated scanners to catch these mistakes before attackers do.

In this project, you will build a Python CLI tool that scans your AWS account for security misconfigurations across Security Groups, S3 buckets, and IAM policies. It produces a severity-scored report with specific remediation recommendations.

What You'll Build

A command-line security scanner called cloud-sec-scan that audits your AWS account across three security domains and outputs a formatted report with an overall security score.

By the end of this project, you'll have:

  • A modular Python scanner architecture using base class inheritance that you can extend with new security checks.
  • Detection logic for overly permissive Security Groups, unprotected S3 buckets, and wildcard IAM policies.
  • A severity scoring engine that calculates an overall account security score from Critical, High, Medium, and Low findings.
  • Secret Mission: A CloudTrail scanner that detects if logging is disabled or not configured as multi-region.

Are there any prerequisites?

This project assumes you already have the following set up:

  • An AWS account with IAM user credentials configured.
  • Python 3.10+ installed on your machine.
  • VS Code installed.

Build the Base Scanner and Scoring Engine

Your environment is ready with boto3 installed and your project files created. Now you need a foundation that every scanner in the project can build on.

In this step, you will define the data structures that represent security findings, create an abstract base class that enforces a consistent interface for all scanners, and build a scoring engine that converts raw findings into a 0-100 security score.

In this step, get ready to:
  • Define the Severity enum and Finding data model in scanner/base.py.
  • Create the abstract BaseScanner class that all domain scanners will inherit from.
  • Build the scoring engine in scoring.py that calculates an overall security score.
Create the Severity enum and Finding data model

Every security scanner needs a consistent way to describe what it found. You need a severity scale to rank issues and a structured data container to hold the details of each finding.

  • Open scanner/base.py in VS Code and paste the following code:
from dataclasses import dataclass
from enum import Enum
from abc import ABC, abstractmethod


# Severity levels for security findings, ranked from worst to least concerning
class Severity(Enum):
    CRITICAL = "CRITICAL"
    HIGH = "HIGH"
    MEDIUM = "MEDIUM"
    LOW = "LOW"


# A single security finding with all context needed for reporting
@dataclass
class Finding:
    description: str
    severity: Severity
    resource_id: str
    recommendation: str

What does this code do?

The Severity enum defines four threat levels (CRITICAL, HIGH, MEDIUM, LOW) that every scanner uses to rank its findings. Enums prevent typos and let you compare severity levels reliably.

The Finding dataclass is a structured container for one security issue. Python's @dataclass decorator automatically generates an __init__ method from your type-annotated fields, so you get a clean data container without writing boilerplate.

  • Save scanner/base.py.

You won't see a change yet. The next chunk uses this to define the base scanner class.

Create the abstract BaseScanner class

Each domain scanner (Security Groups, S3, IAM) needs to follow the same interface so the CLI can call them all uniformly. An abstract base class enforces this contract.

  • In scanner/base.py, add the following class below the Finding dataclass:
# Abstract base class that all domain scanners must implement
class BaseScanner(ABC):
    @abstractmethod
    def scan(self) -> list[Finding]:
        pass

What does this code do?

BaseScanner inherits from Python's ABC (Abstract Base Class). The @abstractmethod decorator on scan() means any class that inherits from BaseScanner must provide its own implementation of scan(). If it does not, Python raises a TypeError at instantiation.

This guarantees every scanner returns a list[Finding], so the CLI and scoring engine can process results from any scanner without special-casing.

  • Save scanner/base.py.
  • Verify your data model works by running this in your terminal:
python3 -c "from scanner.base import Severity, Finding, BaseScanner; print(Severity.CRITICAL); print(Finding('test', Severity.HIGH, 'r-1', 'fix it'))"

You should see Severity.CRITICAL on the first line, followed by a Finding(...) representation on the second line.

Seeing a ModuleNotFoundError?

Make sure you are running the command from the cloud-sec-scan folder (the one containing the scanner/ directory). Also confirm your virtual environment is still active. You should see (venv) at the start of your terminal prompt.

help me fix a ModuleNotFoundError when importing from scanner.base

✔️ Awesome, I've got everything!

Great. Double check you have saved scanner/base.py before moving on.

ⓧ I'd like to double check the full code

from dataclasses import dataclass
from enum import Enum
from abc import ABC, abstractmethod


class Severity(Enum):
    CRITICAL = "CRITICAL"
    HIGH = "HIGH"
    MEDIUM = "MEDIUM"
    LOW = "LOW"


@dataclass
class Finding:
    description: str
    severity: Severity
    resource_id: str
    recommendation: str


class BaseScanner(ABC):
    @abstractmethod
    def scan(self) -> list[Finding]:
        pass
Build the scoring engine

Raw findings need to be converted into a single score so the report can quickly communicate how secure an account is. The scoring engine assigns point penalties based on severity and subtracts them from a perfect 100.

  • Open scoring.py in VS Code and paste the following code:
from scanner.base import Finding, Severity

# Points deducted per finding based on severity
SEVERITY_POINTS = {
    Severity.CRITICAL: 10,
    Severity.HIGH: 5,
    Severity.MEDIUM: 2,
    Severity.LOW: 1,
}


def calculate_score(findings: list[Finding]) -> int:
    # A clean account with no findings scores 100
    if not findings:
        return 100

    # Sum up penalty points and subtract from perfect score
    total_points = sum(SEVERITY_POINTS[f.severity] for f in findings)
    score = max(0, 100 - total_points)
    return score

What does this code do?

SEVERITY_POINTS maps each severity level to the number of points it deducts from the score. A single CRITICAL finding costs 10 points, HIGH costs 5, and so on.

calculate_score() takes all findings from every scanner, sums up the penalty points, and subtracts them from 100. The max(0, ...) ensures the score never goes negative. An account with zero findings gets a perfect 100.

  • Save scoring.py.
  • Verify the scoring engine works by running this in your terminal:
python3 -c "from scoring import calculate_score; from scanner.base import Finding, Severity; print(calculate_score([])); print(calculate_score([Finding('test', Severity.CRITICAL, 'r-1', 'fix')]))"

You should see 100 on the first line (no findings means perfect score) and 90 on the second line (one CRITICAL finding deducts 10 points).

Seeing 100 for both lines?

Check that you have the SEVERITY_POINTS dictionary exactly as shown, with Severity.CRITICAL: 10. If the dictionary keys do not match the Severity enum values, the lookup silently fails.

help me debug why my scoring engine returns 100 even when I pass in findings

✔️ Awesome, I've got everything!

Great. Double check you have saved scoring.py before moving on.

ⓧ I'd like to double check the full code

from scanner.base import Finding, Severity

SEVERITY_POINTS = {
    Severity.CRITICAL: 10,
    Severity.HIGH: 5,
    Severity.MEDIUM: 2,
    Severity.LOW: 1,
}


def calculate_score(findings: list[Finding]) -> int:
    if not findings:
        return 100

    total_points = sum(SEVERITY_POINTS[f.severity] for f in findings)
    score = max(0, 100 - total_points)
    return score

Your foundation is locked in. You have a data model, a base class contract, and a scoring engine ready to process findings. Next up, you will build the first real scanner that uses this foundation to audit Security Groups for dangerous ingress rules.

Build the Security Groups Scanner

Your base scanner architecture and scoring engine are in place. Now it's time to put them to work by building the first real security check.

Security Groups are the firewall rules that control inbound traffic to your AWS resources. A single misconfigured rule that opens SSH to the entire internet is one of the most common causes of cloud breaches. In this step, you'll build a scanner that catches exactly these kinds of dangerous rules.

In this step, get ready to:
  • Implement the SecurityGroupScanner class that fetches all security groups from AWS.
  • Write detection logic that flags dangerous port rules open to the world.
  • Score findings as CRITICAL or HIGH based on which ports are exposed.
Implement the SecurityGroupScanner class

Every scanner in your project extends the BaseScanner class you built in the previous step. The Security Groups scanner connects to the EC2 service and pulls every security group in the account using a paginator to handle large numbers of results.

  • Open scanner/security_groups.py in VS Code and add the following code:
import boto3
from botocore.exceptions import ClientError
from scanner.base import BaseScanner, Finding, Severity

# Ports that should never be open to the entire internet
SENSITIVE_PORTS = {
    22: "SSH",
    3389: "RDP",
    3306: "MySQL",
    5432: "PostgreSQL",
}


class SecurityGroupScanner(BaseScanner):
    def __init__(self):
        # Create an EC2 client to query security groups
        self.client = boto3.client("ec2")

    def scan(self) -> list[Finding]:
        findings = []
        # Use a paginator to handle accounts with many security groups
        paginator = self.client.get_paginator("describe_security_groups")

        for page in paginator.paginate():
            for sg in page["SecurityGroups"]:
                findings.extend(self._check_sg(sg))

        return findings

What does this code do?

SENSITIVE_PORTS defines the ports you care about. These are services that attackers actively scan for on the open internet.

The scan() method uses a paginator instead of a single API call. AWS returns results in pages when an account has many resources. The paginator automatically fetches every page for you.

  • Save scanner/security_groups.py.
  • You won't see a change yet. The scanner needs its detection methods before you can test it.
Write the port detection and severity logic

Each security group has a list of inbound rules called IpPermissions. Each rule specifies a port range and which IP addresses are allowed through. Your detection logic checks whether any sensitive port falls within a rule's range AND that rule allows traffic from the entire internet.

  • Add the _check_sg() method inside the SecurityGroupScanner class, below the scan() method:
    def _check_sg(self, sg: dict) -> list[Finding]:
        findings = []
        sg_id = sg["GroupId"]
        sg_name = sg["GroupName"]

        for permission in sg["IpPermissions"]:
            # Get the port range this rule covers
            from_port = permission.get("FromPort", 0)
            to_port = permission.get("ToPort", 0)

            # Check if any sensitive port falls within this range
            for port, service in SENSITIVE_PORTS.items():
                if from_port <= port <= to_port:
                    if self._is_open_to_world(permission):
                        # SSH/RDP open to the world is critical, database ports are high
                        severity = (
                            Severity.CRITICAL
                            if port in (22, 3389)
                            else Severity.HIGH
                        )
                        findings.append(
                            Finding(
                                description=f"{service} (port {port}) is open to the world in security group {sg_name}",
                                severity=severity,
                                resource_id=sg_id,
                                recommendation=f"Restrict {service} access to specific IP ranges instead of 0.0.0.0/0",
                            )
                        )

        return findings

What does this code do?

For each inbound rule, the method extracts the port range (FromPort to ToPort) and checks whether any sensitive port falls within that range. A rule allowing ports 0-65535 would match ALL sensitive ports.

The severity scoring reflects real-world risk. SSH (port 22) and RDP (port 3389) give direct shell access to a server, so they get CRITICAL. Database ports (3306, 5432) expose data but require credentials, so they get HIGH.

  • Save scanner/security_groups.py.
  • You won't see output yet. The next chunk adds the helper method that checks CIDR ranges.
Add the open-to-world CIDR check

The _check_sg() method calls self._is_open_to_world() to determine whether a rule allows traffic from anyone on the internet. In AWS, 0.0.0.0/0 means "all IPv4 addresses" and ::/0 means "all IPv6 addresses."

  • Add the _is_open_to_world() method inside the SecurityGroupScanner class, below the _check_sg() method:
    def _is_open_to_world(self, permission: dict) -> bool:
        # Check IPv4 ranges for 0.0.0.0/0 (all addresses)
        for ip_range in permission.get("IpRanges", []):
            if ip_range.get("CidrIp") == "0.0.0.0/0":
                return True
        # Check IPv6 ranges for ::/0 (all addresses)
        for ipv6_range in permission.get("Ipv6Ranges", []):
            if ipv6_range.get("CidrIpv6") == "::/0":
                return True
        return False

What does this code do?

Each security group rule stores allowed IP addresses in two lists: IpRanges for IPv4 and Ipv6Ranges for IPv6. The method checks both because attackers can use either protocol.

If either list contains the "everything" CIDR block, the rule is open to the entire internet and the method returns True.

  • Save scanner/security_groups.py.
  • Verify the scanner imports correctly by running this in your terminal:
python3 -c "from scanner.security_groups import SecurityGroupScanner; print('SecurityGroupScanner imported successfully')"

You should see SecurityGroupScanner imported successfully printed in your terminal.

Seeing a ModuleNotFoundError?

  • Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt.
  • Check that you're running the command from inside the cloud-sec-scan folder, not from inside the scanner/ subfolder.
  • Confirm that scanner/__init__.py exists (even if it's empty). Python needs it to recognize the folder as a package.

Help me debug my SecurityGroupScanner import error.

Why check both IPv4 and IPv6?

Many security audits only check IPv4 (0.0.0.0/0) and miss IPv6 (::/0). An attacker with an IPv6 address can still reach resources through a rule that only appears in the Ipv6Ranges list. Checking both protocols is what separates a thorough scanner from a naive one.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete scanner/security_groups.py file. Make sure yours matches exactly:

import boto3
from botocore.exceptions import ClientError
from scanner.base import BaseScanner, Finding, Severity

SENSITIVE_PORTS = {
    22: "SSH",
    3389: "RDP",
    3306: "MySQL",
    5432: "PostgreSQL",
}


class SecurityGroupScanner(BaseScanner):
    def __init__(self):
        self.client = boto3.client("ec2")

    def scan(self) -> list[Finding]:
        findings = []
        paginator = self.client.get_paginator("describe_security_groups")

        for page in paginator.paginate():
            for sg in page["SecurityGroups"]:
                findings.extend(self._check_sg(sg))

        return findings

    def _check_sg(self, sg: dict) -> list[Finding]:
        findings = []
        sg_id = sg["GroupId"]
        sg_name = sg["GroupName"]

        for permission in sg["IpPermissions"]:
            from_port = permission.get("FromPort", 0)
            to_port = permission.get("ToPort", 0)

            for port, service in SENSITIVE_PORTS.items():
                if from_port <= port <= to_port:
                    if self._is_open_to_world(permission):
                        severity = (
                            Severity.CRITICAL
                            if port in (22, 3389)
                            else Severity.HIGH
                        )
                        findings.append(
                            Finding(
                                description=f"{service} (port {port}) is open to the world in security group {sg_name}",
                                severity=severity,
                                resource_id=sg_id,
                                recommendation=f"Restrict {service} access to specific IP ranges instead of 0.0.0.0/0",
                            )
                        )

        return findings

    def _is_open_to_world(self, permission: dict) -> bool:
        for ip_range in permission.get("IpRanges", []):
            if ip_range.get("CidrIp") == "0.0.0.0/0":
                return True
        for ipv6_range in permission.get("Ipv6Ranges", []):
            if ipv6_range.get("CidrIpv6") == "::/0":
                return True
        return False

Your first scanner is complete. It can detect the most common and dangerous Security Group misconfiguration: sensitive ports wide open to the internet. Next up, you'll build the S3 bucket scanner to catch public access misconfigurations.

Build the S3 Buckets Scanner

Your Security Groups scanner can now catch overly permissive network rules. The next common misconfiguration to detect sits at the storage layer.

S3 buckets that are missing public access blocks are one of the most frequently exploited AWS misconfigurations. In this step, you will build a scanner that checks every bucket in the account for missing or incomplete public access block settings.

In this step, get ready to:
  • Build an S3BucketScanner class that lists all buckets in your AWS account.
  • Detect buckets with incomplete public access block settings.
  • Handle the error case where no public access block exists at all.
Create the S3BucketScanner class and scan method

The S3 scanner follows the same pattern as your Security Groups scanner. It extends BaseScanner, creates a boto3 client for the S3 service, and implements the required scan() method.

  • Open scanner/s3_buckets.py in VS Code.
  • Add the imports, class definition, and scan method by pasting in this code:
import boto3
from botocore.exceptions import ClientError
from scanner.base import BaseScanner, Finding, Severity


class S3BucketScanner(BaseScanner):
    def __init__(self):
        self.client = boto3.client("s3")

    def scan(self) -> list[Finding]:
        findings = []
        response = self.client.list_buckets()

        for bucket in response.get("Buckets", []):
            bucket_name = bucket["Name"]
            findings.extend(self._check_public_access(bucket_name))

        return findings

What does this code do?

  • ClientError is imported from botocore.exceptions because the S3 API raises this error when a bucket has no public access block configured at all.
  • list_buckets() retrieves every S3 bucket in the account. Unlike Security Groups, there is no paginator needed here because the API returns all buckets in one response.
  • The loop passes each bucket name to _check_public_access(), which you will write next.
Write the public access block detection logic

Every S3 bucket should have a public access block with four settings enabled. Your scanner needs to handle two distinct cases: a bucket that has some settings disabled (bad), and a bucket with no public access block at all (worse).

  • Add the _check_public_access method below the scan() method by pasting this code:
    def _check_public_access(self, bucket_name: str) -> list[Finding]:
        findings = []

        try:
            response = self.client.get_public_access_block(Bucket=bucket_name)
            config = response["PublicAccessBlockConfiguration"]

            disabled_settings = []
            for setting in [
                "BlockPublicAcls",
                "IgnorePublicAcls",
                "BlockPublicPolicy",
                "RestrictPublicBuckets",
            ]:
                if not config.get(setting, False):
                    disabled_settings.append(setting)

            if disabled_settings:
                findings.append(
                    Finding(
                        description=f"Bucket {bucket_name} has incomplete public access block: {', '.join(disabled_settings)} not enabled",
                        severity=Severity.HIGH,
                        resource_id=bucket_name,
                        recommendation="Enable all four public access block settings on this bucket",
                    )
                )

        except ClientError as e:
            if e.response["Error"]["Code"] == "NoSuchPublicAccessBlockConfiguration":
                findings.append(
                    Finding(
                        description=f"Bucket {bucket_name} has no public access block configuration",
                        severity=Severity.CRITICAL,
                        resource_id=bucket_name,
                        recommendation="Add a public access block configuration with all four settings enabled",
                    )
                )

        return findings

What does this code do?

This method calls get_public_access_block(Bucket=bucket_name) to retrieve the bucket's public access block configuration. It then checks two different failure scenarios:

  • HIGH severity: The bucket has a public access block, but one or more of the four settings (BlockPublicAcls, IgnorePublicAcls, BlockPublicPolicy, RestrictPublicBuckets) is False. This means the bucket is partially protected but has gaps.
  • CRITICAL severity: The S3 API raises a ClientError with the code NoSuchPublicAccessBlockConfiguration. This means the bucket has no public access block whatsoever, making it the more dangerous case.

Why is a missing configuration worse than a partial one?

A bucket with no public access block at all has zero guardrails. Any misconfigured bucket policy or ACL can immediately expose data to the internet. A partial block at least protects some vectors, even if gaps exist.

  • Save scanner/s3_buckets.py.
  • Verify the file has no syntax errors by running this in your terminal:
python3 -c "from scanner.s3_buckets import S3BucketScanner; print('S3BucketScanner loaded successfully')"

You should see S3BucketScanner loaded successfully printed in your terminal.

Seeing an import error?

  • Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
  • Check that you are running the command from the cloud-sec-scan folder, not from inside the scanner/ subfolder.
  • Verify that the indentation of _check_public_access is inside the class body (one level of indentation from the class keyword).

Help me debug my S3BucketScanner import error.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

import boto3
from botocore.exceptions import ClientError
from scanner.base import BaseScanner, Finding, Severity


class S3BucketScanner(BaseScanner):
    def __init__(self):
        self.client = boto3.client("s3")

    def scan(self) -> list[Finding]:
        findings = []
        response = self.client.list_buckets()

        for bucket in response.get("Buckets", []):
            bucket_name = bucket["Name"]
            findings.extend(self._check_public_access(bucket_name))

        return findings

    def _check_public_access(self, bucket_name: str) -> list[Finding]:
        findings = []

        try:
            response = self.client.get_public_access_block(Bucket=bucket_name)
            config = response["PublicAccessBlockConfiguration"]

            disabled_settings = []
            for setting in [
                "BlockPublicAcls",
                "IgnorePublicAcls",
                "BlockPublicPolicy",
                "RestrictPublicBuckets",
            ]:
                if not config.get(setting, False):
                    disabled_settings.append(setting)

            if disabled_settings:
                findings.append(
                    Finding(
                        description=f"Bucket {bucket_name} has incomplete public access block: {', '.join(disabled_settings)} not enabled",
                        severity=Severity.HIGH,
                        resource_id=bucket_name,
                        recommendation="Enable all four public access block settings on this bucket",
                    )
                )

        except ClientError as e:
            if e.response["Error"]["Code"] == "NoSuchPublicAccessBlockConfiguration":
                findings.append(
                    Finding(
                        description=f"Bucket {bucket_name} has no public access block configuration",
                        severity=Severity.CRITICAL,
                        resource_id=bucket_name,
                        recommendation="Add a public access block configuration with all four settings enabled",
                    )
                )

        return findings

Your S3 scanner is ready to catch unprotected buckets. Next up, you will build the IAM policy scanner and wire all three scanners together into a working CLI tool.

Build the IAM Policies Scanner and CLI

Your Security Groups scanner catches open ports and your S3 scanner catches missing public access blocks. The last piece of the puzzle is IAM policies. A single policy with "Action": "*" grants unrestricted access to every AWS service in your account.

In this step, you will build the IAM policy scanner that detects wildcard permissions, wire all three scanners together in a CLI entry point, and run the full security scan against your AWS account.

In this step, get ready to:
  • Build the IAMPolicyScanner that inspects customer-managed policies for wildcard permissions.
  • Wire up all three scanners in a CLI that prints a severity-grouped report.
  • Run the full security scan and see your account's overall score.
Implement the IAMPolicyScanner class

IAM policies define what actions a user or role can take on which resources. Your scanner needs to fetch each customer-managed policy that is actually attached to something, then read the policy document to check for dangerous wildcards.

  • In VS Code, open scanner/iam_policies.py and add the imports, class definition, and scan() method by pasting this code:
import json
import urllib.parse
import boto3
from scanner.base import BaseScanner, Finding, Severity


class IAMPolicyScanner(BaseScanner):
    def __init__(self):
        # Create an IAM client for policy inspection
        self.client = boto3.client("iam")

    def scan(self) -> list[Finding]:
        findings = []
        # Use a paginator to handle accounts with many policies
        paginator = self.client.get_paginator("list_policies")

        # Only check customer-managed policies that are attached to a user/role/group
        for page in paginator.paginate(Scope="Local", OnlyAttached=True):
            for policy in page["Policies"]:
                policy_arn = policy["Arn"]
                version_id = policy["DefaultVersionId"]
                findings.extend(
                    self._check_policy(policy_arn, version_id, policy["PolicyName"])
                )

        return findings

What does this code do?

The scan() method fetches all customer-managed policies that are actually attached to users, groups, or roles. It uses Scope="Local" to skip AWS-managed policies (those are Amazon's responsibility, not yours).

The OnlyAttached=True filter focuses on policies that are actively granting permissions. An unattached policy sitting in your account isn't dangerous until someone attaches it.

  • Keep scanner/iam_policies.py open. You will add the detection method in the next chunk.
Add wildcard permission detection

The policy document returned by AWS can arrive in two formats: a pre-decoded dictionary or a URL-encoded JSON string. Your detection method needs to handle both, then inspect each statement for wildcard actions and wildcard resources.

  • In scanner/iam_policies.py, add the _check_policy() method below the scan() method, inside the IAMPolicyScanner class:
    def _check_policy(
        self, policy_arn: str, version_id: str, policy_name: str
    ) -> list[Finding]:
        findings = []

        # Fetch the actual policy document for this version
        response = self.client.get_policy_version(
            PolicyArn=policy_arn, VersionId=version_id
        )
        document = response["PolicyVersion"]["Document"]

        # Handle URL-encoded policy documents (AWS API can return either format)
        if isinstance(document, str):
            document = json.loads(urllib.parse.unquote(document))

        # Normalize statements to always be a list
        statements = document.get("Statement", [])
        if isinstance(statements, dict):
            statements = [statements]

        for statement in statements:
            # Only check Allow statements (Deny statements restrict, not grant)
            if statement.get("Effect") != "Allow":
                continue

            # Normalize actions and resources to lists
            actions = statement.get("Action", [])
            if isinstance(actions, str):
                actions = [actions]

            resources = statement.get("Resource", [])
            if isinstance(resources, str):
                resources = [resources]

            # CRITICAL: Action wildcard means unrestricted access to all services
            if "*" in actions:
                findings.append(
                    Finding(
                        description=f"Policy {policy_name} grants wildcard actions (Action: *)",
                        severity=Severity.CRITICAL,
                        resource_id=policy_arn,
                        recommendation="Replace wildcard actions with specific, least-privilege actions",
                    )
                )

            # HIGH: Resource wildcard means the action applies to everything
            if "*" in resources and "*" not in actions:
                findings.append(
                    Finding(
                        description=f"Policy {policy_name} grants access to all resources (Resource: *)",
                        severity=Severity.HIGH,
                        resource_id=policy_arn,
                        recommendation="Scope resources to specific ARNs instead of using wildcard",
                    )
                )

        return findings

Why two separate wildcard checks?

A policy with "Action": "*" is CRITICAL because it grants permission to do anything. This is the "admin access" anti-pattern.

A policy with "Resource": "*" (but specific actions) is HIGH severity. It means the actions apply to every resource instead of being scoped to specific ARNs. The second check uses "*" not in actions to avoid double-flagging a single statement.

  • Save scanner/iam_policies.py.

Seeing indentation errors?

The _check_policy() method must be indented one level inside the IAMPolicyScanner class, at the same indentation level as scan(). Make sure it starts with exactly 4 spaces.

help me fix indentation in my IAMPolicyScanner class

✔️ Awesome, I've got everything!

Great. Make sure you have saved scanner/iam_policies.py before moving on.

ⓧ I'd like to double check the full code

import json
import urllib.parse
import boto3
from scanner.base import BaseScanner, Finding, Severity


class IAMPolicyScanner(BaseScanner):
    def __init__(self):
        self.client = boto3.client("iam")

    def scan(self) -> list[Finding]:
        findings = []
        paginator = self.client.get_paginator("list_policies")

        for page in paginator.paginate(Scope="Local", OnlyAttached=True):
            for policy in page["Policies"]:
                policy_arn = policy["Arn"]
                version_id = policy["DefaultVersionId"]
                findings.extend(
                    self._check_policy(policy_arn, version_id, policy["PolicyName"])
                )

        return findings

    def _check_policy(
        self, policy_arn: str, version_id: str, policy_name: str
    ) -> list[Finding]:
        findings = []

        response = self.client.get_policy_version(
            PolicyArn=policy_arn, VersionId=version_id
        )
        document = response["PolicyVersion"]["Document"]

        if isinstance(document, str):
            document = json.loads(urllib.parse.unquote(document))

        statements = document.get("Statement", [])
        if isinstance(statements, dict):
            statements = [statements]

        for statement in statements:
            if statement.get("Effect") != "Allow":
                continue

            actions = statement.get("Action", [])
            if isinstance(actions, str):
                actions = [actions]

            resources = statement.get("Resource", [])
            if isinstance(resources, str):
                resources = [resources]

            if "*" in actions:
                findings.append(
                    Finding(
                        description=f"Policy {policy_name} grants wildcard actions (Action: *)",
                        severity=Severity.CRITICAL,
                        resource_id=policy_arn,
                        recommendation="Replace wildcard actions with specific, least-privilege actions",
                    )
                )

            if "*" in resources and "*" not in actions:
                findings.append(
                    Finding(
                        description=f"Policy {policy_name} grants access to all resources (Resource: *)",
                        severity=Severity.HIGH,
                        resource_id=policy_arn,
                        recommendation="Scope resources to specific ARNs instead of using wildcard",
                    )
                )

        return findings

Why only customer-managed policies?

AWS-managed policies (like AdministratorAccess) are maintained by Amazon. Your scanner uses Scope='Local' to focus on policies your team wrote. These are where misconfigurations creep in because they are authored manually without the same review process.

Build the CLI entry point

With all three scanners complete, you need two more files to make everything work together. The package init file makes the scanners importable, and main.py orchestrates the full scan and formats the output.

  • Open scanner/__init__.py and add these imports to make the scanner classes available as a package:
from scanner.base import BaseScanner, Finding, Severity
from scanner.security_groups import SecurityGroupScanner
from scanner.s3_buckets import S3BucketScanner
from scanner.iam_policies import IAMPolicyScanner

What does this file do?

The __init__.py file turns the scanner/ folder into a Python package. By importing the classes here, other files can write from scanner import SecurityGroupScanner instead of the longer from scanner.security_groups import SecurityGroupScanner.

  • Save scanner/__init__.py.
  • Open main.py and add the print_report() function that formats scan results by severity:
from scanner import SecurityGroupScanner, S3BucketScanner, IAMPolicyScanner
from scanner.base import Finding, Severity
from scoring import calculate_score


def print_report(findings: list[Finding], score: int) -> None:
    # Print the report header
    print("\n" + "=" * 60)
    print("  AWS CLOUD SECURITY SCAN REPORT")
    print("=" * 60)

    if not findings:
        print("\n  No security issues found. Your account looks good!")
    else:
        # Group findings by severity level
        for severity in Severity:
            severity_findings = [f for f in findings if f.severity == severity]
            if severity_findings:
                print(f"\n  [{severity.value}] ({len(severity_findings)} findings)")
                print("  " + "-" * 40)
                for finding in severity_findings:
                    print(f"    Resource: {finding.resource_id}")
                    print(f"    Issue:    {finding.description}")
                    print(f"    Fix:      {finding.recommendation}")
                    print()

    # Print the overall score
    print("=" * 60)
    print(f"  OVERALL SECURITY SCORE: {score}/100")
    print("=" * 60 + "\n")

What does this code do?

The print_report() function iterates through each severity level (CRITICAL, HIGH, MEDIUM, LOW) and groups findings under their respective headers. Each finding shows the affected resource, the issue description, and a remediation recommendation.

The overall security score (from your scoring engine) is printed at the bottom. A clean account scores 100/100.

  • Below the print_report() function in main.py, add the main() function and the entry-point guard:
def main():
    print("\nStarting AWS security scan...")

    # Instantiate all three domain scanners
    scanners = [
        SecurityGroupScanner(),
        S3BucketScanner(),
        IAMPolicyScanner(),
    ]

    # Run each scanner and collect all findings
    all_findings = []
    for scanner in scanners:
        scanner_name = scanner.__class__.__name__
        print(f"  Running {scanner_name}...")
        findings = scanner.scan()
        all_findings.extend(findings)
        print(f"    Found {len(findings)} issue(s)")

    # Calculate the overall score and print the report
    score = calculate_score(all_findings)
    print_report(all_findings, score)


if __name__ == "__main__":
    main()

What does this code do?

The main() function creates an instance of each scanner, loops through them, and collects all findings into a single list. It prints progress as each scanner runs so you can see which domain is being checked.

The if __name__ == "__main__": guard ensures main() only runs when you execute the file directly, not when it's imported by another module.

  • Save main.py.

Seeing a ModuleNotFoundError?

Make sure your virtual environment is still active. You should see (venv) at the start of your terminal prompt. If not, reactivate it with source venv/bin/activate.

Also confirm that scanner/__init__.py is saved. Without it, Python cannot find the scanner package.

help me fix a ModuleNotFoundError in my security scanner project

✔️ Awesome, I've got everything!

Great. Double check you have saved both scanner/__init__.py and main.py.

ⓧ I'd like to double check the full code

from scanner.base import BaseScanner, Finding, Severity
from scanner.security_groups import SecurityGroupScanner
from scanner.s3_buckets import S3BucketScanner
from scanner.iam_policies import IAMPolicyScanner
from scanner import SecurityGroupScanner, S3BucketScanner, IAMPolicyScanner
from scanner.base import Finding, Severity
from scoring import calculate_score


def print_report(findings: list[Finding], score: int) -> None:
    print("\n" + "=" * 60)
    print("  AWS CLOUD SECURITY SCAN REPORT")
    print("=" * 60)

    if not findings:
        print("\n  No security issues found. Your account looks good!")
    else:
        for severity in Severity:
            severity_findings = [f for f in findings if f.severity == severity]
            if severity_findings:
                print(f"\n  [{severity.value}] ({len(severity_findings)} findings)")
                print("  " + "-" * 40)
                for finding in severity_findings:
                    print(f"    Resource: {finding.resource_id}")
                    print(f"    Issue:    {finding.description}")
                    print(f"    Fix:      {finding.recommendation}")
                    print()

    print("=" * 60)
    print(f"  OVERALL SECURITY SCORE: {score}/100")
    print("=" * 60 + "\n")


def main():
    print("\nStarting AWS security scan...")

    scanners = [
        SecurityGroupScanner(),
        S3BucketScanner(),
        IAMPolicyScanner(),
    ]

    all_findings = []
    for scanner in scanners:
        scanner_name = scanner.__class__.__name__
        print(f"  Running {scanner_name}...")
        findings = scanner.scan()
        all_findings.extend(findings)
        print(f"    Found {len(findings)} issue(s)")

    score = calculate_score(all_findings)
    print_report(all_findings, score)


if __name__ == "__main__":
    main()
  • Run the full security scan against your AWS account by running this command in your terminal:
python main.py

You should see progress output as each scanner runs, followed by a formatted report showing any findings grouped by severity and your overall security score.

What should I see?

If your AWS account has no misconfigurations, you will see No security issues found. Your account looks good! and a score of 100/100. This is common for fresh accounts or accounts with strong security defaults.

If your account has open security groups, S3 buckets without public access blocks, or IAM policies with wildcard permissions, you will see those findings listed under their severity level. Each finding includes the affected resource, the issue, and a recommended fix.

Getting an AccessDenied or UnauthorizedAccess error?

Your IAM user needs read-only permissions for EC2, S3, and IAM. The simplest fix is to attach the AWS-managed ReadOnlyAccess policy to your IAM user in the AWS console.

Also verify your credentials are configured correctly by running aws sts get-caller-identity in your terminal. If that fails, re-run aws configure and check your Access Key ID and Secret Access Key.

help me fix AWS access denied errors in my security scanner

Secret mission

Add CloudTrail Audit Scanner

Your scanner covers Security Groups, S3, and IAM. But there's a fourth pillar of AWS security that many teams overlook: audit logging. Can you extend your tool to detect accounts where CloudTrail is disabled or misconfigured?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and only makes read-only AWS API calls. No AWS resources were created, modified, or charged to your account.

Decide whether to keep your scanner code for future use, pause the virtual environment, or delete everything entirely.

Resources you used:

  • The cloud-sec-scan project folder on your Desktop (contains all Python source files and the virtual environment).
  • AWS CLI v2 installed at /usr/local/aws-cli.
  • AWS credentials stored in ~/.aws/ (shared across all AWS SDKs and tools on your machine).

Keep everything running

No action needed. Choose this if you plan to extend the scanner with additional checks or use it regularly to audit your AWS account.

  • Your virtual environment stays activated until you close the terminal or run deactivate.
  • AWS CLI and your credentials remain configured for future AWS projects.
  • The scanner code is ready to run anytime with python main.py from the project folder.

Pause - I'll come back to this later

Shut down the virtual environment to free up your terminal. Your files and AWS credentials stay intact for when you return.

  • Deactivate the virtual environment by running:
deactivate
  • When you come back, navigate to the cloud-sec-scan folder and reactivate with:
source venv/bin/activate

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to clean up completely.

  • Deactivate the virtual environment first (if still active):
deactivate
  • Delete the project folder by running:
cd ~/Desktop
rm -rf cloud-sec-scan
sudo rm /usr/local/bin/aws
sudo rm /usr/local/bin/aws_completer
sudo rm -rf /usr/local/aws-cli
  • Remove your stored AWS credentials and configuration:
rm -rf ~/.aws

Removing credentials affects other AWS tools

The ~/.aws/ directory is shared across all AWS SDKs and tools on your machine. If you use other AWS tools (like the AWS SDK for Python in other projects), deleting this folder removes their access too.

Only remove it if you are certain no other tools on your machine depend on these credentials.

Nice Work!

Nice Work!

You just built a professional-grade AWS security scanner from scratch. Your tool connects to a live AWS account, inspects three critical security domains, scores the findings by severity, and prints an actionable report to the terminal.

You've learned how to:

  • Built a modular Python CLI security scanner using base class inheritance and three domain-specific scanners that each implement the same interface.
  • Audited Security Groups, S3 buckets, and IAM policies for common misconfigurations that attackers actively exploit.
  • Implemented a severity scoring engine that calculates an overall account security score from weighted findings.
  • Secret Mission: Added a CloudTrail scanner that detects disabled logging and missing multi-region trail configuration.

Ready to quiz yourself?