Cloud Security Drift Detector
Build a CLI tool that detects security drift between cloud configurations.
Introduction
30 Second Summary
Imagine locking every door and window in your house before bed, but waking up to find the back door wide open. In cloud security, this happens when configuration settings quietly drift from their intended secure state.
In this project, you will build a command-line security tool that compares cloud configurations and flags dangerous changes with severity ratings and fix-it recommendations.
What You'll Build
Cloud Security Drift Detector is a Python CLI application that reads baseline and current cloud configuration files, then detects security-relevant changes using a rules engine.
By the end of this project, you'll have:
- A Python CLI application with a compare command that detects security drift between cloud configuration files.
- Five security detection rules with severity levels, color-coded terminal output, and Markdown/JSON report generation.
- A GitHub Actions CI workflow that runs automated tests on every push.
- Secret Mission: Add pytest unit tests targeting 80%+ coverage.
Are there any prerequisites?
Basic programming knowledge is all you need. A JavaScript background transfers well to Python.
You should have Python installed on your machine, along with Git and a GitHub account ready to go.
Set Up the Project
Every professional tool starts with a solid foundation. A well-organized directory structure, pinned dependencies, and version control make the difference between a throwaway script and a portfolio-ready project.
In this step, you'll create the project folder with all subdirectories, set up a Python virtual environment, install dependencies, initialize Git, and create example configuration files your detector will analyze.
In this step, get ready to:
- Create your project structure with a virtual environment and pinned dependencies.
- Initialize a Git repository with a Python-specific .gitignore.
- Create example YAML configuration files for testing drift detection.
Create the project structure and virtual environment
Your project needs a clean directory layout from the start. Separating detection logic, example files, and tests into their own folders keeps the codebase navigable as it grows.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder, subdirectories, and an empty package file by running these commands:
macOS
mkdir -p drift-detector/detector drift-detector/examples drift-detector/tests drift-detector/.github/workflows
touch drift-detector/detector/__init__.py
cd drift-detector
Windows
mkdir drift-detector\detector, drift-detector\examples, drift-detector\tests, drift-detector\.github\workflows
New-Item drift-detector\detector\__init__.py
cd drift-detector
- Confirm your folder structure by running:
ls
You should see detector, examples, and tests listed in the output.
Don't see those folders?
Make sure you ran the cd drift-detector command at the end. Running ls from the wrong directory shows the wrong contents.
help me figure out why my project directories are missing
Now you need an isolated Python environment so your project dependencies don't conflict with other projects on your machine.
- Create and activate a virtual environment by running these commands:
macOS
python3 -m venv venv
source venv/bin/activate
Windows
python -m venv venv
venv\Scripts\activate
What is a virtual environment?
A virtual environment is a self-contained folder that holds its own copy of Python and installed packages. This way, the libraries you install for this project won't interfere with other Python projects on your system.
You'll know the environment is active when you see (venv) at the beginning of your terminal prompt.
Not seeing (venv) in your prompt?
On Windows, you may need to allow script execution first. Run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned and then try the activate command again.
help me activate my Python virtual environment
Next, you need a file that lists every package your project depends on, with exact version numbers pinned so anyone can reproduce your environment.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the drift-detector folder on your Desktop, then click Open.
- Create a new file called requirements.txt by clicking the new file icon in the Explorer sidebar (or press Cmd+N on macOS / Ctrl+N on Windows, then save as requirements.txt).
- Paste the following dependency list into requirements.txt:
typer==0.26.7
pydantic==2.13.4
pyyaml==6.0.3
rich==15.0.0
pytest==9.1.0
pytest-cov==6.1.1
What are these packages?
- typer builds the command-line interface using Python type hints.
- pydantic validates data and provides JSON serialization for reports.
- pyyaml parses YAML configuration files.
- rich adds color-coded tables and panels to terminal output.
- pytest and pytest-cov handle testing and coverage reporting.
- Save requirements.txt.
- Switch back to your terminal and install all dependencies by running:
pip install -r requirements.txt
You should see pip downloading and installing each package. This may take a minute.
- Verify everything installed correctly by running:
python -c "import typer; import pydantic; import yaml; print('All dependencies installed')"
You should see All dependencies installed printed in your terminal.
Seeing an import error?
Make sure your virtual environment is active. You should see (venv) in your terminal prompt. If it disappeared, reactivate it with source venv/bin/activate (macOS) or venv\Scripts\activate (Windows), then run the pip install command again.
help me fix my dependency installation
Initialize a Git repository
Version control tracks every change you make and is essential for the GitHub Actions CI pipeline you'll set up later.
- Initialize a new Git repository by running:
git init
Your project needs a .gitignore file to keep generated files and your virtual environment out of version control.
- In VS Code, create a new file called .gitignore in the root of your drift-detector folder.
- Paste the following into .gitignore:
__pycache__/
*.py[cod]
venv/
.venv/
.pytest_cache/
.coverage
reports/
What does this file do?
The .gitignore file tells Git which files and folders to skip when committing. Compiled Python files, virtual environments, and generated reports don't belong in your repository.
- Save .gitignore.
- Switch back to your terminal and make your first commit by running:
git add .
git commit -m "Initial project structure"
You should see a message showing files committed, including requirements.txt and .gitignore.
Getting a git config error?
If Git asks you to set your name and email, run these two commands with your own details:
git config --global user.name "Your Name"
git config --global user.email "your@email.com"
Then run the git commit command again.
help me fix my git commit error
Create the example configuration files
Your drift detector needs sample cloud configurations to compare. You'll create three files: a secure baseline, a safe current state with no drift, and a risky current state with security violations.
- In VS Code, expand the examples folder in the Explorer sidebar.
- Right-click on examples and select New File.
- Name the file baseline.yaml and paste the following content:
s3_bucket:
public_access: false
security_group:
ssh_open_to_world: false
cloudtrail:
enabled: true
encryption:
at_rest: true
iam:
mfa_enabled: true
What does this baseline represent?
This file represents a secure cloud configuration. Public access is off, SSH is locked down, audit logging is active, encryption is enabled, and MFA is required. Think of it as the "known good" state your security team approved.
- Save baseline.yaml.
- Create another file in examples called current_safe.yaml and paste the following:
s3_bucket:
public_access: false
security_group:
ssh_open_to_world: false
cloudtrail:
enabled: true
encryption:
at_rest: true
iam:
mfa_enabled: true
Why is this identical to the baseline?
When the current configuration matches the baseline exactly, it means no security drift has occurred. Your detector should report zero findings for this file.
- Save current_safe.yaml.
- Create one more file in examples called current_risky.yaml and paste the following:
s3_bucket:
public_access: true
security_group:
ssh_open_to_world: true
cloudtrail:
enabled: false
encryption:
at_rest: false
iam:
mfa_enabled: false
What changed here?
Every security setting has drifted to an unsafe value. S3 is publicly accessible, SSH is open to the world, audit logging is disabled, encryption is off, and MFA is gone. Your detector should flag all five of these as security drift.
- Save current_risky.yaml.
✔️ Awesome, I've got everything!
Great. Double check that you've saved all your files.
ⓧ I'd like to double check the full code
Here is the full content of each file you should have at this point.
typer==0.26.7
pydantic==2.13.4
pyyaml==6.0.3
rich==15.0.0
pytest==9.1.0
pytest-cov==6.1.1
__pycache__/
*.py[cod]
venv/
.venv/
.pytest_cache/
.coverage
reports/
s3_bucket:
public_access: false
security_group:
ssh_open_to_world: false
cloudtrail:
enabled: true
encryption:
at_rest: true
iam:
mfa_enabled: true
s3_bucket:
public_access: false
security_group:
ssh_open_to_world: false
cloudtrail:
enabled: true
encryption:
at_rest: true
iam:
mfa_enabled: true
s3_bucket:
public_access: true
security_group:
ssh_open_to_world: true
cloudtrail:
enabled: false
encryption:
at_rest: false
iam:
mfa_enabled: false
Your project foundation is in place. You have a clean directory structure, isolated dependencies, version control, and example configuration files ready for analysis. Next up, you'll build the data models and configuration parser that turn those YAML files into Python objects.
Build Data Models and Configuration Parser
Your project environment is set up and your example configuration files are ready. Now it's time to define how the tool represents security drift internally.
Before the tool can compare configurations, it needs two things: data models that structure drift findings into a consistent shape, and a parser that can read YAML and JSON configuration files. You'll build both in this step using Pydantic for data validation and PyYAML for file parsing.
In this step, get ready to:
- Define Pydantic data models for drift findings and reports.
- Build a configuration file parser that handles YAML and JSON.
- Test the parser with your example files.
Define Pydantic models in detector/models.py
Every drift finding your tool detects will have a consistent structure: which resource drifted, what changed, how severe it is, what the risk is, and what to do about it. Pydantic models enforce this structure at runtime, so malformed data is caught immediately.
- The detector/ directory already exists, but Python needs an __init__.py file inside it to treat it as a package. Create this empty file by running:
touch detector/__init__.py
On Windows?
If touch is not recognized, run type nul > detector\__init__.py instead. Or right-click the detector folder in VS Code's Explorer sidebar, select New File, and name it __init__.py.
- Confirm the file was created by running:
ls detector/
You should see __init__.py listed in the output.
- Create a new file called models.py inside the detector/ folder. In VS Code's Explorer sidebar, right-click the detector folder and select New File.
- Name the file models.py and add the following code:
from enum import Enum
from datetime import datetime
from pydantic import BaseModel
class Severity(str, Enum):
CRITICAL = "CRITICAL"
HIGH = "HIGH"
class DriftFinding(BaseModel):
resource: str
change: str
severity: Severity
risk: str
recommendation: str
class DriftReport(BaseModel):
timestamp: str
baseline_file: str
current_file: str
findings: list[DriftFinding]
What does this code do?
- Severity is a string enum with two levels: CRITICAL and HIGH. Using an enum restricts severity to only valid values.
- DriftFinding is a Pydantic model representing a single detected drift. It captures which resource changed, what changed, how severe it is, the risk description, and a remediation recommendation.
- DriftReport wraps a list of findings with metadata (timestamp, which files were compared). This model becomes the input for report generation later.
- Save detector/models.py.
You won't see any visible output yet. The next substep builds the parser that uses these models.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
from enum import Enum
from datetime import datetime
from pydantic import BaseModel
class Severity(str, Enum):
CRITICAL = "CRITICAL"
HIGH = "HIGH"
class DriftFinding(BaseModel):
resource: str
change: str
severity: Severity
risk: str
recommendation: str
class DriftReport(BaseModel):
timestamp: str
baseline_file: str
current_file: str
findings: list[DriftFinding]
Build the configuration parser in detector/parser.py
The parser's job is to read a configuration file from disk and return its contents as a plain Python dictionary. It needs to handle both YAML and JSON formats, since real-world teams use both.
- Create a new file called parser.py inside the detector/ folder. Right-click the detector folder in VS Code's Explorer sidebar and select New File.
- Name the file parser.py and add the following code:
from pathlib import Path
import json
import yaml
def load_config(file_path: str) -> dict:
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"Configuration file not found: {file_path}")
suffix = path.suffix.lower()
if suffix in (".yaml", ".yml"):
with open(path) as f:
return yaml.safe_load(f)
elif suffix == ".json":
with open(path) as f:
return json.load(f)
else:
raise ValueError(f"Unsupported file format: {suffix}. Use .yaml, .yml, or .json")
What does this code do?
- load_config() takes a file path as a string and returns the parsed contents as a dictionary.
- It checks whether the file actually exists before attempting to read it. If the file is missing, it raises a clear FileNotFoundError.
- The function auto-detects the format by checking the file extension. YAML files are parsed with yaml.safe_load() and JSON files with json.load().
- Any file with an unrecognized extension raises a ValueError with a helpful message listing supported formats.
- Save detector/parser.py.
You won't see output yet. The next substep runs the parser against a real file to confirm everything works.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
from pathlib import Path
import json
import yaml
def load_config(file_path: str) -> dict:
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"Configuration file not found: {file_path}")
suffix = path.suffix.lower()
if suffix in (".yaml", ".yml"):
with open(path) as f:
return yaml.safe_load(f)
elif suffix == ".json":
with open(path) as f:
return json.load(f)
else:
raise ValueError(f"Unsupported file format: {suffix}. Use .yaml, .yml, or .json")
Test the parser manually
Both files are saved. Time to confirm that the parser can actually read your example baseline and return the expected dictionary structure.
- Test the parser by running this command in your terminal:
python -c "from detector.parser import load_config; print(load_config('examples/baseline.yaml'))"
What should I see?
You should see a Python dictionary printed to the terminal that matches your baseline YAML structure:
{'s3_bucket': {'public_access': False}, 'security_group': {'ssh_open_to_world': False}, 'cloudtrail': {'enabled': True}, 'encryption': {'at_rest': True}, 'iam': {'mfa_enabled': True}}
Each YAML key became a dictionary key. Nested values became nested dictionaries. Boolean YAML values became Python True and False.
Seeing a ModuleNotFoundError?
- Make sure you are running the command from the project's root directory (the folder containing the detector/ folder and examples/ folder).
- Confirm that detector/__init__.py exists. Without it, Python does not recognize detector as a package.
- Check that your virtual environment is still active. You should see (venv) at the start of your terminal prompt.
Still stuck? Help me debug this ModuleNotFoundError when importing from detector.parser
Your data layer is complete. The tool now knows what a drift finding looks like and can read configuration files in both YAML and JSON. Next up, you'll build the security rules and comparison engine that actually detects drift between two configurations.
Build Security Rules and Comparator Engine
In the last step, you built a parser that can load YAML and JSON configuration files into Python dictionaries. Now you have raw data, but no way to tell whether something has actually drifted from a secure state.
In this step, you'll define the security rules that specify what "safe" looks like for each configuration key. Then you'll build a comparator engine that checks the current config against those rules and flags any drift.
In this step, get ready to:
- Define 5 security rules with severity levels and remediation advice.
- Build a comparator engine that detects drift between baseline and current configurations.
- Verify the engine finds all 5 drift issues in the risky configuration.
Implement security rules in detector/rules.py
Security rules are the "brain" of the drift detector. Each rule defines what a safe configuration value looks like for a specific setting. When the current value doesn't match the safe value, that's drift.
- Create a new file called rules.py inside the detector/ folder.
- Add the imports and the SecurityRule dataclass by pasting this code:
from dataclasses import dataclass
from detector.models import Severity
@dataclass
class SecurityRule:
key_path: str
safe_value: bool
severity: Severity
resource: str
risk: str
recommendation: str
What does this code do?
A dataclass is a Python class that automatically generates __init__ and other methods from field definitions. It's perfect for structured data that doesn't need validation logic.
- key_path uses dot-notation to locate a value in nested config (e.g. s3_bucket.public_access).
- safe_value is what the configuration SHOULD be set to. Anything different means drift.
- severity uses the Severity enum you already built in models.py.
Now you'll define the actual rules. Each one maps to a real-world cloud security misconfiguration that drift detectors flag in production environments.
- Add the SECURITY_RULES list below the dataclass by pasting this code:
SECURITY_RULES = [
SecurityRule(
key_path="s3_bucket.public_access",
safe_value=False,
severity=Severity.HIGH,
resource="S3 Bucket",
risk="Potential public exposure of sensitive data.",
recommendation="Disable public access and enable block public access settings.",
),
SecurityRule(
key_path="security_group.ssh_open_to_world",
safe_value=False,
severity=Severity.HIGH,
resource="Security Group",
risk="Internet-facing SSH access increases attack surface.",
recommendation="Restrict SSH access to trusted IP ranges only.",
),
SecurityRule(
key_path="cloudtrail.enabled",
safe_value=True,
severity=Severity.CRITICAL,
resource="CloudTrail",
risk="Loss of audit logging removes visibility into account activity.",
recommendation="Re-enable CloudTrail immediately and verify log integrity.",
),
SecurityRule(
key_path="encryption.at_rest",
safe_value=True,
severity=Severity.HIGH,
resource="Encryption",
risk="Data at rest is no longer protected by encryption.",
recommendation="Re-enable encryption for all data stores.",
),
SecurityRule(
key_path="iam.mfa_enabled",
safe_value=True,
severity=Severity.HIGH,
resource="IAM",
risk="Increased risk of account compromise without MFA.",
recommendation="Require MFA for all IAM users and root account.",
),
]
What do these 5 rules cover?
- S3 public access (HIGH): Detects when a bucket is exposed to the internet.
- SSH open to world (HIGH): Detects when SSH port 22 is accessible from any IP address.
- CloudTrail disabled (CRITICAL): The only CRITICAL rule. Losing audit logs means you can't track who did what in your account.
- Encryption at rest disabled (HIGH): Detects when stored data loses encryption protection.
- MFA disabled (HIGH): Detects when multi-factor authentication is turned off for IAM users.
- Save detector/rules.py.
Seeing a red underline on Severity?
Make sure the import line at the top says from detector.models import Severity. VS Code may flag the import until you save both files.
Help me fix import errors in my detector package.
✔️ Awesome, I've got everything!
Great. Double check you've saved detector/rules.py before moving on.
ⓧ I'd like to double check the full code
from dataclasses import dataclass
from detector.models import Severity
@dataclass
class SecurityRule:
key_path: str
safe_value: bool
severity: Severity
resource: str
risk: str
recommendation: str
SECURITY_RULES = [
SecurityRule(
key_path="s3_bucket.public_access",
safe_value=False,
severity=Severity.HIGH,
resource="S3 Bucket",
risk="Potential public exposure of sensitive data.",
recommendation="Disable public access and enable block public access settings.",
),
SecurityRule(
key_path="security_group.ssh_open_to_world",
safe_value=False,
severity=Severity.HIGH,
resource="Security Group",
risk="Internet-facing SSH access increases attack surface.",
recommendation="Restrict SSH access to trusted IP ranges only.",
),
SecurityRule(
key_path="cloudtrail.enabled",
safe_value=True,
severity=Severity.CRITICAL,
resource="CloudTrail",
risk="Loss of audit logging removes visibility into account activity.",
recommendation="Re-enable CloudTrail immediately and verify log integrity.",
),
SecurityRule(
key_path="encryption.at_rest",
safe_value=True,
severity=Severity.HIGH,
resource="Encryption",
risk="Data at rest is no longer protected by encryption.",
recommendation="Re-enable encryption for all data stores.",
),
SecurityRule(
key_path="iam.mfa_enabled",
safe_value=True,
severity=Severity.HIGH,
resource="IAM",
risk="Increased risk of account compromise without MFA.",
recommendation="Require MFA for all IAM users and root account.",
),
]
Build the comparator engine in detector/comparator.py
The comparator engine is the component that actually compares two configurations and applies your rules. It needs a helper function to navigate nested dictionaries using dot-notation paths like s3_bucket.public_access.
- Create a new file called comparator.py inside the detector/ folder.
- Add the imports and the get_nested_value() helper function by pasting this code:
from detector.models import DriftFinding
from detector.rules import SECURITY_RULES
def get_nested_value(data: dict, key_path: str):
current = data
for key in key_path.split("."):
if isinstance(current, dict) and key in current:
current = current[key]
else:
return None
return current
What does get_nested_value() do?
This helper takes a dictionary and a dot-separated path string. It splits the path on dots and walks through each level of the nested dictionary one key at a time.
For example, calling get_nested_value(config, "s3_bucket.public_access") first looks up config["s3_bucket"], then looks up ["public_access"] inside that result. If any key is missing along the way, it returns None instead of crashing.
Now you'll add the main comparison logic that uses the helper to check each rule.
- Add the compare_configs() function below get_nested_value() by pasting this code:
def compare_configs(baseline: dict, current: dict) -> list[DriftFinding]:
findings = []
for rule in SECURITY_RULES:
baseline_val = get_nested_value(baseline, rule.key_path)
current_val = get_nested_value(current, rule.key_path)
if baseline_val is None or current_val is None:
continue
if baseline_val == rule.safe_value and current_val != rule.safe_value:
findings.append(DriftFinding(
resource=rule.resource,
change=f"{rule.key_path}: {baseline_val} -> {current_val}",
severity=rule.severity,
risk=rule.risk,
recommendation=rule.recommendation,
))
return findings
What does this code do?
The compare_configs() function loops through every rule in SECURITY_RULES and checks two conditions:
- The baseline matches the safe value (confirming it was previously secure).
- The current value is different from the safe value (confirming it has drifted).
Both conditions must be true for a finding to be created. This prevents false positives from configs that were already insecure in the baseline.
- Save detector/comparator.py.
Seeing import errors from rules or models?
Make sure detector/__init__.py exists (even if it's empty). Python needs this file to treat the detector/ folder as a package.
Help me fix my detector package imports.
✔️ Awesome, I've got everything!
Great. Double check you've saved detector/comparator.py before moving on.
ⓧ I'd like to double check the full code
from detector.models import DriftFinding
from detector.rules import SECURITY_RULES
def get_nested_value(data: dict, key_path: str):
current = data
for key in key_path.split("."):
if isinstance(current, dict) and key in current:
current = current[key]
else:
return None
return current
def compare_configs(baseline: dict, current: dict) -> list[DriftFinding]:
findings = []
for rule in SECURITY_RULES:
baseline_val = get_nested_value(baseline, rule.key_path)
current_val = get_nested_value(current, rule.key_path)
if baseline_val is None or current_val is None:
continue
if baseline_val == rule.safe_value and current_val != rule.safe_value:
findings.append(DriftFinding(
resource=rule.resource,
change=f"{rule.key_path}: {baseline_val} -> {current_val}",
severity=rule.severity,
risk=rule.risk,
recommendation=rule.recommendation,
))
return findings
Verify the comparator works
Time to confirm that the comparator correctly identifies all 5 security drifts in the risky configuration file.
- Test the comparator by running this command in your terminal:
python -c "
from detector.parser import load_config
from detector.comparator import compare_configs
findings = compare_configs(load_config('examples/baseline.yaml'), load_config('examples/current_risky.yaml'))
print(f'{len(findings)} findings detected')
for f in findings:
print(f' {f.severity.value}: {f.resource}')
"
- You should see output showing 5 findings with their severity levels and resource names.
What should I see?
Your terminal should display something like:
5 findings detected
HIGH: S3 Bucket
HIGH: Security Group
CRITICAL: CloudTrail
HIGH: Encryption
HIGH: IAM
4 rules are HIGH severity and 1 (CloudTrail) is CRITICAL. All 5 values in the risky config have drifted from their safe baseline values.
Seeing 0 findings or a ModuleNotFoundError?
Make sure you're running the command from your project's root directory (the folder containing the detector/ folder and examples/ folder). Also confirm your virtual environment is still activated.
If you see 0 findings, double check that examples/current_risky.yaml has all values flipped from the baseline (e.g. public_access: true).
Help me debug why my comparator isn't finding drift.
Your detection engine is working. It can identify all 5 categories of security drift with accurate severity levels. Next up, you'll build a professional CLI interface with color-coded output so the findings are easy to read at a glance.
Create the CLI Interface with Rich Output
Your comparator engine can detect security drift, but right now it only works as a Python import. Real security tools need a command-line interface that anyone on a team can run without opening a code editor.
In this step, you will build a CLI using Typer and add color-coded terminal output with Rich. You will also add Markdown and JSON report generation so findings can be shared or stored.
In this step, get ready to:
- Build a Rich-powered output module with color-coded severity tables and report generation.
- Create a Typer CLI application with a compare command.
- Test the full tool end-to-end with both safe and risky configurations.
Build the Rich output module
The report generator module handles everything the user sees after running the tool. It prints a color-coded table to the terminal and can write findings to Markdown or JSON files.
- Create a new file called report_generator.py inside your detector folder.
- Add the imports, console setup, and the print_findings() function by pasting the following code:
from pathlib import Path
import json
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from detector.models import DriftFinding, DriftReport, Severity
console = Console()
SEVERITY_COLORS = {Severity.CRITICAL: "red", Severity.HIGH: "yellow"}
def print_findings(findings: list[DriftFinding]) -> None:
if not findings:
console.print(Panel("[green]No security drift detected.[/green]", title="Result"))
return
console.print(Panel(
f"[bold red]DRIFT DETECTED[/bold red] - {len(findings)} finding(s)",
title="Security Drift Report",
))
table = Table(show_header=True, header_style="bold")
table.add_column("Severity", width=10)
table.add_column("Resource", width=15)
table.add_column("Change", width=30)
table.add_column("Risk", width=40)
for f in findings:
color = SEVERITY_COLORS[f.severity]
table.add_row(f"[{color}]{f.severity.value}[/{color}]", f.resource, f.change, f.risk)
console.print(table)
What does this code do?
Rich is a Python library for beautiful terminal output. Here is how each piece works:
- SEVERITY_COLORS maps each severity level to a terminal color. CRITICAL findings appear in red, HIGH findings in yellow.
- When no findings exist, a green Panel displays "No security drift detected" so the user gets clear positive feedback.
- When drift is found, a Table organizes findings into columns for Severity, Resource, Change, and Risk.
Next, add the Markdown report generator. This function writes findings to a structured .md file that can be attached to pull requests or stored in version control.
- In detector/report_generator.py, add the generate_markdown_report() function below print_findings():
def generate_markdown_report(report: DriftReport) -> str:
output_dir = Path("reports")
output_dir.mkdir(exist_ok=True)
output_path = output_dir / "drift_report.md"
lines = [
"# Security Drift Report\n",
f"- Generated: {report.timestamp}",
f"- Baseline: {report.baseline_file}",
f"- Current: {report.current_file}",
f"- Total Findings: {len(report.findings)}\n",
"## Findings\n",
]
for f in report.findings:
lines.append(f"### {f.resource}\n")
lines.append(f"- Change: `{f.change}`")
lines.append(f"- Severity: **{f.severity.value}**")
lines.append(f"- Risk: {f.risk}")
lines.append(f"- Recommendation: {f.recommendation}\n")
output_path.write_text("\n".join(lines))
return str(output_path)
What does this function do?
- It creates a reports/ directory if one does not exist yet.
- It formats each finding as a Markdown section with headings, bullet points, and emphasis on severity.
- The reports/ folder is already in your .gitignore, so generated reports will not clutter your repository.
Finally, add the JSON report generator. JSON output is useful for integrating your tool with other security systems or dashboards.
- Still in detector/report_generator.py, add the generate_json_report() function below generate_markdown_report():
def generate_json_report(report: DriftReport) -> str:
output_dir = Path("reports")
output_dir.mkdir(exist_ok=True)
output_path = output_dir / "drift_report.json"
output_path.write_text(report.model_dump_json(indent=2))
return str(output_path)
What does this function do?
Pydantic's model_dump_json(indent=2) method serializes the entire DriftReport model (including nested DriftFinding objects) into pretty-printed JSON. No manual serialization loop needed.
- Save detector/report_generator.py.
Seeing a red underline on the imports?
If VS Code shows import errors for rich or detector.models, make sure your virtual environment is selected as the Python interpreter. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) and choose Python: Select Interpreter, then pick the one inside your venv folder.
Help me fix my Python interpreter in VS Code.
✔️ Awesome, I've got everything!
Great. Double check you have saved detector/report_generator.py before moving on.
ⓧ I'd like to double check the full code
from pathlib import Path
import json
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from detector.models import DriftFinding, DriftReport, Severity
console = Console()
SEVERITY_COLORS = {Severity.CRITICAL: "red", Severity.HIGH: "yellow"}
def print_findings(findings: list[DriftFinding]) -> None:
if not findings:
console.print(Panel("[green]No security drift detected.[/green]", title="Result"))
return
console.print(Panel(
f"[bold red]DRIFT DETECTED[/bold red] - {len(findings)} finding(s)",
title="Security Drift Report",
))
table = Table(show_header=True, header_style="bold")
table.add_column("Severity", width=10)
table.add_column("Resource", width=15)
table.add_column("Change", width=30)
table.add_column("Risk", width=40)
for f in findings:
color = SEVERITY_COLORS[f.severity]
table.add_row(f"[{color}]{f.severity.value}[/{color}]", f.resource, f.change, f.risk)
console.print(table)
def generate_markdown_report(report: DriftReport) -> str:
output_dir = Path("reports")
output_dir.mkdir(exist_ok=True)
output_path = output_dir / "drift_report.md"
lines = [
"# Security Drift Report\n",
f"- Generated: {report.timestamp}",
f"- Baseline: {report.baseline_file}",
f"- Current: {report.current_file}",
f"- Total Findings: {len(report.findings)}\n",
"## Findings\n",
]
for f in report.findings:
lines.append(f"### {f.resource}\n")
lines.append(f"- Change: `{f.change}`")
lines.append(f"- Severity: **{f.severity.value}**")
lines.append(f"- Risk: {f.risk}")
lines.append(f"- Recommendation: {f.recommendation}\n")
output_path.write_text("\n".join(lines))
return str(output_path)
def generate_json_report(report: DriftReport) -> str:
output_dir = Path("reports")
output_dir.mkdir(exist_ok=True)
output_path = output_dir / "drift_report.json"
output_path.write_text(report.model_dump_json(indent=2))
return str(output_path)
Create the Typer CLI application
Typer turns Python functions into CLI commands using type hints. You will create one compare command that accepts two file paths and optional flags for report output.
- Create a new file called main.py in the root of your project folder (the same level as requirements.txt).
- Paste the following code into main.py:
from datetime import datetime
import typer
from rich.console import Console
from detector.parser import load_config
from detector.comparator import compare_configs
from detector.models import DriftReport
from detector.report_generator import print_findings, generate_markdown_report, generate_json_report
app = typer.Typer(name="drift-detector", no_args_is_help=True)
console = Console()
@app.command()
def compare(
baseline: str = typer.Argument(help="Path to baseline configuration file"),
current: str = typer.Argument(help="Path to current configuration file"),
report: bool = typer.Option(False, "--report", help="Generate Markdown report"),
json_output: bool = typer.Option(False, "--json", help="Generate JSON report"),
) -> None:
"""Compare baseline and current cloud configurations to detect security drift."""
try:
baseline_config = load_config(baseline)
current_config = load_config(current)
except (FileNotFoundError, ValueError) as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(code=1)
findings = compare_configs(baseline_config, current_config)
print_findings(findings)
if findings and (report or json_output):
drift_report = DriftReport(
timestamp=datetime.now().isoformat(),
baseline_file=baseline,
current_file=current,
findings=findings,
)
if report:
path = generate_markdown_report(drift_report)
console.print(f"\n[green]Markdown report saved:[/green] {path}")
if json_output:
path = generate_json_report(drift_report)
console.print(f"\n[green]JSON report saved:[/green] {path}")
if __name__ == "__main__":
app()
What does this code do?
- typer.Typer(name="drift-detector", no_args_is_help=True) creates the CLI app. The no_args_is_help=True parameter shows the help message when no command is given.
- The @app.command() decorator turns the compare function into a CLI command. Typer uses the function parameters as CLI arguments and options automatically.
- typer.Argument() makes baseline and current required positional arguments. typer.Option(False, "--report") creates optional boolean flags that default to False.
- The try/except block catches file errors and prints a friendly red message instead of a Python traceback.
- Save main.py.
Seeing import errors for detector modules?
Make sure you are running commands from the project root folder (where main.py lives). If your terminal is inside the detector/ subdirectory, Python will not find the package correctly.
Help me fix module import errors in my drift detector project.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py before moving on to testing.
ⓧ I'd like to double check the full code
from datetime import datetime
import typer
from rich.console import Console
from detector.parser import load_config
from detector.comparator import compare_configs
from detector.models import DriftReport
from detector.report_generator import print_findings, generate_markdown_report, generate_json_report
app = typer.Typer(name="drift-detector", no_args_is_help=True)
console = Console()
@app.command()
def compare(
baseline: str = typer.Argument(help="Path to baseline configuration file"),
current: str = typer.Argument(help="Path to current configuration file"),
report: bool = typer.Option(False, "--report", help="Generate Markdown report"),
json_output: bool = typer.Option(False, "--json", help="Generate JSON report"),
) -> None:
"""Compare baseline and current cloud configurations to detect security drift."""
try:
baseline_config = load_config(baseline)
current_config = load_config(current)
except (FileNotFoundError, ValueError) as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(code=1)
findings = compare_configs(baseline_config, current_config)
print_findings(findings)
if findings and (report or json_output):
drift_report = DriftReport(
timestamp=datetime.now().isoformat(),
baseline_file=baseline,
current_file=current,
findings=findings,
)
if report:
path = generate_markdown_report(drift_report)
console.print(f"\n[green]Markdown report saved:[/green] {path}")
if json_output:
path = generate_json_report(drift_report)
console.print(f"\n[green]JSON report saved:[/green] {path}")
if __name__ == "__main__":
app()
Test the CLI end-to-end
Time to see everything working together. You will run the tool against both the risky configuration and the safe configuration to verify both output paths.
- Run the drift detector against the risky configuration by running this command:
python main.py compare examples/baseline.yaml examples/current_risky.yaml
- You should see a color-coded table with 5 findings. CRITICAL findings appear in red and HIGH findings in yellow.
Seeing a ModuleNotFoundError?
Confirm your virtual environment is active. You should see (venv) at the start of your terminal prompt. If not, activate it again with source venv/bin/activate (macOS) or venv\Scripts\activate (Windows).
Help me debug ModuleNotFoundError when running my drift detector.
- Now verify the "no drift" path by running this command:
python main.py compare examples/baseline.yaml examples/current_safe.yaml
- You should see a green panel that reads "No security drift detected." with the title "Result".
Why test both paths?
A security tool that only knows how to report problems is incomplete. Operators need clear confirmation when everything is healthy too. Testing both the "drift found" and "no drift" paths ensures your tool communicates clearly in all scenarios.
Your drift detector is now a fully working CLI tool with professional terminal output. Next up, you will push this to GitHub with a CI pipeline that runs automatically on every push.
Add Report Generation and GitHub Actions CI
Your CLI now detects drift and prints color-coded findings in the terminal. That's great for a quick check, but real security workflows need shareable artifacts.
In this step, you'll generate Markdown and JSON report files from your findings. Then you'll set up a GitHub Actions CI workflow so your drift detector runs automatically every time you push code.
In this step, get ready to:
- Generate Markdown and JSON drift reports from your CLI.
- Create a GitHub Actions CI workflow that tests your tool on every push.
- Push your project to GitHub and verify the workflow passes.
Test report file generation
Your detector/report_generator.py already has the report generation functions wired up. Now you'll exercise them to produce actual files.
- Generate both a Markdown and JSON report by running this command:
python main.py compare examples/baseline.yaml examples/current_risky.yaml --report --json
What do the flags do?
The --report flag triggers generate_markdown_report(), which writes findings to reports/drift_report.md.
The --json flag triggers generate_json_report(), which writes a structured JSON version to reports/drift_report.json. Both functions auto-create the reports/ directory if it doesn't exist yet.
- Verify the report files were created by running:
ls reports/
You should see drift_report.md and drift_report.json in the output.
- Open reports/drift_report.md in VS Code to inspect the Markdown report.
You should see a structured report with headings for each finding, including severity, risk, and recommendation fields.
Not seeing the reports/ folder?
Make sure you included both the --report and --json flags when running the command. Reports only generate when drift is actually detected, so make sure you're comparing against current_risky.yaml (not current_safe.yaml).
Help me debug why my drift reports aren't generating.
Create the GitHub Actions CI workflow
A CI/CD pipeline runs your tool automatically whenever you push changes. This catches regressions before they reach production.
- Create the workflow directory structure by running these commands:
mkdir -p .github/workflows
- Create a new file at .github/workflows/test.yml and add the following code:
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.14"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run drift detector smoke test
run: python main.py compare examples/baseline.yaml examples/current_risky.yaml
- name: Run tests
run: pytest -v --cov=detector
What does this workflow do?
- on: push/pull_request triggers the workflow whenever code is pushed to main or a pull request targets main.
- actions/setup-python@v6 installs Python 3.14 on the runner.
- The smoke test runs your CLI against the risky config to confirm it exits cleanly.
- The final step runs pytest with coverage reporting. Tests will be added in the Secret Mission.
- Save .github/workflows/test.yml.
Seeing YAML syntax errors?
YAML is sensitive to indentation. Make sure each nested level uses exactly 2 spaces (not tabs). The steps: key should be indented 4 spaces under test:, and each - uses: or - name: should be indented 6 spaces.
Help me fix my GitHub Actions YAML formatting.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.14"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run drift detector smoke test
run: python main.py compare examples/baseline.yaml examples/current_risky.yaml
- name: Run tests
run: pytest -v --cov=detector
Push to GitHub and verify the workflow
With the workflow file in place, pushing your code to GitHub will automatically trigger the CI pipeline.
- Go to github.com in your browser.
- Click the + icon in the top right, then click New repository.
- Enter cloud-security-drift-detector as the repository name.
- Leave the repository set to Public.
- Do NOT check any boxes for README, .gitignore, or license (your project already has these locally).
- Click Create repository.
GitHub shows a quick setup page with commands. You'll use those to connect your local project.
- Stage all your files, commit, and push to GitHub by running these commands:
git add .
git commit -m "Add report generation and CI workflow"
git branch -M main
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/cloud-security-drift-detector.git"]]
git push -u origin main
Where do I find my repository URL?
After creating the repository, GitHub displays the HTTPS URL at the top of the quick setup page. It looks like https://github.com/YOUR_USERNAME/cloud-security-drift-detector.git. Copy and paste that URL into the git remote add origin command.
- In your browser, navigate to the Actions tab of your new repository.
- Click on the running workflow to watch it execute.
You should see a green checkmark once all steps pass. The smoke test step confirms your CLI runs successfully in the CI environment.
What about the pytest step?
The workflow includes a pytest -v --cov=detector step. Right now there are no test files, so pytest will report "no tests ran" but still exit successfully. You'll add tests in the Secret Mission.
Workflow failing with a red X?
Click the failed job to see the logs. Common issues include a typo in the YAML indentation or a missing file in your commit. Make sure requirements.txt and the examples/ folder were included in your push.
Help me debug my failing GitHub Actions workflow.
Your drift detector is now a complete, portfolio-ready project with automated CI. Every push to main triggers the workflow, confirming your tool still works correctly. Next up: take on the Secret Mission to add pytest unit tests and push your coverage above 80%.
Secret mission
Add Pytest Unit Tests
Your drift detector works, but how do you prove it works reliably? In this secret mission, you'll add a professional test suite with pytest, target 80%+ code coverage, and wire the tests into your CI pipeline so every push is automatically validated.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
This project runs entirely locally, so there are no ongoing costs. Your GitHub repository is free to host on a free-tier GitHub account.
Resources you used:
- Local project folder on your Desktop (includes virtual environment, source code, and test files).
- GitHub repository containing your drift detector code and CI workflow.
Keep everything running
No action needed. Choose this if you're still actively building or want to keep the tool handy for future use.
- Your project folder stays on your Desktop, ready to use anytime.
- Your GitHub repository remains public (or private) and your CI workflow continues to run on new pushes.
- The virtual environment stays inside your project folder. Reactivate it anytime you want to run the tool.
Pause - I'll come back to this later
Shut down running processes to free up terminal sessions. Your files and repository stay intact.
- Deactivate your virtual environment by running:
deactivate
- Close your VS Code window and terminal.
- When you come back, reactivate the virtual environment from inside your project folder.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
- First, deactivate your virtual environment if it is still active:
deactivate
- Delete the project folder from your Desktop. On macOS/Linux, run:
cd ~/Desktop
rm -rf drift-detector
- On Windows, run:
cd ~\Desktop
rmdir /s /q drift-detector
- Delete your GitHub repository by going to your repository page on GitHub.
- Click Settings in the top navigation bar.
- Scroll down to the Danger Zone section at the bottom.
- Click Delete this repository and follow the confirmation prompts.
Nice Work!
Nice Work!
Nice work! You just built a professional cloud security tool from scratch and deployed it to GitHub with a full CI pipeline.
You've learned how to:
- Build a Python CLI application that compares cloud security configurations and flags drift with severity-rated findings and remediation recommendations.
- Design a rule-based detection engine with Pydantic data models, structured reporting in Markdown and JSON, and color-coded terminal output using Rich.
- Set up a GitHub Actions CI workflow that automatically runs tests on every push and pull request.
- Secret Mission: Achieved 80%+ test coverage with pytest by writing unit tests for every module in the project.
Ready to quiz yourself?