IAM Risk Analyzer: Security Engine
Build a Python CLI tool that analyzes AWS IAM policies for security risks.
Introduction
30 Second Summary
Cloud security breaches make headlines constantly, and the leading cause is misconfigured permissions. When one policy grants too much access, an attacker can take over an entire cloud account.
In this project, you will build a Python command-line tool that reads AWS IAM policy files and flags dangerous permission patterns. Your tool calculates risk scores, explains potential attack paths in plain English, and exports professional security reports.
What You'll Build
A modular Python CLI application that analyzes AWS IAM policy JSON files for security risks entirely offline.
By the end of this project, you'll have:
- A modular Python project with src-layout architecture and 7 source modules that cleanly separate data models, analysis rules, scoring, and reporting.
- A risk detection engine that identifies wildcard permissions, privilege escalation paths, and overly permissive access patterns.
- A multi-format reporter that generates colored console output, JSON exports, and Markdown documents with risk scores and attack path explanations.
- Secret Mission: Add a pytest unit test suite to validate your detection rules and scoring algorithm.
Are there any prerequisites?
You need basic programming knowledge (variables, functions, loops, conditionals). No prior Python or AWS experience is required.
Set Up the Project
Before you can analyze IAM policies, you need a properly structured Python project with the right tools installed. Python gives you the language runtime, and a src-layout project structure keeps your source code cleanly separated from tests and configuration.
In this step, you will install Python 3.12, scaffold a project directory with the recommended src-layout, and install two libraries: Typer for building the CLI and Rich for colored terminal output.
In this step, get ready to:
- Install Python 3.12 and create the project directory with src-layout.
- Create a virtual environment and install Typer and Rich.
- Create the project configuration file and package init module.
Install Python 3.12 and create the project directory
This project requires Python 3.12 or higher because it uses modern type hint syntax like list[dict] without importing from typing.
- Open a terminal in VS Code by pressing Ctrl+` (macOS and Windows).
- Check your current Python version by running this command:
macOS
python3 --version
Windows
python --version
✔️ I see version 3.12 or higher
Python 3.12 is already installed. You can move on to creating the project directory below.
ⓧ I see an older version
You have Python installed, but it is an older version (like 3.10 or 3.11). You need to install Python 3.12 alongside it.
macOS
- Install Python 3.12 using Homebrew by running:
brew install python@3.12
After installation, the binary is named python3.12. Your system python3 may still point to the older version.
- Verify the install by running:
python3.12 --version
You should see Python 3.12.x in the output.
Windows
- Download the Python 3.12 installer from python.org/downloads.
- Run the installer. On the first screen, check the box labeled Add Python to PATH, then click Install Now.
- After installation completes, close and reopen your terminal.
- Verify the install by running:
python --version
You should see Python 3.12.x in the output.
ⓧ Command not found
Python is not installed on your machine yet. Follow the instructions below to install Python 3.12.
macOS
- Install Python 3.12 using Homebrew by running:
brew install python@3.12
- Verify the install by running:
python3.12 --version
You should see Python 3.12.x in the output.
Windows
- Download the Python 3.12 installer from python.org/downloads.
- Run the installer. On the first screen, check the box labeled Add Python to PATH, then click Install Now.
- After installation completes, close and reopen your terminal.
- Verify the install by running:
python --version
You should see Python 3.12.x in the output.
Python install not working?
On macOS, if brew is not found, install Homebrew first from brew.sh. On Windows, make sure you checked Add Python to PATH during installation. If you missed it, re-run the installer and select Modify.
help me install Python 3.12 on my machine
Now create the project directory. The src-layout keeps your source code inside a src/ folder, which prevents accidental imports during testing.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and its internal directories by running these commands:
macOS
mkdir iam-risk-analyzer
cd iam-risk-analyzer
mkdir -p src/iam_analyzer policies
Windows
mkdir iam-risk-analyzer
cd iam-risk-analyzer
mkdir src\iam_analyzer
mkdir policies
- Confirm the directories exist by running:
ls
You should see policies and src in the output.
- Open this project folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the iam-risk-analyzer folder, and click Open.
Why src-layout?
The src-layout is the recommended structure for installable Python packages. It forces Python to import the installed version of your code during testing, not the raw source files in your working directory. This catches packaging bugs early.
Create a virtual environment and install dependencies
A virtual environment isolates this project's packages from your system Python. This prevents version conflicts between projects.
- Open a new terminal in VS Code by pressing Ctrl+`. Make sure you are inside the iam-risk-analyzer folder.
- Create a virtual environment by running this command:
macOS
python3.12 -m venv .venv
Windows
python -m venv .venv
- Activate the virtual environment by running:
macOS
source .venv/bin/activate
Windows
.venv\Scripts\activate
You should see (.venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.
- Install the project dependencies by running:
pip install typer==0.26.7 rich==15.0.0
What are these packages?
Typer handles the CLI interface. It generates help text, argument parsing, and command routing from Python type hints. Rich provides colored tables, panels, and formatted text in the terminal.
You pin exact versions (==0.26.7 and ==15.0.0) so every learner gets identical behavior.
- Verify the packages installed correctly by running:
python -c "import typer; import rich; print('Ready')"
You should see Ready printed with no errors.
Seeing an import error?
Make sure (.venv) appears in your terminal prompt. If it does not, re-run the activate command above. Packages installed without an active virtual environment go to your system Python and will not be available here.
help me fix my virtual environment and package installation
Create the project configuration and package files
Every Python package needs a pyproject.toml file that declares its name, version, dependencies, and entry points. You also need an __init__.py file to mark the directory as a Python package.
- In the VS Code Explorer sidebar, right-click the iam-risk-analyzer root folder and select New File.
- Name the file pyproject.toml and press Enter.
- Paste the following configuration into pyproject.toml:
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[project]
name = "iam-risk-analyzer"
version = "0.1.0"
description = "Analyze AWS IAM policies and generate security risk assessments"
requires-python = ">=3.12"
license = {text = "MIT"}
dependencies = [
"typer==0.26.7",
"rich==15.0.0",
]
[project.optional-dependencies]
dev = [
"pytest==9.1.0",
]
[project.scripts]
iam-analyzer = "iam_analyzer.cli:app"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
What does this configuration do?
The [project.scripts] section registers a command called iam-analyzer that points to the app object in iam_analyzer.cli. This is how the CLI becomes a runnable command.
The [tool.setuptools.packages.find] section tells setuptools to look inside src/ for packages. The [project.optional-dependencies] section defines dev dependencies you will use in the Secret Mission.
- Save pyproject.toml.
Now create the package init file that marks src/iam_analyzer/ as a Python package.
- In the Explorer sidebar, expand src then iam_analyzer.
- Right-click the iam_analyzer folder and select New File.
- Name the file __init__.py and press Enter.
- Paste the following code into __init__.py:
"""IAM Risk Analyzer - Analyze AWS IAM policies for security risks."""
__version__ = "0.1.0"
What does __init__.py do?
This file tells Python that the iam_analyzer directory is a package it can import. The __version__ variable provides a single source of truth for the package version number.
- Save __init__.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is the full pyproject.toml:
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[project]
name = "iam-risk-analyzer"
version = "0.1.0"
description = "Analyze AWS IAM policies and generate security risk assessments"
requires-python = ">=3.12"
license = {text = "MIT"}
dependencies = [
"typer==0.26.7",
"rich==15.0.0",
]
[project.optional-dependencies]
dev = [
"pytest==9.1.0",
]
[project.scripts]
iam-analyzer = "iam_analyzer.cli:app"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
Here is the full src/iam_analyzer/__init__.py:
"""IAM Risk Analyzer - Analyze AWS IAM policies for security risks."""
__version__ = "0.1.0"
Your project skeleton is in place with dependencies installed and configuration ready. Next up, you will build the data models that represent IAM policies and security findings.
Build Data Models and Policy Loader
Your project structure and dependencies are ready. Now it is time to define the core data types your analyzer will work with.
Every analysis tool needs a shared language for its inputs and outputs. In this step, you will create Python dataclasses that represent IAM policies, security findings, and risk reports. You will also build a loader that reads and validates policy JSON files.
In this step, get ready to:
- Define data models for policies, findings, and reports.
- Build a policy loader with JSON validation and error handling.
- Create sample IAM policy files for testing your analyzer.
Define the data models
Your analyzer needs structured types to represent the data it processes. A Severity enum defines risk levels. Three dataclasses define the shape of a policy document, a single finding, and a complete report.
- In VS Code, create a new file at src/iam_analyzer/models.py and add the following code:
"""Data models for IAM policy analysis."""
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
class Severity(Enum):
"""Risk severity levels."""
CRITICAL = "CRITICAL"
HIGH = "HIGH"
MEDIUM = "MEDIUM"
LOW = "LOW"
INFO = "INFO"
@dataclass
class Finding:
"""A single security finding from policy analysis."""
rule_id: str
severity: Severity
title: str
description: str
remediation: str
resource: str = ""
@dataclass
class PolicyDocument:
"""A parsed IAM policy document."""
file_path: str
version: str
statements: list[dict]
raw_content: dict
@dataclass
class RiskReport:
"""Complete risk assessment report for a policy."""
policy_file: str
findings: list[Finding]
risk_score: int
risk_level: str
attack_paths: list[str]
analyzed_at: str = field(default_factory=lambda: datetime.now().isoformat())
What does this code do?
This module defines the four core types your analyzer uses everywhere:
- Severity is a Python Enum that restricts risk levels to exactly five values: CRITICAL, HIGH, MEDIUM, LOW, and INFO.
- Finding holds one detected security issue, including a rule ID, severity, human-readable title, and remediation advice.
- PolicyDocument wraps a loaded IAM policy file with its parsed statements and raw JSON content.
- RiskReport is the final output structure combining findings, a risk score, risk level label, and attack path explanations.
- Save models.py.
- Confirm the module loads cleanly by running this in your terminal:
python -c "from iam_analyzer.models import Severity, Finding, PolicyDocument, RiskReport; print('Models loaded')"
You should see Models loaded printed without any errors.
Seeing a ModuleNotFoundError?
- Make sure your virtual environment is active. You should see (.venv) at the start of your terminal prompt.
- Confirm the file is saved at exactly src/iam_analyzer/models.py and that src/iam_analyzer/__init__.py exists.
- help me fix a ModuleNotFoundError when importing my models
Build the policy loader
The loader reads an IAM policy JSON file from disk, validates that it follows the AWS IAM policy structure, and returns a PolicyDocument. It raises a clear error if anything is wrong with the file.
- Create a new file at src/iam_analyzer/loader.py and add the following code:
"""Load and validate IAM policy JSON files."""
import json
from pathlib import Path
from iam_analyzer.models import PolicyDocument
class PolicyLoadError(Exception):
"""Raised when a policy file cannot be loaded or is invalid."""
pass
def load_policy(file_path: str) -> PolicyDocument:
path = Path(file_path)
if not path.exists():
raise PolicyLoadError(f"File not found: {file_path}")
if not path.suffix == ".json":
raise PolicyLoadError(f"File must be a .json file: {file_path}")
try:
content = path.read_text(encoding="utf-8")
data = json.loads(content)
except json.JSONDecodeError as e:
raise PolicyLoadError(f"Invalid JSON in {file_path}: {e}")
if "Statement" not in data:
raise PolicyLoadError(
f"Missing required 'Statement' field in {file_path}"
)
version = data.get("Version", "2012-10-17")
statements = data["Statement"]
if isinstance(statements, dict):
statements = [statements]
return PolicyDocument(
file_path=str(path),
version=version,
statements=statements,
raw_content=data,
)
What does this code do?
The loader performs several validation steps before returning a PolicyDocument:
- It checks the file exists and has a .json extension.
- It attempts to parse the JSON content and raises PolicyLoadError if the JSON is malformed.
- It verifies the policy has a Statement field, which is required by the AWS IAM policy grammar.
- It normalizes the Statement field to always be a list, since AWS allows a single statement object or an array.
Why a custom exception class?
PolicyLoadError gives the CLI layer a single exception type to catch for any loading failure. Without it, the CLI would need to handle FileNotFoundError, json.JSONDecodeError, and KeyError separately.
- Save loader.py.
You cannot verify the loader yet because there are no policy files to load. You will test it after creating the sample files in the next substep.
Create sample IAM policy files
Your analyzer needs real-looking IAM policies to test against. You will create four files that cover the spectrum from dangerously overpowered to properly scoped.
- In the policies/ directory, create a file called admin_wildcard.json with this content:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "FullAdminAccess",
"Effect": "Allow",
"Action": "*",
"Resource": "*"
}
]
}
What makes this policy dangerous?
Action "*" combined with Resource "*" grants unrestricted access to every AWS service and every resource in the account. This is equivalent to the AWS-managed AdministratorAccess policy.
- Create policies/passrole_escalation.json with this content:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowPassRole",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "*"
},
{
"Sid": "AllowEC2Launch",
"Effect": "Allow",
"Action": [
"ec2:RunInstances",
"ec2:DescribeInstances",
"ec2:DescribeImages"
],
"Resource": "*"
}
]
}
What is the PassRole escalation path?
iam:PassRole with Resource "*" lets a user assign any IAM role to a compute resource. Combined with ec2:RunInstances, an attacker can launch an EC2 instance with an admin role attached, then access those elevated credentials through the instance metadata service.
- Create policies/overly_permissive.json with this content:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DangerousIAMActions",
"Effect": "Allow",
"Action": [
"iam:CreatePolicyVersion",
"iam:AttachUserPolicy",
"iam:AttachRolePolicy",
"iam:PutRolePolicy",
"iam:UpdateAssumeRolePolicy",
"iam:CreateAccessKey",
"iam:CreateUser",
"iam:CreateRole"
],
"Resource": "*"
}
]
}
- Create policies/least_privilege.json with this content:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowS3ReadOnly",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::my-application-bucket",
"arn:aws:s3:::my-application-bucket/*"
]
},
{
"Sid": "AllowCloudWatchLogs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/my-function:*"
}
]
}
Why include a safe policy?
least_privilege.json follows best practices: actions are scoped to specific services (s3:GetObject, logs:PutLogEvents) and resources point to specific ARNs. Your analyzer should produce zero findings for this file. It serves as a baseline to prove your rules do not over-flag.
- Save all four JSON files.
- Verify the loader works by running this command in your terminal:
python -c "from iam_analyzer.loader import load_policy; doc = load_policy('policies/admin_wildcard.json'); print(f'Loaded {len(doc.statements)} statement(s) from {doc.file_path}')"
You should see Loaded 1 statement(s) from policies/admin_wildcard.json printed in your terminal.
Seeing a PolicyLoadError or FileNotFoundError?
- Make sure you are running the command from your project root (the iam-risk-analyzer folder), not from inside src/.
- Confirm the file is saved at policies/admin_wildcard.json (not src/policies/).
- Check that the JSON content has no trailing commas or syntax errors.
- help me debug a PolicyLoadError when loading my IAM policy file
✔️ Awesome, I've got everything!
Great. Double check you have saved all files: models.py, loader.py, and the four JSON files in policies/.
ⓧ I'd like to double check the full code
Here is the complete code for each file at this point:
"""Data models for IAM policy analysis."""
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
class Severity(Enum):
"""Risk severity levels."""
CRITICAL = "CRITICAL"
HIGH = "HIGH"
MEDIUM = "MEDIUM"
LOW = "LOW"
INFO = "INFO"
@dataclass
class Finding:
"""A single security finding from policy analysis."""
rule_id: str
severity: Severity
title: str
description: str
remediation: str
resource: str = ""
@dataclass
class PolicyDocument:
"""A parsed IAM policy document."""
file_path: str
version: str
statements: list[dict]
raw_content: dict
@dataclass
class RiskReport:
"""Complete risk assessment report for a policy."""
policy_file: str
findings: list[Finding]
risk_score: int
risk_level: str
attack_paths: list[str]
analyzed_at: str = field(default_factory=lambda: datetime.now().isoformat())
"""Load and validate IAM policy JSON files."""
import json
from pathlib import Path
from iam_analyzer.models import PolicyDocument
class PolicyLoadError(Exception):
"""Raised when a policy file cannot be loaded or is invalid."""
pass
def load_policy(file_path: str) -> PolicyDocument:
path = Path(file_path)
if not path.exists():
raise PolicyLoadError(f"File not found: {file_path}")
if not path.suffix == ".json":
raise PolicyLoadError(f"File must be a .json file: {file_path}")
try:
content = path.read_text(encoding="utf-8")
data = json.loads(content)
except json.JSONDecodeError as e:
raise PolicyLoadError(f"Invalid JSON in {file_path}: {e}")
if "Statement" not in data:
raise PolicyLoadError(
f"Missing required 'Statement' field in {file_path}"
)
version = data.get("Version", "2012-10-17")
statements = data["Statement"]
if isinstance(statements, dict):
statements = [statements]
return PolicyDocument(
file_path=str(path),
version=version,
statements=statements,
raw_content=data,
)
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "FullAdminAccess",
"Effect": "Allow",
"Action": "*",
"Resource": "*"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowPassRole",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "*"
},
{
"Sid": "AllowEC2Launch",
"Effect": "Allow",
"Action": [
"ec2:RunInstances",
"ec2:DescribeInstances",
"ec2:DescribeImages"
],
"Resource": "*"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DangerousIAMActions",
"Effect": "Allow",
"Action": [
"iam:CreatePolicyVersion",
"iam:AttachUserPolicy",
"iam:AttachRolePolicy",
"iam:PutRolePolicy",
"iam:UpdateAssumeRolePolicy",
"iam:CreateAccessKey",
"iam:CreateUser",
"iam:CreateRole"
],
"Resource": "*"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowS3ReadOnly",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::my-application-bucket",
"arn:aws:s3:::my-application-bucket/*"
]
},
{
"Sid": "AllowCloudWatchLogs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/my-function:*"
}
]
}
Your data models and loader are in place. Next, you will build the rules engine that uses these structures to detect dangerous permission patterns in the policies you just created.
Build the Risk Detection Rules Engine
You have data models that represent policies and findings, and a loader that reads JSON into those models. The missing piece is the brain that actually decides what's dangerous.
In this step, you'll build the rules engine that inspects each policy statement and flags risky patterns. This module needs to detect wildcard permissions, privilege escalation paths, and dangerous permission combinations that could give an attacker full account control.
In this step, get ready to:
- Create the rules module with constants, helpers, and core wildcard detection functions.
- Add privilege escalation detection and dangerous combination analysis.
- Test the rules engine against sample policies to verify detection accuracy.
Create the rules module with core detection rules
The rules engine works by checking each statement in a policy against a set of known dangerous patterns. Each detection function takes a single statement dictionary and returns a list of Finding objects if it spots a problem.
- Create a new file src/iam_analyzer/rules.py and add the following code:
"""Risk detection rules for IAM policy analysis."""
from iam_analyzer.models import Finding, PolicyDocument, Severity
# Actions that grant dangerous IAM control
SENSITIVE_IAM_ACTIONS = [
"iam:CreatePolicyVersion",
"iam:AttachUserPolicy",
"iam:AttachRolePolicy",
"iam:PutRolePolicy",
"iam:PutUserPolicy",
"iam:UpdateAssumeRolePolicy",
"iam:CreateAccessKey",
"iam:CreateRole",
"iam:CreateUser",
]
# Subset of sensitive actions that directly enable privilege escalation
PRIVILEGE_ESCALATION_ACTIONS = [
"iam:CreatePolicyVersion",
"iam:AttachUserPolicy",
"iam:AttachRolePolicy",
"iam:PutRolePolicy",
"iam:UpdateAssumeRolePolicy",
]
# Actions that create compute resources (dangerous with PassRole)
COMPUTE_CREATION_ACTIONS = [
"ec2:RunInstances",
"lambda:CreateFunction",
"lambda:UpdateFunctionCode",
"glue:CreateJob",
"glue:CreateDevEndpoint",
"ecs:RegisterTaskDefinition",
"sagemaker:CreateNotebookInstance",
]
def _normalize_actions(statement: dict) -> list[str]:
"""Extract and normalize actions to a lowercase list."""
actions = statement.get("Action", [])
if isinstance(actions, str):
actions = [actions]
return [a.lower() for a in actions]
def _normalize_resources(statement: dict) -> list[str]:
"""Extract and normalize resources to a list."""
resources = statement.get("Resource", [])
if isinstance(resources, str):
resources = [resources]
return resources
def _is_allow(statement: dict) -> bool:
"""Check if statement has Allow effect."""
return statement.get("Effect", "").lower() == "allow"
def _check_wildcard_actions(statement: dict) -> list[Finding]:
"""Detect Action: * in Allow statements."""
findings = []
if not _is_allow(statement):
return findings
actions = statement.get("Action", [])
if isinstance(actions, str):
actions = [actions]
if "*" in actions:
findings.append(Finding(
rule_id="WILDCARD_ACTION",
severity=Severity.CRITICAL,
title="Wildcard Action Detected",
description="The policy grants all actions. This provides unrestricted access to all AWS services.",
remediation="Replace the wildcard action with specific actions required for the workload.",
resource=str(statement.get("Resource", "*")),
))
return findings
def _check_wildcard_resources(statement: dict) -> list[Finding]:
"""Detect Resource: * combined with sensitive actions."""
findings = []
if not _is_allow(statement):
return findings
resources = _normalize_resources(statement)
actions = _normalize_actions(statement)
if "*" in resources and "*" not in statement.get("Action", []):
has_sensitive = any(action in [a.lower() for a in SENSITIVE_IAM_ACTIONS] for action in actions)
if has_sensitive:
findings.append(Finding(
rule_id="WILDCARD_RESOURCE_SENSITIVE",
severity=Severity.HIGH,
title="Wildcard Resource with Sensitive Actions",
description="Sensitive IAM actions are allowed on all resources.",
remediation="Scope the Resource field to specific ARNs.",
resource="*",
))
return findings
def _check_admin_access(statement: dict) -> list[Finding]:
"""Detect AdministratorAccess-equivalent behavior."""
findings = []
if not _is_allow(statement):
return findings
actions = statement.get("Action", [])
if isinstance(actions, str):
actions = [actions]
resources = statement.get("Resource", [])
if isinstance(resources, str):
resources = [resources]
if "*" in actions and "*" in resources:
findings.append(Finding(
rule_id="ADMIN_ACCESS",
severity=Severity.CRITICAL,
title="AdministratorAccess Equivalent",
description="The policy grants all actions on all resources.",
remediation="Remove this policy and replace with specific permissions.",
resource="*",
))
return findings
def _check_passrole(statement: dict) -> list[Finding]:
"""Detect iam:PassRole with broad resource scope."""
findings = []
if not _is_allow(statement):
return findings
actions = _normalize_actions(statement)
resources = _normalize_resources(statement)
has_passrole = "iam:passrole" in actions or any(a in ["iam:*", "*"] for a in actions)
if has_passrole and "*" in resources:
findings.append(Finding(
rule_id="PASSROLE_WILDCARD",
severity=Severity.CRITICAL,
title="iam:PassRole with Wildcard Resource",
description="iam:PassRole is allowed on all resources.",
remediation="Scope iam:PassRole to specific role ARNs.",
resource="*",
))
return findings
What does this code do?
The three constant lists define which IAM actions are considered dangerous. The rules engine checks incoming policies against these lists.
- _normalize_actions and _normalize_resources handle the fact that AWS policy fields can be either a single string or a list of strings.
- _check_wildcard_actions catches policies that grant every possible action ("Action": "*").
- _check_admin_access flags the most dangerous pattern: all actions on all resources (full administrator control).
- _check_passrole detects when iam:PassRole targets all resources, meaning any role in the account can be passed to a service.
Why is iam:PassRole so dangerous?
PassRole with Resource: * is considered the highest-risk single IAM permission. It allows assigning any role to any AWS service. Combined with the ability to launch compute resources, an attacker can spin up an EC2 instance with an admin role attached, then steal those credentials from the instance metadata service.
- Save src/iam_analyzer/rules.py.
- You won't see output yet. The next section adds the orchestrator function that runs all these rules together.
Add privilege escalation detection and the orchestrator
Wildcard detection catches the obvious problems, but real-world attackers exploit subtler patterns. Specific IAM actions like iam:CreatePolicyVersion let an attacker silently rewrite an existing policy to grant themselves full admin access.
- In src/iam_analyzer/rules.py, add the following code below the _check_passrole function:
def _check_privilege_escalation(statement: dict) -> list[Finding]:
"""Detect privilege escalation permissions."""
findings = []
if not _is_allow(statement):
return findings
actions = _normalize_actions(statement)
found_escalation = [action for action in PRIVILEGE_ESCALATION_ACTIONS if action.lower() in actions]
if found_escalation:
findings.append(Finding(
rule_id="PRIVILEGE_ESCALATION",
severity=Severity.HIGH,
title="Privilege Escalation Permissions Detected",
description=f"The policy grants privilege escalation permissions: {', '.join(found_escalation)}.",
remediation="Remove these permissions unless absolutely required.",
resource=str(_normalize_resources(statement)),
))
return findings
def _check_sensitive_iam_actions(statement: dict) -> list[Finding]:
"""Detect sensitive IAM actions not caught by other rules."""
findings = []
if not _is_allow(statement):
return findings
actions = _normalize_actions(statement)
sensitive_found = [action for action in SENSITIVE_IAM_ACTIONS if action.lower() in actions and action not in PRIVILEGE_ESCALATION_ACTIONS]
if sensitive_found:
findings.append(Finding(
rule_id="SENSITIVE_IAM_ACTIONS",
severity=Severity.MEDIUM,
title="Sensitive IAM Actions Detected",
description=f"The policy grants sensitive IAM actions: {', '.join(sensitive_found)}.",
remediation="Review whether these actions are required.",
resource=str(_normalize_resources(statement)),
))
return findings
def _check_passrole_compute_combo(policy: PolicyDocument) -> list[Finding]:
"""Detect dangerous PassRole + compute creation combinations across statements."""
findings = []
all_actions: list[str] = []
for statement in policy.statements:
if _is_allow(statement):
all_actions.extend(_normalize_actions(statement))
has_passrole = "iam:passrole" in all_actions or "iam:*" in all_actions
compute_actions_found = [action for action in COMPUTE_CREATION_ACTIONS if action.lower() in all_actions]
if has_passrole and compute_actions_found:
findings.append(Finding(
rule_id="PASSROLE_COMPUTE_COMBO",
severity=Severity.CRITICAL,
title="PassRole + Compute Creation Combination",
description=f"The policy combines iam:PassRole with compute creation permissions ({', '.join(compute_actions_found)}).",
remediation="Separate PassRole and compute creation into different policies.",
resource="Multiple statements",
))
return findings
def analyze_policy(policy: PolicyDocument) -> list[Finding]:
"""Run all detection rules against a policy and return findings."""
all_findings: list[Finding] = []
for statement in policy.statements:
all_findings.extend(_check_admin_access(statement))
all_findings.extend(_check_wildcard_actions(statement))
all_findings.extend(_check_passrole(statement))
all_findings.extend(_check_privilege_escalation(statement))
all_findings.extend(_check_wildcard_resources(statement))
all_findings.extend(_check_sensitive_iam_actions(statement))
# Check for cross-statement combinations
all_findings.extend(_check_passrole_compute_combo(policy))
return all_findings
What does this code do?
- _check_privilege_escalation detects actions that let an attacker modify IAM policies or role trust relationships to gain elevated access.
- _check_sensitive_iam_actions catches remaining dangerous IAM actions (like creating access keys or users) that aren't already flagged by the escalation check.
- _check_passrole_compute_combo looks across ALL statements in the policy, not just one. If PassRole and a compute creation action appear together anywhere, the combination is flagged.
- analyze_policy is the main entry point. It loops through every statement, runs each per-statement check, then runs the cross-statement combo check.
- Save src/iam_analyzer/rules.py.
- Verify the module loads without errors by running this in your terminal:
python -c "from iam_analyzer.rules import analyze_policy; print('Rules module loaded')"
You should see Rules module loaded printed with no errors.
Seeing a ModuleNotFoundError?
- Make sure your virtual environment is active. You should see (.venv) in your terminal prompt.
- Confirm you're running the command from the project root (the folder containing src/).
- Check that src/iam_analyzer/__init__.py exists. Python needs it to treat the folder as a package.
Why can't Python find my rules module?
Test the rules engine against sample policies
The rules module is complete. Time to confirm it correctly identifies dangerous patterns in your sample policies and stays silent on safe ones.
- Test the rules against admin_wildcard.json by running:
python -c "
from iam_analyzer.loader import load_policy
from iam_analyzer.rules import analyze_policy
policy = load_policy('policies/admin_wildcard.json')
findings = analyze_policy(policy)
print(f'Findings: {len(findings)}')
for f in findings:
print(f' [{f.severity.value}] {f.title}')
"
You should see at least 2 findings, including Wildcard Action Detected and AdministratorAccess Equivalent.
- Now test the safe policy to confirm no false positives by running:
python -c "
from iam_analyzer.loader import load_policy
from iam_analyzer.rules import analyze_policy
policy = load_policy('policies/least_privilege.json')
findings = analyze_policy(policy)
print(f'Findings: {len(findings)}')
"
You should see Findings: 0. The properly scoped policy passes cleanly.
Seeing unexpected findings on least_privilege.json?
- Double check that every detection function starts with if not _is_allow(statement): return findings. Without this guard, Deny statements could incorrectly trigger checks.
- Verify that _check_wildcard_resources requires has_sensitive to be True before appending a finding. A wildcard resource alone (without sensitive actions) should not trigger it.
Why is my rules engine producing false positives on a safe policy?
✔️ Awesome, I've got everything!
Great. Double check you've saved src/iam_analyzer/rules.py.
ⓧ I'd like to double check the full code
Here is the complete src/iam_analyzer/rules.py file for comparison:
"""Risk detection rules for IAM policy analysis."""
from iam_analyzer.models import Finding, PolicyDocument, Severity
SENSITIVE_IAM_ACTIONS = [
"iam:CreatePolicyVersion",
"iam:AttachUserPolicy",
"iam:AttachRolePolicy",
"iam:PutRolePolicy",
"iam:PutUserPolicy",
"iam:UpdateAssumeRolePolicy",
"iam:CreateAccessKey",
"iam:CreateRole",
"iam:CreateUser",
]
PRIVILEGE_ESCALATION_ACTIONS = [
"iam:CreatePolicyVersion",
"iam:AttachUserPolicy",
"iam:AttachRolePolicy",
"iam:PutRolePolicy",
"iam:UpdateAssumeRolePolicy",
]
COMPUTE_CREATION_ACTIONS = [
"ec2:RunInstances",
"lambda:CreateFunction",
"lambda:UpdateFunctionCode",
"glue:CreateJob",
"glue:CreateDevEndpoint",
"ecs:RegisterTaskDefinition",
"sagemaker:CreateNotebookInstance",
]
def _normalize_actions(statement: dict) -> list[str]:
actions = statement.get("Action", [])
if isinstance(actions, str):
actions = [actions]
return [a.lower() for a in actions]
def _normalize_resources(statement: dict) -> list[str]:
resources = statement.get("Resource", [])
if isinstance(resources, str):
resources = [resources]
return resources
def _is_allow(statement: dict) -> bool:
return statement.get("Effect", "").lower() == "allow"
def _check_wildcard_actions(statement: dict) -> list[Finding]:
findings = []
if not _is_allow(statement):
return findings
actions = statement.get("Action", [])
if isinstance(actions, str):
actions = [actions]
if "*" in actions:
findings.append(Finding(
rule_id="WILDCARD_ACTION",
severity=Severity.CRITICAL,
title="Wildcard Action Detected",
description="The policy grants all actions. This provides unrestricted access to all AWS services.",
remediation="Replace the wildcard action with specific actions required for the workload.",
resource=str(statement.get("Resource", "*")),
))
return findings
def _check_wildcard_resources(statement: dict) -> list[Finding]:
findings = []
if not _is_allow(statement):
return findings
resources = _normalize_resources(statement)
actions = _normalize_actions(statement)
if "*" in resources and "*" not in statement.get("Action", []):
has_sensitive = any(action in [a.lower() for a in SENSITIVE_IAM_ACTIONS] for action in actions)
if has_sensitive:
findings.append(Finding(
rule_id="WILDCARD_RESOURCE_SENSITIVE",
severity=Severity.HIGH,
title="Wildcard Resource with Sensitive Actions",
description="Sensitive IAM actions are allowed on all resources.",
remediation="Scope the Resource field to specific ARNs.",
resource="*",
))
return findings
def _check_admin_access(statement: dict) -> list[Finding]:
findings = []
if not _is_allow(statement):
return findings
actions = statement.get("Action", [])
if isinstance(actions, str):
actions = [actions]
resources = statement.get("Resource", [])
if isinstance(resources, str):
resources = [resources]
if "*" in actions and "*" in resources:
findings.append(Finding(
rule_id="ADMIN_ACCESS",
severity=Severity.CRITICAL,
title="AdministratorAccess Equivalent",
description="The policy grants all actions on all resources.",
remediation="Remove this policy and replace with specific permissions.",
resource="*",
))
return findings
def _check_passrole(statement: dict) -> list[Finding]:
findings = []
if not _is_allow(statement):
return findings
actions = _normalize_actions(statement)
resources = _normalize_resources(statement)
has_passrole = "iam:passrole" in actions or any(a in ["iam:*", "*"] for a in actions)
if has_passrole and "*" in resources:
findings.append(Finding(
rule_id="PASSROLE_WILDCARD",
severity=Severity.CRITICAL,
title="iam:PassRole with Wildcard Resource",
description="iam:PassRole is allowed on all resources.",
remediation="Scope iam:PassRole to specific role ARNs.",
resource="*",
))
return findings
def _check_privilege_escalation(statement: dict) -> list[Finding]:
findings = []
if not _is_allow(statement):
return findings
actions = _normalize_actions(statement)
found_escalation = [action for action in PRIVILEGE_ESCALATION_ACTIONS if action.lower() in actions]
if found_escalation:
findings.append(Finding(
rule_id="PRIVILEGE_ESCALATION",
severity=Severity.HIGH,
title="Privilege Escalation Permissions Detected",
description=f"The policy grants privilege escalation permissions: {', '.join(found_escalation)}.",
remediation="Remove these permissions unless absolutely required.",
resource=str(_normalize_resources(statement)),
))
return findings
def _check_sensitive_iam_actions(statement: dict) -> list[Finding]:
findings = []
if not _is_allow(statement):
return findings
actions = _normalize_actions(statement)
sensitive_found = [action for action in SENSITIVE_IAM_ACTIONS if action.lower() in actions and action not in PRIVILEGE_ESCALATION_ACTIONS]
if sensitive_found:
findings.append(Finding(
rule_id="SENSITIVE_IAM_ACTIONS",
severity=Severity.MEDIUM,
title="Sensitive IAM Actions Detected",
description=f"The policy grants sensitive IAM actions: {', '.join(sensitive_found)}.",
remediation="Review whether these actions are required.",
resource=str(_normalize_resources(statement)),
))
return findings
def _check_passrole_compute_combo(policy: PolicyDocument) -> list[Finding]:
findings = []
all_actions: list[str] = []
for statement in policy.statements:
if _is_allow(statement):
all_actions.extend(_normalize_actions(statement))
has_passrole = "iam:passrole" in all_actions or "iam:*" in all_actions
compute_actions_found = [action for action in COMPUTE_CREATION_ACTIONS if action.lower() in all_actions]
if has_passrole and compute_actions_found:
findings.append(Finding(
rule_id="PASSROLE_COMPUTE_COMBO",
severity=Severity.CRITICAL,
title="PassRole + Compute Creation Combination",
description=f"The policy combines iam:PassRole with compute creation permissions ({', '.join(compute_actions_found)}).",
remediation="Separate PassRole and compute creation into different policies.",
resource="Multiple statements",
))
return findings
def analyze_policy(policy: PolicyDocument) -> list[Finding]:
all_findings: list[Finding] = []
for statement in policy.statements:
all_findings.extend(_check_admin_access(statement))
all_findings.extend(_check_wildcard_actions(statement))
all_findings.extend(_check_passrole(statement))
all_findings.extend(_check_privilege_escalation(statement))
all_findings.extend(_check_wildcard_resources(statement))
all_findings.extend(_check_sensitive_iam_actions(statement))
all_findings.extend(_check_passrole_compute_combo(policy))
return all_findings
Your rules engine can now detect 6 categories of dangerous IAM patterns. Next up, you'll build the scoring algorithm that turns those raw findings into a single risk score from 0-100.
Build the Scorer and Attack Path Analyzer
Your rules engine can now flag dangerous IAM patterns, but a list of raw findings is hard to act on.
In this step, you will build two modules that transform findings into actionable intelligence. A scorer assigns a 0-100 risk number so teams can prioritize policies by danger level. An attack path analyzer explains, in plain English, how an attacker could chain permissions together to escalate privileges.
In this step, get ready to:
- Build a risk scoring algorithm with weighted severity calculations.
- Build an attack path analyzer that generates plain-English escalation explanations.
- Test the full analysis pipeline against a vulnerable policy.
Create the risk scoring module
The rules engine outputs a list of findings, but a security team needs a single number to compare policies at a glance. A weighted scoring algorithm converts findings into a 0-100 risk score.
- Create a new file called scorer.py inside src/iam_analyzer/ and add the following code:
"""Risk scoring algorithm for IAM policy findings."""
from iam_analyzer.models import Finding, Severity
# Points each severity level contributes to the total score
SEVERITY_WEIGHTS = {
Severity.CRITICAL: 25,
Severity.HIGH: 15,
Severity.MEDIUM: 8,
Severity.LOW: 3,
Severity.INFO: 1,
}
# Score thresholds mapped to human-readable risk labels
RISK_LEVELS = [
(20, "Low"),
(40, "Moderate"),
(60, "High"),
(80, "Critical"),
(100, "Severe"),
]
def calculate_risk_score(findings: list[Finding]) -> int:
"""Calculate a risk score from 0-100 based on findings.
Each finding contributes points based on its severity level.
The score is capped at 100.
"""
total = sum(SEVERITY_WEIGHTS.get(f.severity, 0) for f in findings)
return min(total, 100)
def get_risk_level(score: int) -> str:
"""Map a risk score to a human-readable risk level."""
for threshold, level in RISK_LEVELS:
if score <= threshold:
return level
return "Severe"
What does this code do?
The SEVERITY_WEIGHTS dictionary assigns point values to each severity level. A single CRITICAL finding adds 25 points, which already signals significant risk on a 100-point scale.
The calculate_risk_score function sums up all the severity weights and caps the total at 100. The get_risk_level function walks through the thresholds in order and returns the first label whose threshold is at or above the score.
- Save scorer.py.
- In your terminal, verify the module imports cleanly by running:
python -c "from iam_analyzer.scorer import calculate_risk_score, get_risk_level; print('Scorer ready')"
You should see Scorer ready printed with no errors.
Seeing a ModuleNotFoundError?
Make sure your virtual environment is active (you should see (.venv) in your terminal prompt).
Check that the file is saved as src/iam_analyzer/scorer.py and not accidentally placed in the project root.
help me fix a ModuleNotFoundError when importing scorer
Create the attack path analyzer
Individual findings tell you what is wrong, but they do not explain how an attacker would chain permissions together. The attack path analyzer looks across all statements in a policy and generates plain-English explanations of real escalation techniques.
- Create a new file called attack_paths.py inside src/iam_analyzer/ and add the following code:
"""Attack path analysis for dangerous permission combinations."""
from iam_analyzer.models import PolicyDocument
from iam_analyzer.rules import (
COMPUTE_CREATION_ACTIONS,
PRIVILEGE_ESCALATION_ACTIONS,
_is_allow,
_normalize_actions,
)
def analyze_attack_paths(policy: PolicyDocument) -> list[str]:
"""Analyze a policy for dangerous permission combinations and generate
plain-English attack path explanations."""
attack_paths: list[str] = []
all_actions: list[str] = []
# Collect all allowed actions across every statement
for statement in policy.statements:
if _is_allow(statement):
all_actions.extend(_normalize_actions(statement))
# Check for PassRole + compute creation path
has_passrole = "iam:passrole" in all_actions or "iam:*" in all_actions or "*" in all_actions
compute_found = [a for a in COMPUTE_CREATION_ACTIONS if a.lower() in all_actions]
if has_passrole and compute_found:
services = ", ".join(compute_found)
attack_paths.append(
f"PRIVILEGE ESCALATION VIA PASSROLE: An attacker could use "
f"iam:PassRole combined with {services} to launch a compute "
f"resource with a high-privilege role attached."
)
# Check for policy rewrite path
if "iam:createpolicyversion" in all_actions:
attack_paths.append(
"POLICY REWRITE ATTACK: An attacker could call "
"iam:CreatePolicyVersion to create a new version of an attached "
"managed policy with full admin permissions."
)
# Check for policy attachment path
if "iam:attachuserpolicy" in all_actions or "iam:attachrolepolicy" in all_actions:
attack_paths.append(
"POLICY ATTACHMENT ATTACK: An attacker could attach the "
"AWS-managed AdministratorAccess policy directly to their "
"compromised user or role."
)
# Check for role hijacking path
if "iam:updateassumerolepolicy" in all_actions:
attack_paths.append(
"ROLE HIJACKING: An attacker could modify the trust policy of a "
"high-privilege role to allow their own principal to assume it."
)
# Check for full admin access
if "*" in all_actions:
has_wildcard_resource = any(
"*" in (stmt.get("Resource", []) if isinstance(stmt.get("Resource", []), list) else [stmt.get("Resource", "")])
for stmt in policy.statements if _is_allow(stmt)
)
if has_wildcard_resource:
attack_paths.append(
"FULL ACCOUNT TAKEOVER: This policy grants unrestricted "
"access to all AWS services and resources."
)
return attack_paths
What does this code do?
The function first collects every allowed action from all statements into a single list. This lets it detect dangerous combinations that span multiple statements in the same policy.
Each conditional block checks for a known escalation technique. For example, if iam:PassRole and a compute creation action both appear, an attacker can pass a high-privilege role to a new compute instance and extract those credentials.
The function reuses helpers from your rules.py module (_is_allow and _normalize_actions) instead of duplicating logic.
- Save attack_paths.py.
- Verify the module imports cleanly by running:
python -c "from iam_analyzer.attack_paths import analyze_attack_paths; print('Attack paths ready')"
You should see Attack paths ready printed with no errors.
Seeing an ImportError?
This module imports from rules.py, so any typo in the import line will cause a failure. Double-check that your import block matches exactly: from iam_analyzer.rules import (COMPUTE_CREATION_ACTIONS, PRIVILEGE_ESCALATION_ACTIONS, _is_allow, _normalize_actions,).
help me fix an ImportError in my attack_paths module
Test the full analysis pipeline
Both modules are ready. Now you will wire them together by running the full analysis flow against a vulnerable policy to confirm the scorer and attack path analyzer produce correct output.
- Run the full pipeline against passrole_escalation.json by running this command:
python -c "
from iam_analyzer.loader import load_policy
from iam_analyzer.rules import analyze_policy
from iam_analyzer.scorer import calculate_risk_score, get_risk_level
from iam_analyzer.attack_paths import analyze_attack_paths
policy = load_policy('policies/passrole_escalation.json')
findings = analyze_policy(policy)
score = calculate_risk_score(findings)
level = get_risk_level(score)
paths = analyze_attack_paths(policy)
print(f'Findings: {len(findings)}')
print(f'Risk Score: {score}/100 ({level})')
print(f'Attack Paths: {len(paths)}')
for p in paths:
print(f' - {p[:80]}...')
"
What should I see?
The output should show multiple findings, a risk score above 60, and at least one attack path mentioning PassRole. The risk level should read "Critical" or "Severe" because the passrole_escalation.json policy combines iam:PassRole with ec2:RunInstances.
Score lower than expected?
If your score is below 60, check that rules.py includes the _check_passrole_compute_combo function and that analyze_policy calls it at the end after the per-statement loop.
help me debug why my risk score is too low
✔️ Awesome, I've got everything!
Great. Make sure both scorer.py and attack_paths.py are saved.
ⓧ I'd like to double check the full code
Here is the complete scorer.py:
"""Risk scoring algorithm for IAM policy findings."""
from iam_analyzer.models import Finding, Severity
SEVERITY_WEIGHTS = {
Severity.CRITICAL: 25,
Severity.HIGH: 15,
Severity.MEDIUM: 8,
Severity.LOW: 3,
Severity.INFO: 1,
}
RISK_LEVELS = [
(20, "Low"),
(40, "Moderate"),
(60, "High"),
(80, "Critical"),
(100, "Severe"),
]
def calculate_risk_score(findings: list[Finding]) -> int:
total = sum(SEVERITY_WEIGHTS.get(f.severity, 0) for f in findings)
return min(total, 100)
def get_risk_level(score: int) -> str:
for threshold, level in RISK_LEVELS:
if score <= threshold:
return level
return "Severe"
Here is the complete attack_paths.py:
"""Attack path analysis for dangerous permission combinations."""
from iam_analyzer.models import PolicyDocument
from iam_analyzer.rules import (
COMPUTE_CREATION_ACTIONS,
PRIVILEGE_ESCALATION_ACTIONS,
_is_allow,
_normalize_actions,
)
def analyze_attack_paths(policy: PolicyDocument) -> list[str]:
attack_paths: list[str] = []
all_actions: list[str] = []
for statement in policy.statements:
if _is_allow(statement):
all_actions.extend(_normalize_actions(statement))
has_passrole = "iam:passrole" in all_actions or "iam:*" in all_actions or "*" in all_actions
compute_found = [a for a in COMPUTE_CREATION_ACTIONS if a.lower() in all_actions]
if has_passrole and compute_found:
services = ", ".join(compute_found)
attack_paths.append(
f"PRIVILEGE ESCALATION VIA PASSROLE: An attacker could use "
f"iam:PassRole combined with {services} to launch a compute "
f"resource with a high-privilege role attached."
)
if "iam:createpolicyversion" in all_actions:
attack_paths.append(
"POLICY REWRITE ATTACK: An attacker could call "
"iam:CreatePolicyVersion to create a new version of an attached "
"managed policy with full admin permissions."
)
if "iam:attachuserpolicy" in all_actions or "iam:attachrolepolicy" in all_actions:
attack_paths.append(
"POLICY ATTACHMENT ATTACK: An attacker could attach the "
"AWS-managed AdministratorAccess policy directly to their "
"compromised user or role."
)
if "iam:updateassumerolepolicy" in all_actions:
attack_paths.append(
"ROLE HIJACKING: An attacker could modify the trust policy of a "
"high-privilege role to allow their own principal to assume it."
)
if "*" in all_actions:
has_wildcard_resource = any(
"*" in (stmt.get("Resource", []) if isinstance(stmt.get("Resource", []), list) else [stmt.get("Resource", "")])
for stmt in policy.statements if _is_allow(stmt)
)
if has_wildcard_resource:
attack_paths.append(
"FULL ACCOUNT TAKEOVER: This policy grants unrestricted "
"access to all AWS services and resources."
)
return attack_paths
Your analyzer can now quantify risk and explain attack scenarios in plain English. Next up, you will build the reporter and CLI that ties everything into a polished command-line tool with colored output and file exports.
Build the Reporter and CLI
Your analyzer can now detect dangerous patterns, calculate risk scores, and trace attack paths. The analysis engine is complete, but there is no way to actually run it or see the results.
In this step, you will build the presentation layer and the command-line interface. You will create a reporter module that formats findings as colored terminal output, structured JSON, or Markdown documents. Then you will wire everything together with a Typer CLI so you can analyze any policy file with a single command.
In this step, get ready to:
- Create a multi-format reporter with console, JSON, and Markdown output.
- Build a Typer CLI with an analyze command and format options.
- Test the full analysis pipeline end-to-end against your sample policies.
Create the reporter module with helper functions
The reporter module translates raw analysis data into human-readable output. It uses the Rich library for colored terminal tables and panels.
- Create a new file src/iam_analyzer/reporter.py and add the imports and helper functions by pasting this code:
"""Multi-format report generation for IAM risk analysis."""
import json
from dataclasses import asdict
from pathlib import Path
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from iam_analyzer.models import RiskReport, Severity
# Map severity levels to Rich color names for terminal output
def _severity_color(severity: Severity) -> str:
colors = {
Severity.CRITICAL: "red bold",
Severity.HIGH: "red",
Severity.MEDIUM: "yellow",
Severity.LOW: "blue",
Severity.INFO: "green",
}
return colors.get(severity, "white")
# Map risk score ranges to colors for the score panel
def _score_color(score: int) -> str:
if score >= 81:
return "red bold"
elif score >= 61:
return "red"
elif score >= 41:
return "yellow"
elif score >= 21:
return "blue"
return "green"
What does this code do?
The two helper functions map severity levels and risk scores to Rich color strings. When a finding is CRITICAL, the terminal will show it in bold red. When the risk score is above 81, the score panel turns red.
The imports pull in Rich's Console, Panel, and Table classes for building the formatted console output.
- You won't see a change yet. The next chunk adds the main reporting function that uses these helpers.
- Add the report_console function below the helpers in reporter.py:
def report_console(report: RiskReport) -> None:
console = Console()
# Print header panel with policy name and timestamp
console.print()
console.print(Panel(
f"[bold]IAM Risk Analyzer Report[/bold]\nPolicy: {report.policy_file}\nAnalyzed: {report.analyzed_at}",
border_style="blue",
))
# Print risk score panel with color-coded severity
score_style = _score_color(report.risk_score)
console.print()
console.print(Panel(
f"[{score_style}]Risk Score: {report.risk_score}/100 ({report.risk_level})[/{score_style}]",
title="Risk Assessment",
border_style=score_style.split()[0],
))
# Print findings as a formatted table
if report.findings:
console.print()
table = Table(title="Security Findings", show_lines=True)
table.add_column("Severity", style="bold", width=10)
table.add_column("Rule", width=28)
table.add_column("Description", width=50)
table.add_column("Remediation", width=40)
for finding in report.findings:
color = _severity_color(finding.severity)
table.add_row(
f"[{color}]{finding.severity.value}[/{color}]",
finding.title, finding.description, finding.remediation,
)
console.print(table)
else:
console.print("\n[green]No security findings detected.[/green]")
# Print attack paths if any were identified
if report.attack_paths:
console.print()
console.print(Panel(
"\n\n".join(f"[red]{i+1}.[/red] {path}" for i, path in enumerate(report.attack_paths)),
title="Attack Path Analysis",
border_style="red",
))
console.print()
What does this function do?
The report_console function builds a Rich-formatted terminal report in four sections: a header panel with the file name and timestamp, a color-coded risk score panel, a table of all findings with severity and remediation columns, and an attack paths panel.
If no findings exist, it prints a green "no findings" message instead of an empty table.
- You won't see a change yet. The next chunk adds the file export functions.
- Add the report_json and report_markdown functions below report_console in reporter.py:
def report_json(report: RiskReport, output_path: str) -> None:
# Convert dataclass to dict and clean up severity enum format
report_dict = asdict(report)
for finding in report_dict["findings"]:
finding["severity"] = finding["severity"].split(".")[-1]
path = Path(output_path)
path.write_text(json.dumps(report_dict, indent=2), encoding="utf-8")
console = Console()
console.print(f"[green]JSON report saved to: {output_path}[/green]")
def report_markdown(report: RiskReport, output_path: str) -> None:
lines: list[str] = []
# Report header with metadata
lines.append("# IAM Risk Analyzer Report")
lines.append("")
lines.append(f"- Policy: `{report.policy_file}`")
lines.append(f"- Analyzed: {report.analyzed_at}")
lines.append(f"- Risk Score: **{report.risk_score}/100** ({report.risk_level})")
lines.append("")
# Findings as a Markdown table
lines.append("## Security Findings")
lines.append("")
if report.findings:
lines.append("| Severity | Title | Description | Remediation |")
lines.append("|----------|-------|-------------|-------------|")
for finding in report.findings:
desc = finding.description.replace("|", "\\|")
rem = finding.remediation.replace("|", "\\|")
title = finding.title.replace("|", "\\|")
lines.append(f"| {finding.severity.value} | {title} | {desc} | {rem} |")
else:
lines.append("No security findings detected.")
lines.append("")
# Attack paths as a numbered list
if report.attack_paths:
lines.append("## Attack Path Analysis")
lines.append("")
for i, path in enumerate(report.attack_paths, 1):
lines.append(f"{i}. {path}")
lines.append("")
path = Path(output_path)
path.write_text("\n".join(lines), encoding="utf-8")
console = Console()
console.print(f"[green]Markdown report saved to: {output_path}[/green]")
What do these functions do?
- report_json converts the RiskReport dataclass to a dictionary using asdict(), strips the "Severity." prefix from enum values, and writes indented JSON to a file.
- report_markdown builds a Markdown document line by line with metadata, a findings table, and numbered attack paths. It escapes pipe characters so they do not break the Markdown table syntax.
- Save reporter.py.
Seeing a syntax error?
Check that the f-string formatting inside the Rich markup brackets matches exactly. The pattern f"[{color}]{value}[/{color}]" uses curly braces for Python variables and square brackets for Rich styling.
help me fix a syntax error in my reporter.py file
✔️ Awesome, I've got everything!
Great. Double check you have saved reporter.py before moving on.
ⓧ I'd like to double check the full code
"""Multi-format report generation for IAM risk analysis."""
import json
from dataclasses import asdict
from pathlib import Path
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from iam_analyzer.models import RiskReport, Severity
def _severity_color(severity: Severity) -> str:
colors = {
Severity.CRITICAL: "red bold",
Severity.HIGH: "red",
Severity.MEDIUM: "yellow",
Severity.LOW: "blue",
Severity.INFO: "green",
}
return colors.get(severity, "white")
def _score_color(score: int) -> str:
if score >= 81:
return "red bold"
elif score >= 61:
return "red"
elif score >= 41:
return "yellow"
elif score >= 21:
return "blue"
return "green"
def report_console(report: RiskReport) -> None:
console = Console()
console.print()
console.print(Panel(
f"[bold]IAM Risk Analyzer Report[/bold]\nPolicy: {report.policy_file}\nAnalyzed: {report.analyzed_at}",
border_style="blue",
))
score_style = _score_color(report.risk_score)
console.print()
console.print(Panel(
f"[{score_style}]Risk Score: {report.risk_score}/100 ({report.risk_level})[/{score_style}]",
title="Risk Assessment",
border_style=score_style.split()[0],
))
if report.findings:
console.print()
table = Table(title="Security Findings", show_lines=True)
table.add_column("Severity", style="bold", width=10)
table.add_column("Rule", width=28)
table.add_column("Description", width=50)
table.add_column("Remediation", width=40)
for finding in report.findings:
color = _severity_color(finding.severity)
table.add_row(
f"[{color}]{finding.severity.value}[/{color}]",
finding.title, finding.description, finding.remediation,
)
console.print(table)
else:
console.print("\n[green]No security findings detected.[/green]")
if report.attack_paths:
console.print()
console.print(Panel(
"\n\n".join(f"[red]{i+1}.[/red] {path}" for i, path in enumerate(report.attack_paths)),
title="Attack Path Analysis",
border_style="red",
))
console.print()
def report_json(report: RiskReport, output_path: str) -> None:
report_dict = asdict(report)
for finding in report_dict["findings"]:
finding["severity"] = finding["severity"].split(".")[-1]
path = Path(output_path)
path.write_text(json.dumps(report_dict, indent=2), encoding="utf-8")
console = Console()
console.print(f"[green]JSON report saved to: {output_path}[/green]")
def report_markdown(report: RiskReport, output_path: str) -> None:
lines: list[str] = []
lines.append("# IAM Risk Analyzer Report")
lines.append("")
lines.append(f"- Policy: `{report.policy_file}`")
lines.append(f"- Analyzed: {report.analyzed_at}")
lines.append(f"- Risk Score: **{report.risk_score}/100** ({report.risk_level})")
lines.append("")
lines.append("## Security Findings")
lines.append("")
if report.findings:
lines.append("| Severity | Title | Description | Remediation |")
lines.append("|----------|-------|-------------|-------------|")
for finding in report.findings:
desc = finding.description.replace("|", "\\|")
rem = finding.remediation.replace("|", "\\|")
title = finding.title.replace("|", "\\|")
lines.append(f"| {finding.severity.value} | {title} | {desc} | {rem} |")
else:
lines.append("No security findings detected.")
lines.append("")
if report.attack_paths:
lines.append("## Attack Path Analysis")
lines.append("")
for i, path in enumerate(report.attack_paths, 1):
lines.append(f"{i}. {path}")
lines.append("")
path = Path(output_path)
path.write_text("\n".join(lines), encoding="utf-8")
console = Console()
console.print(f"[green]Markdown report saved to: {output_path}[/green]")
Create the CLI module
Typer generates professional CLIs from Python type hints. It automatically creates help text, validates arguments, and provides colored error messages.
- Create a new file src/iam_analyzer/cli.py and paste the following code:
"""Command-line interface for IAM Risk Analyzer."""
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
from iam_analyzer.attack_paths import analyze_attack_paths
from iam_analyzer.loader import PolicyLoadError, load_policy
from iam_analyzer.models import RiskReport
from iam_analyzer.reporter import report_console, report_json, report_markdown
from iam_analyzer.rules import analyze_policy
from iam_analyzer.scorer import calculate_risk_score, get_risk_level
# Create the Typer app with metadata
app = typer.Typer(
name="iam-analyzer",
help="Analyze AWS IAM policies and generate security risk assessments.",
add_completion=False,
)
console = Console()
@app.command()
def analyze(
policy_file: str = typer.Argument(help="Path to the IAM policy JSON file to analyze."),
format: str = typer.Option("console", "--format", "-f", help="Output format: console, json, or markdown."),
output: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path."),
) -> None:
# Validate the chosen format
valid_formats = ["console", "json", "markdown"]
if format not in valid_formats:
console.print(f"[red]Error: Invalid format '{format}'. Choose from: {', '.join(valid_formats)}[/red]")
raise typer.Exit(code=1)
# Require --output for file-based formats
if format in ["json", "markdown"] and output is None:
console.print(f"[red]Error: --output is required for {format} format.[/red]")
raise typer.Exit(code=1)
# Load the policy file
try:
policy = load_policy(policy_file)
except PolicyLoadError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(code=1)
# Run the full analysis pipeline
findings = analyze_policy(policy)
risk_score = calculate_risk_score(findings)
risk_level = get_risk_level(risk_score)
attack_paths = analyze_attack_paths(policy)
# Build the report dataclass
report = RiskReport(
policy_file=policy_file,
findings=findings,
risk_score=risk_score,
risk_level=risk_level,
attack_paths=attack_paths,
)
# Output in the requested format
if format == "console":
report_console(report)
elif format == "json":
report_json(report, output)
elif format == "markdown":
report_markdown(report, output)
@app.command()
def version() -> None:
"""Show the IAM Risk Analyzer version."""
from iam_analyzer import __version__
console.print(f"IAM Risk Analyzer v{__version__}")
if __name__ == "__main__":
app()
What does this code do?
- typer.Typer() creates the CLI application. The add_completion=False flag disables shell completion install prompts.
- The analyze command uses typer.Argument for the required policy file path and typer.Option for the optional format and output flags.
- The function validates inputs first, then runs the full pipeline: load, analyze, score, trace attack paths, and report.
- Save cli.py.
Getting an import error?
Make sure reporter.py is saved in the same src/iam_analyzer/ directory as cli.py. The imports reference sibling modules within the same package.
help me debug an import error in my IAM Analyzer CLI
✔️ Awesome, I've got everything!
Great. Double check you have saved cli.py before moving on.
ⓧ I'd like to double check the full code
"""Command-line interface for IAM Risk Analyzer."""
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
from iam_analyzer.attack_paths import analyze_attack_paths
from iam_analyzer.loader import PolicyLoadError, load_policy
from iam_analyzer.models import RiskReport
from iam_analyzer.reporter import report_console, report_json, report_markdown
from iam_analyzer.rules import analyze_policy
from iam_analyzer.scorer import calculate_risk_score, get_risk_level
app = typer.Typer(
name="iam-analyzer",
help="Analyze AWS IAM policies and generate security risk assessments.",
add_completion=False,
)
console = Console()
@app.command()
def analyze(
policy_file: str = typer.Argument(help="Path to the IAM policy JSON file to analyze."),
format: str = typer.Option("console", "--format", "-f", help="Output format: console, json, or markdown."),
output: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path."),
) -> None:
valid_formats = ["console", "json", "markdown"]
if format not in valid_formats:
console.print(f"[red]Error: Invalid format '{format}'. Choose from: {', '.join(valid_formats)}[/red]")
raise typer.Exit(code=1)
if format in ["json", "markdown"] and output is None:
console.print(f"[red]Error: --output is required for {format} format.[/red]")
raise typer.Exit(code=1)
try:
policy = load_policy(policy_file)
except PolicyLoadError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(code=1)
findings = analyze_policy(policy)
risk_score = calculate_risk_score(findings)
risk_level = get_risk_level(risk_score)
attack_paths = analyze_attack_paths(policy)
report = RiskReport(
policy_file=policy_file,
findings=findings,
risk_score=risk_score,
risk_level=risk_level,
attack_paths=attack_paths,
)
if format == "console":
report_console(report)
elif format == "json":
report_json(report, output)
elif format == "markdown":
report_markdown(report, output)
@app.command()
def version() -> None:
from iam_analyzer import __version__
console.print(f"IAM Risk Analyzer v{__version__}")
if __name__ == "__main__":
app()
Create the entry point and test the full pipeline
Python needs a __main__.py file to run a package with python -m. This file is the bridge between the module system and your Typer app.
- Create a new file src/iam_analyzer/__main__.py and paste this code:
"""Allow running as python -m iam_analyzer."""
from iam_analyzer.cli import app
app()
What does this file do?
When you run python -m iam_analyzer, Python looks for a __main__.py file in the package and executes it. This file imports the Typer app and calls it, so the CLI starts up.
- Save __main__.py.
What about the pyproject.toml entry point?
Your pyproject.toml already has the console script entry point iam-analyzer = "iam_analyzer.cli:app" configured from Step 1. This means once the package is installed, the iam-analyzer command will work system-wide. For now, you will use python -m iam_analyzer to run the tool directly.
- Test the console output by running this command in your terminal:
python -m iam_analyzer analyze policies/admin_wildcard.json
- You should see colored terminal output with a blue header panel, a red risk score (100/100 Severe), a findings table with CRITICAL entries, and a red attack paths panel.
- Test the Markdown export by running:
python -m iam_analyzer analyze policies/passrole_escalation.json --format markdown --output report.md
- You should see a green confirmation message saying the report was saved. Verify the file exists by running:
ls report.md
- You should see report.md listed in the output.
Seeing ModuleNotFoundError?
Make sure your virtual environment is active (you should see (.venv) in your terminal prompt). Also confirm you are running the command from the project root directory where the src/ folder lives.
If your virtual environment is active but you still see the error, install the package in development mode by running:
pip install -e .
help me fix a ModuleNotFoundError when running my IAM Analyzer
Your IAM Risk Analyzer is fully operational. You have a modular Python application that loads policies, detects dangerous patterns, scores risk, traces attack paths, and exports professional reports in three formats. Next up, you will put it to the test with a Secret Mission.
Secret mission
Add pytest Unit Tests
Your analyzer detects dangerous IAM patterns, but how do you know it keeps working as you add new rules? Add a comprehensive pytest test suite that validates your detection rules, scoring algorithm, and attack path analyzer. This proves your tool is production-ready.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The iam-risk-analyzer project folder on your Desktop (includes source code, virtual environment, sample policies, tests, and any generated reports).
- Python 3.12 installed on your system (via Homebrew or the python.org installer).
Keep everything running
No action needed. Choose this if you plan to continue building on this project or want to keep the tool ready for future IAM policy reviews.
- Your iam-risk-analyzer folder stays on your Desktop.
- Python 3.12 stays installed and available for other projects.
- Your virtual environment (.venv) stays inside the project folder, ready to reactivate.
Pause - I'll come back to this later
Deactivate the virtual environment to free up your terminal session. Your project files stay intact on disk.
- Deactivate the virtual environment by running:
deactivate
- When you come back, reactivate it by navigating to your project folder and running:
cd ~/Desktop/iam-risk-analyzer
source .venv/bin/activate
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes all source code, sample policies, test files, and generated reports.
- Deactivate the virtual environment first (if it is still active):
deactivate
- Delete the entire project folder by running:
macOS
rm -rf ~/Desktop/iam-risk-analyzer
Windows
rmdir /s /q %USERPROFILE%\Desktop\iam-risk-analyzer
- If you also want to remove Python 3.12 (Homebrew install), run:
brew uninstall python@3.12
Should I uninstall Python?
Python 3.12 is useful for many other projects. Only uninstall it if you are certain you will not need it again.
Nice Work!
Nice Work!
Nice work! You just built a professional-grade IAM policy risk analyzer from scratch using modular Python architecture.
You've learned how to:
- Architect a modular CLI application using src-layout with 7 source modules, dataclasses for data models, and Typer for professional command-line interfaces.
- Implement a rules engine that detects 6+ categories of dangerous IAM permission patterns and a risk scoring algorithm (0-100) with weighted severity calculations.
- Build an attack path analyzer that explains how permission combinations enable privilege escalation, with multi-format reporting using Rich console tables, JSON, and Markdown.
- Secret Mission: Added a pytest unit test suite validating detection rules, scoring calculations, and attack path generation.
Ready to quiz yourself?