AWS Infrastructure Security Scanner
Build a Python CLI tool that scans AWS for security misconfigurations.
Introduction
30 Second Summary
Every AWS account accumulates security misconfigurations over time. Unencrypted storage, overly permissive firewall rules, and missing tags are silent risks that go unnoticed until an automated scanner catches them.
In this project, you will build a Python command-line tool that connects to your real AWS account, audits your infrastructure for common security issues, and generates a color-coded report of findings.
What You'll Build
A Python CLI security scanner that queries AWS APIs using boto3 and outputs a terminal report with findings categorized by severity.
By the end of this project, you'll have:
- A Python virtual environment with boto3 installed and verified connectivity to your AWS account.
- Modular check functions that scan S3 encryption, EC2 security group rules, and EC2 tag compliance.
- A formatted terminal report with color-coded severity levels (HIGH, MEDIUM, LOW) and a summary count of findings.
- Secret Mission: Extend the scanner to audit IAM users without MFA enabled, including pagination handling for large accounts.
Are there any prerequisites?
This project assumes you have an AWS account with the AWS CLI already configured, Python 3.10 or higher installed, and VS Code ready to go. All API calls in this project are read-only, so there is zero risk of accidentally creating or deleting resources in your account.
Before We Start
Before We Start...
Set Up the Project
Before you can scan anything, you need a workspace for your scanner code and a way to talk to AWS from Python.
In this step, you will create a project directory, set up a Python virtual environment with boto3 installed, verify your AWS credentials work, and lay out the file structure for the scanner.
In this step, get ready to:
- Create a project directory and Python virtual environment.
- Verify AWS connectivity with a test script.
- Create the project file structure for the scanner.
Create the project directory and virtual environment
A virtual environment keeps your project's packages isolated from your system Python. This prevents version conflicts between projects.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir aws-scanner
cd aws-scanner
- Confirm the folder was created by running:
pwd
You should see a path ending in aws-scanner. This confirms you are inside your new project folder.
- Create a Python virtual environment by running:
python3 -m venv venv
What does this command do?
python3 -m venv venv tells Python to run its built-in venv module and create a new virtual environment in a folder called venv.
This folder contains its own Python binary and a clean pip install. Any packages you install while the environment is active stay inside this folder.
- Activate the virtual environment:
macOS
- Run this command to activate:
source venv/bin/activate
This command also works on Linux.
Windows
- Run this command to activate:
.\venv\Scripts\activate
You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.
Don't see (venv) in your prompt?
Make sure you ran the activation command from inside the aws-scanner folder. Run pwd (macOS) or cd (Windows) to check your current directory.
If you see a "file not found" error, the venv folder may not have been created. Re-run python3 -m venv venv and try activating again.
help me activate my Python virtual environment
- Install boto3 by running:
pip install boto3
You should see output ending with Successfully installed followed by package names including boto3.
Seeing a permission error or install failure?
Make sure (venv) is showing in your terminal prompt. If it is not, the virtual environment is not active and pip may be trying to install system-wide.
help me install boto3 in my virtual environment
Verify AWS connectivity with a test script
Before building the scanner, you need to confirm that Python can reach your AWS account. A quick test script will call the STS (Security Token Service) API to retrieve your account details.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the aws-scanner folder on your Desktop, then click Open.
- Create a new file called test_connection.py by clicking the new file icon in the Explorer sidebar.
- Copy the following code into test_connection.py:
import boto3
from botocore.exceptions import ClientError
def test_aws_connection():
try:
sts = boto3.client("sts")
identity = sts.get_caller_identity()
print("AWS Connection Successful!")
print(f" Account: {identity['Account']}")
print(f" ARN: {identity['Arn']}")
return True
except ClientError as e:
print(f"AWS Connection Failed: {e}")
return False
if __name__ == "__main__":
test_aws_connection()
What does this code do?
boto3.client("sts") creates a connection to the AWS Security Token Service. This is the simplest API call you can make to verify credentials.
get_caller_identity() returns your AWS account ID and ARN. If this works, your credentials are valid and boto3 can talk to AWS.
The try/except ClientError block catches any AWS-specific errors (expired credentials, missing config). This is the standard boto3 error handling pattern you will use throughout the project.
- Save test_connection.py.
- Run the test script in your terminal:
python test_connection.py
You should see output like this:
AWS Connection Successful!
Account: 123456789012
ARN: arn:aws:iam::123456789012:user/your-username
Seeing "AWS Connection Failed"?
This means your AWS CLI credentials are not configured correctly. Run aws sts get-caller-identity in your terminal to check whether the CLI itself works.
If the CLI command also fails, your credentials may be expired or missing. Make sure your ~/.aws/credentials file has valid access keys.
help me fix my AWS credentials for boto3
✔️ Awesome, I've got everything!
Great. Double check you have saved test_connection.py and your output shows your account ID.
ⓧ I'd like to double check the full code
import boto3
from botocore.exceptions import ClientError
def test_aws_connection():
try:
sts = boto3.client("sts")
identity = sts.get_caller_identity()
print("AWS Connection Successful!")
print(f" Account: {identity['Account']}")
print(f" ARN: {identity['Arn']}")
return True
except ClientError as e:
print(f"AWS Connection Failed: {e}")
return False
if __name__ == "__main__":
test_aws_connection()
Create the project file structure
The scanner will be modular. Each AWS service gets its own file inside a checks package folder. This keeps the code organized as the project grows.
- Create the checks directory and all project files by running these commands:
mkdir checks
touch scanner.py checks/__init__.py checks/s3_checks.py checks/ec2_checks.py
What is __init__.py?
The __init__.py file tells Python that the checks folder is a package. Without it, you cannot import functions from files inside that folder.
It can be empty for now. You will add import statements to it later when the check functions are written.
- Confirm the files inside checks exist by running:
ls checks
You should see __init__.py, ec2_checks.py, and s3_checks.py in the output.
- Also verify scanner.py exists at the project root by running:
ls scanner.py
You should see scanner.py printed back to you.
Don't see the files?
Make sure you are running the commands from inside the aws-scanner directory. Run pwd to check your current location.
If the checks folder was not created, re-run mkdir checks first, then the touch command.
help me create my project file structure
Your project is ready to go. You have a virtual environment with boto3 installed, confirmed AWS connectivity, and created the file structure for all three scanner modules.
Next up, you will write the first real check function to scan S3 bucket encryption.
Build the S3 Encryption Scanner
Your environment is set up and connected to AWS. Now it's time to write real scanning logic that interrogates your infrastructure.
Every S3 bucket has an encryption configuration that determines how objects are protected at rest. The default (SSE-S3) uses AWS-managed keys, but organizations often require SSE-KMS for finer access control. Your scanner will flag buckets that rely on the weaker default.
In this step, get ready to:
- Write a function that lists all S3 buckets and inspects their encryption settings.
- Structure security findings as a list of dictionaries with severity levels.
- Test the scanner against your real AWS account and verify the output.
Write the bucket encryption check function
This function will connect to the S3 API, retrieve every bucket in your account, and check what encryption algorithm each one uses. It also handles the case where your credentials don't have permission to read a bucket's encryption.
- Add the following code to checks/s3_checks.py:
import boto3
from botocore.exceptions import ClientError
def check_bucket_encryption():
findings = []
s3 = boto3.client("s3")
try:
response = s3.list_buckets()
buckets = response["Buckets"]
except ClientError as e:
print(f" Error listing buckets: {e}")
return findings
for bucket in buckets:
bucket_name = bucket["Name"]
try:
enc_response = s3.get_bucket_encryption(Bucket=bucket_name)
rules = enc_response["ServerSideEncryptionConfiguration"]["Rules"]
algorithm = rules[0]["ApplyServerSideEncryptionByDefault"]["SSEAlgorithm"]
if algorithm == "AES256":
findings.append({
"service": "S3",
"resource": bucket_name,
"severity": "MEDIUM",
"message": "Bucket uses default SSE-S3 encryption (AES256). Consider upgrading to SSE-KMS for key management control.",
})
else:
findings.append({
"service": "S3",
"resource": bucket_name,
"severity": "LOW",
"message": f"Bucket uses {algorithm} encryption. Good.",
})
except ClientError as e:
error_code = e.response["Error"]["Code"]
if error_code == "AccessDenied":
findings.append({
"service": "S3",
"resource": bucket_name,
"severity": "MEDIUM",
"message": "Unable to check encryption - access denied.",
})
else:
findings.append({
"service": "S3",
"resource": bucket_name,
"severity": "MEDIUM",
"message": f"Error checking encryption: {error_code}",
})
return findings
What does this code do?
- findings = [] creates an empty list that accumulates every security issue the function discovers.
- s3.list_buckets() calls the S3 API to retrieve every bucket in your account. The response is a Python dictionary with a Buckets key containing a list of bucket dictionaries.
- s3.get_bucket_encryption() inspects a single bucket's server-side encryption configuration. The algorithm value tells us whether it uses AES256 (basic SSE-S3) or aws:kms (stronger key management).
- The except ClientError blocks handle two failure scenarios: an initial failure to list buckets (returns early with an empty list), and per-bucket access denied errors (records a finding and continues to the next bucket).
- Save checks/s3_checks.py.
- You won't see a change yet. The next chunk wires this function into scanner.py so you can run it.
Seeing a syntax error on save?
Check that every opening brace { in the findings.append() calls has a matching closing brace }. Each dictionary inside append() ends with }).
Also confirm the indentation is consistent. The for bucket in buckets: loop should be at the same level as the first try: block above it.
help me fix a syntax error in my s3_checks.py file
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import boto3
from botocore.exceptions import ClientError
def check_bucket_encryption():
findings = []
s3 = boto3.client("s3")
try:
response = s3.list_buckets()
buckets = response["Buckets"]
except ClientError as e:
print(f" Error listing buckets: {e}")
return findings
for bucket in buckets:
bucket_name = bucket["Name"]
try:
enc_response = s3.get_bucket_encryption(Bucket=bucket_name)
rules = enc_response["ServerSideEncryptionConfiguration"]["Rules"]
algorithm = rules[0]["ApplyServerSideEncryptionByDefault"]["SSEAlgorithm"]
if algorithm == "AES256":
findings.append({
"service": "S3",
"resource": bucket_name,
"severity": "MEDIUM",
"message": "Bucket uses default SSE-S3 encryption (AES256). Consider upgrading to SSE-KMS for key management control.",
})
else:
findings.append({
"service": "S3",
"resource": bucket_name,
"severity": "LOW",
"message": f"Bucket uses {algorithm} encryption. Good.",
})
except ClientError as e:
error_code = e.response["Error"]["Code"]
if error_code == "AccessDenied":
findings.append({
"service": "S3",
"resource": bucket_name,
"severity": "MEDIUM",
"message": "Unable to check encryption - access denied.",
})
else:
findings.append({
"service": "S3",
"resource": bucket_name,
"severity": "MEDIUM",
"message": f"Error checking encryption: {error_code}",
})
return findings
Wire up the imports and test the scanner
The function exists but nothing calls it yet. You need to export it from the checks package and call it from your main entry point.
- Add the following import line to checks/__init__.py:
from checks.s3_checks import check_bucket_encryption
- Save checks/__init__.py.
- Now add the following code to scanner.py to import and run the check function:
from checks import check_bucket_encryption
findings = check_bucket_encryption()
for f in findings:
print(f)
What does this code do?
The __init__.py file makes the checks folder a Python package. The import line re-exports check_bucket_encryption so that scanner.py can import it with a short path: from checks import check_bucket_encryption.
The loop at the bottom prints each finding dictionary as raw output so you can confirm the function works before formatting a proper report later.
- Save scanner.py.
- Run the scanner against your real AWS account by running this command:
python scanner.py
You should see one dictionary printed per S3 bucket in your account. Each dictionary contains service, resource, severity, and message keys.
What should I see?
If your buckets use default encryption, you'll see MEDIUM severity findings mentioning SSE-S3. If any buckets use KMS, those will appear as LOW severity. Your actual bucket names will appear in the resource field.
If you have no S3 buckets at all, the output will be empty. That's fine. The function ran without errors, which confirms your code and AWS credentials are working.
Seeing a ModuleNotFoundError?
Make sure you're running python scanner.py from inside the aws-scanner directory (the folder that contains both scanner.py and the checks folder).
Also confirm that your virtual environment is still active. You should see (venv) at the start of your terminal prompt. If not, activate it again with source venv/bin/activate.
help me debug a ModuleNotFoundError in my scanner
✔️ Awesome, I've got everything!
Great. Make sure both checks/__init__.py and scanner.py are saved.
ⓧ I'd like to double check the full code
from checks.s3_checks import check_bucket_encryption
from checks import check_bucket_encryption
findings = check_bucket_encryption()
for f in findings:
print(f)
Your scanner already talks to real AWS infrastructure and returns structured findings. Next up, you'll build a security group scanner that checks for dangerously open network ports.
Build the Security Group Scanner
Your S3 encryption checker is working and returning real findings from your AWS account. Now it's time to scan a different attack surface: network access.
Security groups are the firewall rules that control which traffic can reach your EC2 instances. A single misconfigured rule that allows 0.0.0.0/0 (the entire internet) to reach a sensitive port like SSH is one of the most common causes of cloud breaches. In this step, you'll build a scanner that catches exactly this type of misconfiguration.
In this step, get ready to:
- Write a function that queries all EC2 security groups and inspects their inbound rules.
- Classify open ports by severity based on how dangerous they are when exposed to the internet.
- Test the scanner against your real AWS account.
Write the security group scanning function
The describe_security_groups() API returns every security group in your account along with its inbound rules. Each rule specifies which IP ranges can send traffic to which ports. Your function will walk through this nested data and flag any rule that opens a sensitive port to the entire internet.
- In VS Code, open checks/ec2_checks.py (the empty file you created earlier).
- Copy the following code into the file:
import boto3
from botocore.exceptions import ClientError
SENSITIVE_PORTS = {
22: "SSH",
3389: "RDP",
3306: "MySQL",
5432: "PostgreSQL",
}
REQUIRED_TAGS = ["Name", "Environment"]
def check_security_groups():
findings = []
ec2 = boto3.client("ec2")
try:
response = ec2.describe_security_groups()
security_groups = response["SecurityGroups"]
except ClientError as e:
print(f" Error describing security groups: {e}")
return findings
for sg in security_groups:
group_id = sg["GroupId"]
group_name = sg["GroupName"]
for rule in sg["IpPermissions"]:
ip_protocol = rule.get("IpProtocol", "")
from_port = rule.get("FromPort", 0)
to_port = rule.get("ToPort", 0)
for ip_range in rule.get("IpRanges", []):
cidr = ip_range.get("CidrIp", "")
if cidr == "0.0.0.0/0":
if ip_protocol == "-1":
findings.append({
"service": "EC2",
"resource": f"{group_id} ({group_name})",
"severity": "HIGH",
"message": "All traffic allowed from 0.0.0.0/0 (all ports, all protocols).",
})
else:
for port, service_name in SENSITIVE_PORTS.items():
if from_port <= port <= to_port:
severity = "HIGH" if port in (22, 3389) else "MEDIUM"
findings.append({
"service": "EC2",
"resource": f"{group_id} ({group_name})",
"severity": severity,
"message": f"Port {port} ({service_name}) open to 0.0.0.0/0.",
})
return findings
What does this code do?
This function follows the same pattern as your S3 checker: create a client, call an API, then iterate through the response looking for problems.
- SENSITIVE_PORTS is a dictionary mapping port numbers to human-readable service names. This makes findings more useful than just showing a number.
- REQUIRED_TAGS defines which tags every EC2 instance should have. You'll use this in the next step.
- describe_security_groups() returns all security groups with their inbound rules (IpPermissions).
- The three nested for loops walk from security groups, into each rule, then into each IP range to find rules that allow 0.0.0.0/0.
Understand the severity classification logic
The severity assignment inside the function uses two layers of decision-making. This is where the scanner becomes more than a simple list. It categorizes risk.
How does the severity logic work?
When a rule allows traffic from 0.0.0.0/0 (the entire internet), the function checks two things:
- If IpProtocol is -1, it means ALL traffic on ALL ports is allowed. This is always HIGH severity because the entire machine is exposed.
- If a specific port range is open, the function checks whether any sensitive port falls within that range. SSH (22) and RDP (3389) are HIGH because they give direct shell access. Database ports (3306, 5432) are MEDIUM because they expose data stores.
Why are SSH and RDP rated higher than database ports?
SSH and RDP give an attacker a direct interactive session on the machine itself. From there they can pivot to other resources, install malware, or exfiltrate data. An open database port is dangerous, but still requires valid credentials to do damage.
- Save checks/ec2_checks.py.
Seeing a syntax error?
- Check that every opening brace { in the SENSITIVE_PORTS dictionary has a matching closing brace }.
- Make sure the f-strings inside findings.append() use double quotes for the outer string and curly braces for the variables.
- Verify that the indentation inside the nested for loops is consistent. Python uses indentation to determine scope.
Help me fix a syntax error in my check_security_groups function
Test the security group scanner
Your function is written. Now you need to wire it into scanner.py and run the whole thing against your real AWS account.
- Open checks/__init__.py and find this line:
from checks.s3_checks import check_bucket_encryption
- Add this line directly below it:
from checks.ec2_checks import check_security_groups
What does this change do?
The __init__.py file controls what gets exported from the checks package. By adding this import, you can use from checks import check_security_groups in your main scanner file.
- Save checks/__init__.py.
- Now open scanner.py and replace its entire contents with the following code:
from checks import check_bucket_encryption, check_security_groups
findings = []
findings.extend(check_bucket_encryption())
findings.extend(check_security_groups())
for f in findings:
print(f)
What changed in scanner.py?
- Instead of a single function call, the scanner now uses a findings list that collects results from multiple checks using .extend().
- This pattern scales cleanly. Each new check function returns a list, and you extend the combined list with it.
- Save scanner.py.
- Run the scanner by executing this command in your terminal:
python scanner.py
- You should see your S3 findings from before, followed by any security group findings. If your account has no overly permissive security groups, the output will only contain S3 findings. That means your network rules are clean.
Seeing an ImportError?
- Make sure you saved both checks/__init__.py and checks/ec2_checks.py before running the scanner.
- Confirm that the function name in __init__.py matches exactly: check_security_groups (no typos, no capital letters).
- Check that your terminal is in the aws-scanner directory with the virtual environment still activated.
Help me fix an ImportError in my AWS scanner project
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files before moving on.
ⓧ I'd like to double check the full code
Here's the complete checks/ec2_checks.py:
import boto3
from botocore.exceptions import ClientError
SENSITIVE_PORTS = {
22: "SSH",
3389: "RDP",
3306: "MySQL",
5432: "PostgreSQL",
}
REQUIRED_TAGS = ["Name", "Environment"]
def check_security_groups():
findings = []
ec2 = boto3.client("ec2")
try:
response = ec2.describe_security_groups()
security_groups = response["SecurityGroups"]
except ClientError as e:
print(f" Error describing security groups: {e}")
return findings
for sg in security_groups:
group_id = sg["GroupId"]
group_name = sg["GroupName"]
for rule in sg["IpPermissions"]:
ip_protocol = rule.get("IpProtocol", "")
from_port = rule.get("FromPort", 0)
to_port = rule.get("ToPort", 0)
for ip_range in rule.get("IpRanges", []):
cidr = ip_range.get("CidrIp", "")
if cidr == "0.0.0.0/0":
if ip_protocol == "-1":
findings.append({
"service": "EC2",
"resource": f"{group_id} ({group_name})",
"severity": "HIGH",
"message": "All traffic allowed from 0.0.0.0/0 (all ports, all protocols).",
})
else:
for port, service_name in SENSITIVE_PORTS.items():
if from_port <= port <= to_port:
severity = "HIGH" if port in (22, 3389) else "MEDIUM"
findings.append({
"service": "EC2",
"resource": f"{group_id} ({group_name})",
"severity": severity,
"message": f"Port {port} ({service_name}) open to 0.0.0.0/0.",
})
return findings
Here's the complete checks/__init__.py:
from checks.s3_checks import check_bucket_encryption
from checks.ec2_checks import check_security_groups
Here's the complete scanner.py:
from checks import check_bucket_encryption, check_security_groups
findings = []
findings.extend(check_bucket_encryption())
findings.extend(check_security_groups())
for f in findings:
print(f)
Your scanner now covers network access in addition to encryption. Next up, you'll add a tag compliance checker that catches EC2 instances missing required metadata.
Build the Tag Compliance Checker
Security groups protect network access, but proper tagging is what keeps your AWS account organized and auditable. Without tags like Name and Environment, it becomes impossible to tell which instance belongs to which project or whether it should be running at all.
In this step, you will write a tag compliance checker that inspects every running EC2 instance and flags any that are missing required tags. This is the same pattern production governance tools use to enforce tagging policies across entire organizations.
In this step, get ready to:
- Write a tag compliance checking function for EC2 instances.
- Update the scanner to call all three check functions together.
- Run the complete scanner and verify it handles empty results gracefully.
Write the check_instance_tags() function
The describe_instances() API returns a nested structure. Instances are grouped inside Reservations, so you need to loop through two levels to reach each instance.
- In checks/ec2_checks.py, scroll below the check_security_groups() function.
- Add the following check_instance_tags() function after the last line of check_security_groups():
def check_instance_tags():
findings = []
ec2 = boto3.client("ec2")
try:
response = ec2.describe_instances()
except ClientError as e:
print(f" Error describing instances: {e}")
return findings
for reservation in response["Reservations"]:
for instance in reservation["Instances"]:
state = instance["State"]["Name"]
if state != "running":
continue
instance_id = instance["InstanceId"]
instance_type = instance.get("InstanceType", "unknown")
tags = instance.get("Tags", [])
tag_keys = [tag["Key"] for tag in tags] if tags else []
missing_tags = [t for t in REQUIRED_TAGS if t not in tag_keys]
if missing_tags:
findings.append({
"service": "EC2",
"resource": f"{instance_id} ({instance_type})",
"severity": "MEDIUM",
"message": f"Missing required tags: {', '.join(missing_tags)}.",
})
return findings
What does this code do?
This function checks every running EC2 instance for required tags. Here is how it works:
- describe_instances() returns all instances grouped inside Reservations. The nested for loops drill down to each individual instance.
- The if state != "running" check skips terminated or stopped instances. Only running instances need proper tags.
- instance.get("Tags", []) handles the edge case where an instance has no tags at all. Without this, accessing Tags directly would crash with a KeyError.
- The list comprehension [t for t in REQUIRED_TAGS if t not in tag_keys] compares the instance's tag keys against the REQUIRED_TAGS constant you defined earlier in the file.
- Save checks/ec2_checks.py.
You will not see a change yet. The next substep wires this function into the scanner.
Seeing a syntax error on save?
- Make sure the function is at the same indentation level as check_security_groups() above it (zero indentation, not nested inside another function).
- Check that the findings.append({...}) block uses consistent indentation. Mixing tabs and spaces causes Python errors.
Why is my check_instance_tags() function showing a syntax error?
Update scanner.py to call the new function
The checks/__init__.py file already imports check_instance_tags from a previous step. You just need to update scanner.py to actually call it.
- In scanner.py, find this import line at the top:
from checks import check_bucket_encryption, check_security_groups
- Replace it with this updated import that includes check_instance_tags:
from checks import check_bucket_encryption, check_security_groups, check_instance_tags
- Find this line near the bottom of the file:
findings.extend(check_security_groups())
- Add this line directly below it:
findings.extend(check_instance_tags())
- Save scanner.py.
What does this change do?
The extend() call runs check_instance_tags() and appends any findings it returns into the combined findings list. If you have no running instances, it returns an empty list and nothing gets added.
Run the scanner and verify tag checking works
- Run the complete scanner by entering this command in your terminal:
python scanner.py
You should see dictionary output from all three checks printed to the terminal. If you have running EC2 instances missing Name or Environment tags, you will see MEDIUM severity findings for them.
What if I have no EC2 instances?
That is perfectly fine. The function returns an empty list when no running instances exist. You will only see S3 and security group findings in the output. The important thing is that the script runs without any errors.
Seeing an ImportError or NameError?
- If you see an error mentioning check_instance_tags, confirm the function name in ec2_checks.py matches exactly: def check_instance_tags():.
- Check that checks/__init__.py contains from checks.ec2_checks import check_security_groups, check_instance_tags.
- Make sure your virtual environment is still active. You should see (venv) at the start of your terminal prompt.
Help me debug an ImportError or NameError when running scanner.py with check_instance_tags.
✔️ Awesome, I've got everything!
Great. Double check you have saved both checks/ec2_checks.py and scanner.py.
ⓧ I'd like to double check the full code
Here is the complete checks/ec2_checks.py file:
import boto3
from botocore.exceptions import ClientError
SENSITIVE_PORTS = {
22: "SSH",
3389: "RDP",
3306: "MySQL",
5432: "PostgreSQL",
}
REQUIRED_TAGS = ["Name", "Environment"]
def check_security_groups():
findings = []
ec2 = boto3.client("ec2")
try:
response = ec2.describe_security_groups()
security_groups = response["SecurityGroups"]
except ClientError as e:
print(f" Error describing security groups: {e}")
return findings
for sg in security_groups:
group_id = sg["GroupId"]
group_name = sg["GroupName"]
for rule in sg["IpPermissions"]:
ip_protocol = rule.get("IpProtocol", "")
from_port = rule.get("FromPort", 0)
to_port = rule.get("ToPort", 0)
for ip_range in rule.get("IpRanges", []):
cidr = ip_range.get("CidrIp", "")
if cidr == "0.0.0.0/0":
if ip_protocol == "-1":
findings.append({
"service": "EC2",
"resource": f"{group_id} ({group_name})",
"severity": "HIGH",
"message": "All traffic allowed from 0.0.0.0/0 (all ports, all protocols).",
})
else:
for port, service_name in SENSITIVE_PORTS.items():
if from_port <= port <= to_port:
severity = "HIGH" if port in (22, 3389) else "MEDIUM"
findings.append({
"service": "EC2",
"resource": f"{group_id} ({group_name})",
"severity": severity,
"message": f"Port {port} ({service_name}) open to 0.0.0.0/0.",
})
return findings
def check_instance_tags():
findings = []
ec2 = boto3.client("ec2")
try:
response = ec2.describe_instances()
except ClientError as e:
print(f" Error describing instances: {e}")
return findings
for reservation in response["Reservations"]:
for instance in reservation["Instances"]:
state = instance["State"]["Name"]
if state != "running":
continue
instance_id = instance["InstanceId"]
instance_type = instance.get("InstanceType", "unknown")
tags = instance.get("Tags", [])
tag_keys = [tag["Key"] for tag in tags] if tags else []
missing_tags = [t for t in REQUIRED_TAGS if t not in tag_keys]
if missing_tags:
findings.append({
"service": "EC2",
"resource": f"{instance_id} ({instance_type})",
"severity": "MEDIUM",
"message": f"Missing required tags: {', '.join(missing_tags)}.",
})
return findings
Here is the complete scanner.py file:
from checks import check_bucket_encryption, check_security_groups, check_instance_tags
findings = []
findings.extend(check_bucket_encryption())
findings.extend(check_security_groups())
findings.extend(check_instance_tags())
for f in findings:
print(f)
Your scanner now covers three distinct security concerns: encryption, network access, and tagging compliance. Next up, you will build a polished report generator that turns these raw findings into a color-coded terminal output sorted by severity.
Generate the Security Report
Your scanner now checks three resource types across S3 and EC2. The raw dictionary output works for debugging, but it's hard to scan quickly when you have dozens of findings.
In this step, you'll build a formatted report generator that sorts findings by severity and displays them with color-coded labels in your terminal. This is the same presentation pattern used by production security tools like Prowler and AWS Config.
In this step, get ready to:
- Set up ANSI color codes and severity configuration.
- Build a generate_report() function with sorting and formatted output.
- Wire all checks into a main() function and run the complete scanner.
Set up ANSI color codes and severity configuration
Terminal applications use special character sequences called ANSI escape codes to display colored text. Instead of installing an external library, you can embed these codes directly in your print statements.
- In VS Code, open scanner.py.
- Select all the existing code and replace it with the following:
import datetime
from checks import check_bucket_encryption, check_security_groups, check_instance_tags
# ANSI escape codes for terminal color output
RED = "\033[91m"
YELLOW = "\033[93m"
GREEN = "\033[92m"
BOLD = "\033[1m"
RESET = "\033[0m"
# Severity levels mapped to sort priority (lower number = higher priority)
SEVERITY_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
# Map each severity to its display color
SEVERITY_COLORS = {"HIGH": RED, "MEDIUM": YELLOW, "LOW": GREEN}
What does this code do?
The \033[91m style strings are ANSI escape codes. When your terminal encounters one, it switches the text color until it hits RESET (\033[0m).
The two dictionaries work together. SEVERITY_ORDER assigns a sort priority so HIGH findings appear first. SEVERITY_COLORS maps each severity to the right color code for display.
Why ANSI codes instead of a library?
Libraries like colorama add cross-platform color support, but ANSI codes work natively in macOS Terminal, iTerm2, and the VS Code integrated terminal. Keeping zero extra dependencies makes the scanner easier to share and deploy.
Build the color-coded report generator
The generate_report() function takes a list of finding dictionaries and transforms them into a readable, color-coded terminal report.
- Below the SEVERITY_COLORS dictionary, add the generate_report() function:
def generate_report(findings):
# Create a timestamp for when the scan ran
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Sort findings so HIGH severity appears first
sorted_findings = sorted(findings, key=lambda f: SEVERITY_ORDER.get(f["severity"], 3))
# Print the report header with scan metadata
print(f"\n{BOLD}{'=' * 60}")
print(f" AWS Infrastructure Security Scan Report")
print(f" Scan Time: {timestamp}")
print(f" Total Findings: {len(sorted_findings)}")
print(f"{'=' * 60}{RESET}\n")
# Handle the case where no issues were found
if not sorted_findings:
print(f" {GREEN}No security issues found. Your infrastructure looks clean!{RESET}\n")
return
# Print each finding with its color-coded severity label
for finding in sorted_findings:
severity = finding["severity"]
color = SEVERITY_COLORS.get(severity, RESET)
print(f" {color}[{severity}]{RESET} {BOLD}{finding['service']}{RESET} - {finding['resource']}")
print(f" {finding['message']}")
print()
# Print a summary footer with counts per severity level
print(f"{BOLD}{'=' * 60}")
print(f" Summary:")
for severity in ["HIGH", "MEDIUM", "LOW"]:
count = sum(1 for f in sorted_findings if f["severity"] == severity)
color = SEVERITY_COLORS[severity]
print(f" {color}{severity}: {count}{RESET}")
print(f"{'=' * 60}{RESET}\n")
What does this function do?
- sorted() reorders the findings list using SEVERITY_ORDER as the sort key, so HIGH findings always appear at the top.
- The header block prints a bordered box with the scan timestamp and total count, giving immediate context.
- Each finding prints with its severity in the matching color (red, yellow, or green) followed by the service name, resource, and message.
- The summary footer counts how many findings exist at each severity level, so you can see the overall risk at a glance.
The lambda sort pattern
The expression key=lambda f: SEVERITY_ORDER.get(f["severity"], 3) is a common Python pattern. The lambda creates an inline function that extracts the sort value from each dictionary. The fallback value of 3 ensures any unexpected severity ends up last.
Wire everything together and run the scanner
The main() function orchestrates the entire scan. It calls each check function, collects the results, and passes them to the report generator.
- Below the generate_report() function, add the main() function and entry point:
def main():
print(f"\n{BOLD}Starting AWS Infrastructure Security Scan...{RESET}\n")
# Collect findings from all check functions
all_findings = []
print(" Scanning S3 bucket encryption...")
all_findings.extend(check_bucket_encryption())
print(" Scanning EC2 security groups...")
all_findings.extend(check_security_groups())
print(" Scanning EC2 instance tags...")
all_findings.extend(check_instance_tags())
print(" Scan complete.")
# Generate the formatted report from all collected findings
generate_report(all_findings)
if __name__ == "__main__":
main()
What does this code do?
- main() prints progress messages as each check runs, so you can see which service is being scanned.
- all_findings.extend() appends each check function's results into a single combined list.
- The if __name__ == "__main__" block ensures main() only runs when you execute the file directly, not when it's imported.
- Save scanner.py.
- Run the complete scanner by running this command:
python scanner.py
- You should see progress messages for each check, followed by a bordered report with color-coded findings sorted by severity.
What should I see?
If your account has S3 buckets using default encryption, you'll see MEDIUM findings in yellow. If you have security groups open to the internet, those appear as HIGH in red.
If your account is well-configured, you might only see LOW (green) findings or the message "No security issues found." Both outcomes mean the scanner is working correctly.
Not seeing colors in your terminal?
- Make sure you're running the command in the VS Code integrated terminal or a native terminal app. Some older Windows terminals don't support ANSI codes natively.
- Check that your RESET variable is set to "\033[0m". A typo here causes colors to bleed across lines.
- If you see an ImportError, confirm your virtual environment is still activated (you should see (venv) in your terminal prompt).
Why isn't my scanner producing colored output?
✔️ Awesome, I've got everything!
Great. Double check you've saved scanner.py and confirmed the report output in your terminal.
ⓧ I'd like to double check the full code
Here is the complete scanner.py file:
import datetime
from checks import check_bucket_encryption, check_security_groups, check_instance_tags
RED = "\033[91m"
YELLOW = "\033[93m"
GREEN = "\033[92m"
BOLD = "\033[1m"
RESET = "\033[0m"
SEVERITY_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
SEVERITY_COLORS = {"HIGH": RED, "MEDIUM": YELLOW, "LOW": GREEN}
def generate_report(findings):
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sorted_findings = sorted(findings, key=lambda f: SEVERITY_ORDER.get(f["severity"], 3))
print(f"\n{BOLD}{'=' * 60}")
print(f" AWS Infrastructure Security Scan Report")
print(f" Scan Time: {timestamp}")
print(f" Total Findings: {len(sorted_findings)}")
print(f"{'=' * 60}{RESET}\n")
if not sorted_findings:
print(f" {GREEN}No security issues found. Your infrastructure looks clean!{RESET}\n")
return
for finding in sorted_findings:
severity = finding["severity"]
color = SEVERITY_COLORS.get(severity, RESET)
print(f" {color}[{severity}]{RESET} {BOLD}{finding['service']}{RESET} - {finding['resource']}")
print(f" {finding['message']}")
print()
print(f"{BOLD}{'=' * 60}")
print(f" Summary:")
for severity in ["HIGH", "MEDIUM", "LOW"]:
count = sum(1 for f in sorted_findings if f["severity"] == severity)
color = SEVERITY_COLORS[severity]
print(f" {color}{severity}: {count}{RESET}")
print(f"{'=' * 60}{RESET}\n")
def main():
print(f"\n{BOLD}Starting AWS Infrastructure Security Scan...{RESET}\n")
all_findings = []
print(" Scanning S3 bucket encryption...")
all_findings.extend(check_bucket_encryption())
print(" Scanning EC2 security groups...")
all_findings.extend(check_security_groups())
print(" Scanning EC2 instance tags...")
all_findings.extend(check_instance_tags())
print(" Scan complete.")
generate_report(all_findings)
if __name__ == "__main__":
main()
Your scanner is complete. You've built a modular Python CLI tool that authenticates to AWS, queries real infrastructure across multiple services, and presents its findings in a professional security report.
Secret mission
Add IAM MFA Compliance Check
Your scanner covers S3 and EC2, but one of the most critical security gaps in any AWS account is IAM users without multi-factor authentication. Extend your scanner to audit every IAM user and flag anyone missing MFA as a HIGH severity finding.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally and uses only read-only AWS API calls. There are no ongoing costs and no cloud resources to tear down.
Decide whether to keep your project files, deactivate the virtual environment, or delete everything entirely.
Resources you used:
- The aws-scanner project directory on your Desktop (contains all Python files and the virtual environment).
Keep everything running
No action needed. Choose this if you want to keep extending your scanner or running it again later.
- Your aws-scanner folder stays on your Desktop with all files intact.
- The virtual environment stays inside the project folder. Reactivate it any time with source venv/bin/activate (macOS/Linux) or venv\Scripts\activate (Windows).
- No AWS resources were created, so there is nothing accumulating cost in your account.
Pause - I'll come back to this later
Deactivate the virtual environment to free up your terminal session. Your files stay on disk for later.
- Deactivate the virtual environment by running:
deactivate
- Your project files remain in ~/Desktop/aws-scanner. When you want to resume, navigate back to that folder and reactivate the virtual environment.
Delete - I don't want to use this again
Remove the entire project directory from your machine. This deletes all Python files and the virtual environment permanently.
- Deactivate the virtual environment first (if it is still active):
deactivate
- Delete the aws-scanner directory by running:
macOS
rm -rf ~/Desktop/aws-scanner
Windows
rmdir /s /q %USERPROFILE%\Desktop\aws-scanner
- Confirm deletion by running ls ~/Desktop (macOS) or dir %USERPROFILE%\Desktop (Windows). You should no longer see aws-scanner in the output.
Nice Work!
Nice Work!
You just built a production-style infrastructure security scanner from scratch using Python and the AWS SDK. This is a real tool that audits real cloud resources for real misconfigurations.
You've learned how to:
- Build a Python CLI tool that authenticates to AWS and queries real infrastructure using boto3.
- Parse complex nested API responses (dictionaries inside lists inside dictionaries) and implement error handling with try/except for real-world failure scenarios.
- Structure a modular Python project with separate check files and a color-coded terminal report that categorizes findings by severity.
- Secret Mission: Added IAM MFA auditing with cross-API correlation and pagination handling for accounts with many users.
Ready to quiz yourself?