diff --git a/local-app/python-tools/cross-organization/assess_check_ecr.py b/local-app/python-tools/cross-organization/assess_check_ecr.py index 09ad867f..d0877a4c 100755 --- a/local-app/python-tools/cross-organization/assess_check_ecr.py +++ b/local-app/python-tools/cross-organization/assess_check_ecr.py @@ -1,33 +1,66 @@ #!/usr/bin/env python import json, argparse, sys, os, glob -from datetime import datetime +from datetime import datetime, timezone +from collections import Counter # --- VERSIONING --- -__version__ = "1.0.0" +__version__ = "1.1.0" def find_latest_file(pattern): files = glob.glob(pattern) return max(files, key=os.path.getctime) if files else None +def get_days_ago(iso_str): + """Calculates days between now and an ISO date string.""" + if not iso_str or iso_str == "N/A": + return None + try: + dt = datetime.fromisoformat(iso_str) + # Handle timezone awareness + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + now = datetime.now(timezone.utc) + return (now - dt).days + except: + return None + +def bucket_age(days, counters): + """Increments the appropriate age bucket counter.""" + if days is None: return + if days >= 365: counters['365+'] += 1 + elif days >= 180: counters['180-364'] += 1 + elif days >= 90: counters['90-179'] += 1 + elif days >= 60: counters['60-89'] += 1 + elif days >= 30: counters['30-59'] += 1 + else: counters['<30'] += 1 + def main(): - parser = argparse.ArgumentParser(description="AWS ECR Audit Assessor") + parser = argparse.ArgumentParser(description="AWS ECR Audit Assessor - Data Aging Edition") parser.add_argument("--input", help="JSON audit file") args = parser.parse_args() input_file = args.input or find_latest_file("audit_results.check_ecr.*.json") - if not input_file: print("Error: No file found."); sys.exit(1) + if not input_file: + print("Error: No file found."); sys.exit(1) - with open(input_file, 'r') as f: data = json.load(f) + with open(input_file, 'r') as f: + data = json.load(f) report_width = 220 print("-" * report_width) - print(f"ECR REPOSITORY ASSESSMENT | Total Accounts: {len(data)}") + print(f"ECR COMPREHENSIVE ASSESSMENT | Total Accounts: {len(data)} | Input: {os.path.basename(input_file)}") print("-" * report_width) - print(f"{'Idx':<5} | {'Account ID':<15} | {'Region':<12} | {'Repo Name':<40} | {'Mutability':<12} | {'Lifecycle':<10} | {'Img Count'}") + print(f"{'Idx':<5} | {'Account ID':<15} | {'Region':<12} | {'Repo Name':<45} | {'Mutability':<12} | {'Lifecycle':<10} | {'Img Count'}") print("-" * report_width) - stats = {"total_repos": 0, "total_images": 0, "no_lifecycle": 0} + # Summary Statistics + stats = { + "repos": 0, "images": 0, "no_lifecycle": 0, + "mutable": 0, "immutable": 0, + "push_ages": Counter(), "pull_ages": Counter(), + "total_push_days": 0, "push_day_count": 0 + } for idx, account in enumerate(data, 1): checks = account.get("data", {}) @@ -35,20 +68,54 @@ def main(): if key == "account_summary" or ":" not in key: continue region = key.split(":")[0] - stats["total_repos"] += 1 - img_count = len(val.get("images", [])) - stats["total_images"] += img_count + stats["repos"] += 1 + + # Mutability Tracking + mut = val.get("mutability", "MUTABLE") + if mut == "IMMUTABLE": stats["immutable"] += 1 + else: stats["mutable"] += 1 - lifecycle_status = "YES" if val.get("has_lifecycle") == "True" else "NO" - if lifecycle_status == "NO": stats["no_lifecycle"] += 1 + # Lifecycle Tracking + has_lc = val.get("has_lifecycle") == "True" + if not has_lc: stats["no_lifecycle"] += 1 - print(f"{idx:<5} | {account['account_id']:<15} | {region:<12} | {val['repo_name']:<40} | {val['mutability']:<12} | {lifecycle_status:<10} | {img_count}") + # Image Age Tracking + images = val.get("images", []) + stats["images"] += len(images) + + for img in images: + # Push Age + p_days = get_days_ago(img.get("pushed_at")) + if p_days is not None: + bucket_age(p_days, stats["push_ages"]) + stats["total_push_days"] += p_days + stats["push_day_count"] += 1 + + # Pull Age + l_days = get_days_ago(img.get("last_pulled_at")) + bucket_age(l_days, stats["pull_ages"]) + + print(f"{idx:<5} | {account['account_id']:<15} | {region:<12} | {val['repo_name']:<45} | {mut:<12} | {'YES' if has_lc else 'NO':<10} | {len(images)}") + # Footer Logic + avg_push_age = stats["total_push_days"] / stats["push_day_count"] if stats["push_day_count"] > 0 else 0 + print("-" * report_width) print(f"ORGANIZATION ECR FOOTPRINT SUMMARY") - print(f" Total Repositories: {stats['total_repos']}") - print(f" Total Images: {stats['total_images']}") - print(f" Repos w/o Lifecycle Policies: {stats['no_lifecycle']} (RISK)") + print(f" --- Repository Config ---") + print(f" Total Repositories: {stats['repos']} | Immutable: {stats['immutable']} | Mutable: {stats['mutable']}") + print(f" Repos Missing Lifecycle Policies: {stats['no_lifecycle']} (Action Required)") + + print(f"\n --- Image Aging & Usage ---") + print(f" Total Images: {stats['images']} | Average Age (Since Push): {avg_push_age:.1f} days") + print(f" Images Older than 1 Year: {stats['push_ages']['365+']}") + + print(f"\n Age Distribution (Days Since Action):") + print(f" Bucket | Pushed Count | Last Pull Count") + print(f" ----------- | ------------ | ---------------") + for b in ['<30', '30-59', '60-89', '90-179', '180-364', '365+']: + print(f" {b:<11} | {stats['push_ages'][b]:<12} | {stats['pull_ages'][b]}") print("-" * report_width) -if __name__ == "__main__": main() +if __name__ == "__main__": + main()