#!/usr/bin/env python3 """ evaluate_policy.py ------------------ Read the JSON file produced by `gather_policy.sh`, ask the auditor for the expected/minimum values for each of the 10 IAM password‑policy items, and emit a CSV audit report. Features * Interactive prompts – press to mark a rule as N/A. * Most numeric items are treated as **minimums** (actual >= expected → PASS). * Maximum password age is treated as a **maximum** (actual <= expected → PASS), because a lower ceiling is the stricter/more‑secure setting. * Boolean items are treated as **exact matches** (actual == expected → PASS). * A required item that is absent from the policy is reported as FAIL rather than crashing (AWS omits e.g. MaxPasswordAge when password expiry is off). * The CSV begins with a small metadata block (same data that the Bash script captured) so the audit trail is self‑contained. * Usage: python3 evaluate_policy.py policy_report.json """ import csv import json import sys from datetime import datetime, timezone from pathlib import Path from typing import Any # ---------------------------------------------------------------------- # Mapping of the 10 password‑policy fields we care about # (rule_no, json_key, friendly_name, datatype) # ---------------------------------------------------------------------- # Numeric datatypes carry a direction: # "int_min" – actual must be >= expected (higher is stricter) # "int_max" – actual must be <= expected (lower is stricter) POLICY_FIELDS = [ (1, "MinimumPasswordLength", "Minimum password length", "int_min"), (2, "RequireSymbols", "Require symbols (!@#$…)", "bool"), (3, "RequireNumbers", "Require numbers (0‑9)", "bool"), (4, "RequireUppercaseCharacters", "Require uppercase letters (A‑Z)", "bool"), (5, "RequireLowercaseCharacters", "Require lowercase letters (a‑z)", "bool"), (6, "AllowUsersToChangePassword", "Allow users to change password", "bool"), (7, "ExpirePasswords", "Expire passwords (enable aging)", "bool"), (8, "MaxPasswordAge", "Maximum password age (days)", "int_max"), (9, "PasswordReusePrevention", "Prevent password reuse (last N)", "int_min"), (10, "HardExpiry", "Hard expiry (no grace period)", "bool"), ] # ---------------------------------------------------------------------- # Helper functions # ---------------------------------------------------------------------- def utc_now() -> datetime: """Return a timezone‑aware UTC datetime.""" return datetime.now(timezone.utc) def prompt_expected(field_type: str, description: str) -> Any | None: """ Ask the auditor for the expected value. Returns: - int / bool : the entered expectation - None : user pressed Enter → rule is N/A """ while True: raw = input( f"Enter expected value for '{description}' ({field_type}) or press to skip: " ).strip() if raw == "": return None # N/A if field_type.startswith("int"): if raw.isdigit(): return int(raw) print("Please enter a whole number (or leave blank).") elif field_type == "bool": lowered = raw.lower() if lowered in {"true", "t", "yes", "y", "1"}: return True if lowered in {"false", "f", "no", "n", "0"}: return False print("Boolean expected – type yes/no (or true/false).") else: # Should never happen return raw def evaluate(expect: Any | None, actual: Any, field_type: str) -> str: """Return PASS / FAIL / N/A. A required item (expectation set) that is absent or of the wrong type in the policy is a FAIL, never a crash. """ if expect is None: return "N/A" if field_type == "bool": # bool is a subclass of int, so guard against ints sneaking through. return "PASS" if isinstance(actual, bool) and actual == expect else "FAIL" if field_type.startswith("int"): # Reject non‑numbers (e.g. a missing field rendered as a string) and # booleans (a subclass of int that must not satisfy a numeric rule). if isinstance(actual, bool) or not isinstance(actual, (int, float)): return "FAIL" if field_type == "int_max": return "PASS" if actual <= expect else "FAIL" return "PASS" if actual >= expect else "FAIL" return "FAIL" def load_json(path: Path) -> dict[str, Any]: """Read the JSON file generated by the Bash script.""" resolved = path.resolve() if not resolved.is_file(): sys.exit(f"Could not read JSON file {path}: not a regular file") try: with resolved.open("r", encoding="utf-8") as fh: return json.load(fh) except Exception as exc: sys.exit(f"Could not read JSON file {path}: {exc}") def write_csv( out_path: Path, metadata: dict[str, Any], rows: list[list[Any]], ) -> None: """Write the CSV report, including a metadata header block.""" with out_path.open("w", newline="", encoding="utf-8") as csvfile: writer = csv.writer(csvfile) # ---- metadata block (prefixed with #) ---- for key, val in metadata.items(): writer.writerow([f"# {key}: {val}"]) writer.writerow([]) # blank line # ---- column header ---- writer.writerow(["Rule#", "Policy‑Item", "Expected", "Actual", "Result"]) # ---- data rows ---- for row in rows: writer.writerow(row) # ---------------------------------------------------------------------- # Main workflow # ---------------------------------------------------------------------- def main() -> None: if len(sys.argv) != 2: sys.exit("Usage: python3 evaluate_policy.py ") json_path = Path(sys.argv[1]) data = load_json(json_path) # Extract the two top‑level sections we expect metadata = data.get("metadata", {}) policy = data.get("PasswordPolicy", {}) # -------------------------------------------------------------- # 1. Prompt the auditor for expectations # -------------------------------------------------------------- expectations: dict[str, Any | None] = {} print("\n=== Expected / Minimum Values (press for N/A) ===\n") for _, key, friendly, typ in POLICY_FIELDS: expectations[key] = prompt_expected(typ, friendly) # -------------------------------------------------------------- # 2. Build the CSV rows (including PASS/FAIL) # -------------------------------------------------------------- csv_rows: list[list[Any]] = [] for rule_no, key, friendly, typ in POLICY_FIELDS: expected = expectations[key] actual = policy.get(key, "(missing)") result = evaluate(expected, actual, typ) # Normalise booleans for nicer CSV output actual_str = str(actual).lower() if isinstance(actual, bool) else str(actual) expected_str = "" if expected is None else str(expected).lower() csv_rows.append([rule_no, friendly, expected_str, actual_str, result]) # -------------------------------------------------------------- # 3. Write the CSV file (timestamped) # -------------------------------------------------------------- timestamp = utc_now().strftime("%Y%m%dT%H%M%SZ") out_csv = Path(f"policy_audit_{timestamp}.csv") write_csv(out_csv, metadata, csv_rows) print(f"\nAudit CSV written to: {out_csv}\n") # Simple on‑screen summary print("Summary:") for row in csv_rows: print(f" {row[0]:2}. {row[1]:35} → {row[4]}") print("\n--- End of report ---\n") if __name__ == "__main__": main()