Skip to content

Commit 3518704

Browse files
committed
drift on the way
1 parent 342fe93 commit 3518704

File tree

9 files changed

+236
-1
lines changed

9 files changed

+236
-1
lines changed

devolv/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import typer
22
from devolv import __version__
33
from devolv.iam.validator.cli import validate
4+
from devolv.drift.cli import drift
45

56
app = typer.Typer(help="Devolv CLI - Modular DevOps Toolkit")
67

78
app.command("validate")(validate)
9+
app.command("drift")(drift)
810

911
@app.callback()
1012
def main(

devolv/drift/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Drift module init

devolv/drift/aws_fetcher.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import boto3
2+
import botocore
3+
4+
def get_policy(policy_name=None, policy_arn=None):
5+
client = boto3.client("iam")
6+
sts_client = boto3.client("sts")
7+
8+
try:
9+
if policy_arn:
10+
# Directly fetch by provided ARN
11+
return _fetch_policy_document(client, policy_arn)
12+
13+
# No ARN provided → try to construct ARN
14+
account_id = sts_client.get_caller_identity()["Account"]
15+
constructed_arn = f"arn:aws:iam::{account_id}:policy/{policy_name}"
16+
17+
try:
18+
return _fetch_policy_document(client, constructed_arn)
19+
except botocore.exceptions.ClientError as e:
20+
if e.response["Error"]["Code"] not in ["NoSuchEntity", "AccessDenied"]:
21+
raise # Unexpected error, re-raise
22+
23+
# Fallthrough to attempt list-based discovery
24+
pass
25+
26+
# Fallback: attempt to list policies (if permission allows)
27+
paginator = client.get_paginator('list_policies')
28+
for page in paginator.paginate(Scope='Local'):
29+
for policy in page['Policies']:
30+
if policy['PolicyName'] == policy_name:
31+
return _fetch_policy_document(client, policy['Arn'])
32+
33+
# As last attempt, try AWS-managed policies
34+
for page in paginator.paginate(Scope='AWS'):
35+
for policy in page['Policies']:
36+
if policy['PolicyName'] == policy_name:
37+
return _fetch_policy_document(client, policy['Arn'])
38+
39+
# Not found at all
40+
return None
41+
42+
except botocore.exceptions.ClientError as e:
43+
error = e.response.get("Error", {})
44+
code = error.get("Code", "UnknownError")
45+
message = error.get("Message", "")
46+
print(f"❌ AWS API error during policy fetch: {code}{message}")
47+
return None
48+
except Exception as e:
49+
print(f"❌ Unexpected error during policy fetch: {str(e)}")
50+
return None
51+
52+
def _fetch_policy_document(client, policy_arn):
53+
policy_meta = client.get_policy(PolicyArn=policy_arn)
54+
version_id = policy_meta["Policy"]["DefaultVersionId"]
55+
version = client.get_policy_version(
56+
PolicyArn=policy_arn,
57+
VersionId=version_id
58+
)
59+
return version["PolicyVersion"]["Document"]

devolv/drift/cli.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import typer
2+
from pathlib import Path
3+
from devolv.drift import aws_fetcher, file_loader, report
4+
import botocore
5+
6+
def drift(
7+
policy_name: str = typer.Option(..., "--policy-name", help="IAM policy name in AWS"),
8+
file: str = typer.Option(..., "--file", help="Path to local policy file")
9+
):
10+
try:
11+
local_path = Path(file)
12+
if not local_path.exists():
13+
typer.secho(f"❌ File not found: {file}", fg=typer.colors.RED)
14+
raise typer.Exit(1)
15+
16+
local_policy = file_loader.load_policy(local_path)
17+
aws_policy = aws_fetcher.get_policy(policy_name)
18+
19+
if aws_policy is None:
20+
typer.secho(f"⚠️ Could not fetch AWS policy '{policy_name}'.", fg=typer.colors.YELLOW)
21+
raise typer.Exit(1)
22+
23+
report.generate_diff_report(local_policy, aws_policy)
24+
25+
except botocore.exceptions.ClientError as e:
26+
error = e.response.get("Error", {})
27+
code = error.get("Code", "UnknownError")
28+
message = error.get("Message", "")
29+
30+
typer.secho(f"❌ AWS API error: {code}", fg=typer.colors.RED)
31+
32+
if code == "AccessDenied" and ' because ' in message:
33+
main, reason = message.split(' because ', 1)
34+
typer.echo(f" → {main.strip()}")
35+
typer.echo(f" → Reason: {reason.strip()}")
36+
else:
37+
typer.echo(f" → {message.strip()}")
38+
39+
typer.echo(f"⚠️ Could not fetch AWS policy '{policy_name}'.")
40+
typer.echo(f"💡 Tip: Ensure your IAM user has permission for {code}-related actions.")
41+
raise typer.Exit(1)
42+
43+
except typer.Exit:
44+
raise # Let Typer handle clean exits
45+
46+
except Exception as e:
47+
typer.secho(f"❌ Unexpected error: {str(e)}", fg=typer.colors.RED)
48+
raise typer.Exit(1)

devolv/drift/comparator.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from deepdiff import DeepDiff
2+
3+
def compare_policies(local, aws):
4+
diff = DeepDiff(local, aws, ignore_order=True)
5+
return diff

devolv/drift/file_loader.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import json
2+
import yaml
3+
4+
def load_policy(path):
5+
with open(path, "r") as f:
6+
if path.suffix in [".yaml", ".yml"]:
7+
return yaml.safe_load(f)
8+
else:
9+
return json.load(f)

devolv/drift/report.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import json
2+
import difflib
3+
from rich.console import Console
4+
from rich.text import Text
5+
6+
def clean_policy(policy):
7+
"""
8+
Remove empty statements ({} entries) from the policy's 'Statement' list.
9+
"""
10+
if isinstance(policy, dict) and "Statement" in policy:
11+
statements = policy.get("Statement", [])
12+
if isinstance(statements, list):
13+
policy["Statement"] = [s for s in statements if s]
14+
return policy
15+
16+
def generate_diff_report(local_policy, aws_policy):
17+
"""
18+
Generate a Git-style unified diff report for two IAM policies (local vs AWS),
19+
with inline highlights for changed parts of a line.
20+
"""
21+
console = Console()
22+
23+
# Clean out empty statements to reduce noise
24+
if isinstance(local_policy, dict):
25+
local_policy = clean_policy(local_policy)
26+
if isinstance(aws_policy, dict):
27+
aws_policy = clean_policy(aws_policy)
28+
29+
# Convert dicts to pretty-printed JSON strings
30+
if isinstance(local_policy, dict):
31+
local_str = json.dumps(local_policy, indent=2, sort_keys=True)
32+
else:
33+
local_str = str(local_policy)
34+
35+
if isinstance(aws_policy, dict):
36+
aws_str = json.dumps(aws_policy, indent=2, sort_keys=True)
37+
else:
38+
aws_str = str(aws_policy)
39+
40+
# Split into lines
41+
local_lines = local_str.splitlines(keepends=True)
42+
aws_lines = aws_str.splitlines(keepends=True)
43+
44+
# Generate unified diff
45+
diff_lines = list(difflib.unified_diff(
46+
local_lines,
47+
aws_lines,
48+
fromfile="local",
49+
tofile="aws",
50+
lineterm=""
51+
))
52+
53+
if not diff_lines:
54+
console.print("✅ No drift detected: Policies match.", style="green")
55+
return
56+
57+
i = 0
58+
while i < len(diff_lines):
59+
line = diff_lines[i]
60+
61+
if line.startswith('---') or line.startswith('+++'):
62+
console.print(Text(line, style="bold"))
63+
elif line.startswith('@@'):
64+
console.print(Text(line, style="cyan"))
65+
elif line.startswith('-'):
66+
# Check if next line is a '+', for possible inline diff
67+
if (i + 1 < len(diff_lines)) and diff_lines[i + 1].startswith('+'):
68+
next_line = diff_lines[i + 1]
69+
old_content = line[1:].rstrip('\n')
70+
new_content = next_line[1:].rstrip('\n')
71+
72+
# Use SequenceMatcher for inline diff
73+
matcher = difflib.SequenceMatcher(None, old_content, new_content)
74+
old_text = Text("-", style="red")
75+
new_text = Text("+", style="green")
76+
77+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
78+
if tag == 'equal':
79+
old_text.append(old_content[i1:i2], style="red")
80+
new_text.append(new_content[j1:j2], style="green")
81+
elif tag == 'replace':
82+
old_text.append(old_content[i1:i2], style="bold white on red")
83+
new_text.append(new_content[j1:j2], style="bold black on green")
84+
elif tag == 'delete':
85+
old_text.append(old_content[i1:i2], style="bold white on red")
86+
elif tag == 'insert':
87+
new_text.append(new_content[j1:j2], style="bold black on green")
88+
89+
console.print(old_text)
90+
console.print(new_text)
91+
i += 1 # Skip next line since it's handled
92+
else:
93+
console.print(Text(line, style="red"))
94+
elif line.startswith('+'):
95+
console.print(Text(line, style="green"))
96+
elif line.startswith(' '):
97+
console.print(Text(line, style="bright_black"))
98+
else:
99+
console.print(Text(line)) # Fallback for any edge case lines
100+
i += 1

devolv/drift/utils.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import boto3
2+
3+
def assume_role(role_arn, session_name="DevolvDriftSession"):
4+
client = boto3.client("sts")
5+
response = client.assume_role(RoleArn=role_arn, RoleSessionName=session_name)
6+
creds = response["Credentials"]
7+
return boto3.Session(
8+
aws_access_key_id=creds["AccessKeyId"],
9+
aws_secret_access_key=creds["SecretAccessKey"],
10+
aws_session_token=creds["SessionToken"],
11+
)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ readme = "README.md"
99
requires-python = ">=3.8"
1010
license = "MIT"
1111
authors = [{ name = "Devolv Dev", email = "devolv.dev@gmail.com" }]
12-
dependencies = ["typer>=0.9", "PyYAML"]
12+
dependencies = ["typer>=0.9", "PyYAML", "deepdiff>=6.0.0"]
1313
dynamic = ["version"]
1414

1515
[project.optional-dependencies]

0 commit comments

Comments
 (0)