diff --git a/local-app/python-tools/cross-organization/check_config.py b/local-app/python-tools/cross-organization/check_config.py index bdeca6a4..ac973e70 100644 --- a/local-app/python-tools/cross-organization/check_config.py +++ b/local-app/python-tools/cross-organization/check_config.py @@ -1,66 +1,51 @@ -import boto3 -from botocore.exceptions import ClientError - def account_task(account_session, account_id, account_name, region): """ - Task: Audits AWS Config across ALL enabled regions in the account. - Captures: Status, Retention, S3 Bucket, SNS Topic, and Delivery Frequency. + Task: Deep Audit of AWS Config across all regions. + Returns: Data structured by region for JSON/CSV processing. """ - res = {"alias": "None", "status": "PENDING", "details": ""} + results = {"alias": "None", "status": "SUCCESS", "data": {}} try: - # 1. Get IAM Alias iam = account_session.client('iam') aliases = iam.list_account_aliases().get('AccountAliases', []) - res["alias"] = aliases[0] if aliases else "None" + results["alias"] = aliases[0] if aliases else "None" - # 2. Determine all active regions for this account - # We use EC2 to describe regions as it's the most reliable way to find 'opt-in' status ec2 = account_session.client('ec2', region_name=region) regions = [r['RegionName'] for r in ec2.describe_regions()['Regions']] - report_lines = [] - global_recorder_count = 0 - for reg in regions: - reg_status = "OFF" config = account_session.client('config', region_name=reg) - # Get Recorder Status + # Fetch Config state recorders = config.describe_configuration_recorders().get('ConfigurationRecorders', []) delivery = config.describe_delivery_channels().get('DeliveryChannels', []) retention = config.describe_retention_configurations().get('RetentionConfigurations', []) - # Default values - s3 = "N/A" - sns = "N/A" - freq = "N/A" - ret_days = "Default(7yrs)" - + # Store regional metrics + reg_data = { + "recorder_enabled": "False", + "global_resources": "False", + "s3_bucket": "N/A", + "sns_topic": "N/A", + "delivery_freq": "N/A", + "retention_days": "2555 (7yr)" + } + if recorders: - reg_status = "ON" - if recorders[0].get('recordingGroup', {}).get('includeGlobalResourceTypes'): - global_recorder_count += 1 - reg_status = "ON+GLOBAL" + reg_data["recorder_enabled"] = "True" + reg_data["global_resources"] = str(recorders[0].get('recordingGroup', {}).get('includeGlobalResourceTypes', False)) if delivery: - s3 = delivery[0].get('s3BucketName', 'N/A') - sns = delivery[0].get('snsTopicARN', 'N/A').split(':')[-1] # Shorten ARN - freq = delivery[0].get('configSnapshotDeliveryProperties', {}).get('deliveryFrequency', '24h') + reg_data["s3_bucket"] = delivery[0].get('s3BucketName', 'N/A') + reg_data["sns_topic"] = delivery[0].get('snsTopicARN', 'N/A').split(':')[-1] + reg_data["delivery_freq"] = delivery[0].get('configSnapshotDeliveryProperties', {}).get('deliveryFrequency', '24h') if retention: - ret_days = f"{retention[0].get('RetentionPeriodInDays')} days" + reg_data["retention_days"] = str(retention[0].get('RetentionPeriodInDays')) - report_lines.append(f"[{reg}: {reg_status} | S3:{s3} | Ret:{ret_days} | Freq:{freq}]") - - # Summarize for the main table - res["status"] = f"OK({len(regions)}reg)" if global_recorder_count == 1 else f"WARN({global_recorder_count} Globals)" - res["details"] = " | ".join(report_lines) - - except ClientError as e: - res["status"] = f"FAIL({e.response['Error']['Code']})" + results["data"][reg] = reg_data + except Exception as e: - res["status"] = "ERROR" + results["status"] = f"ERROR: {str(e)}" - return res - + return results diff --git a/local-app/python-tools/cross-organization/org_runner.py b/local-app/python-tools/cross-organization/org_runner.py index 3ab0df4c..10802871 100755 --- a/local-app/python-tools/cross-organization/org_runner.py +++ b/local-app/python-tools/cross-organization/org_runner.py @@ -4,168 +4,102 @@ import argparse import os import sys -import logging -import time +import json import csv +import time import importlib from datetime import datetime from pathlib import Path -from botocore.exceptions import ClientError, ProfileNotFound # --- VERSIONING --- -__version__ = "1.2.2" +__version__ = "1.3.0" class OrgTaskRunner: def __init__(self, args): self.args = args self.output_log = [] - self.csv_data = [] # Stores structured data for CSV export + self.full_results = [] # Internal list of dicts for JSON/CSV self.hierarchy_cache = {} - self.ou_summary = {} self.start_time = 0 - self.partition = "aws" - self.session = None self.org_id = "unknown" def log_print(self, message=""): print(message) self.output_log.append(message) - def get_full_path(self, org_client, entity_id): - if entity_id in self.hierarchy_cache: - return self.hierarchy_cache[entity_id] - if entity_id.startswith('r-'): - self.hierarchy_cache[entity_id] = (None, entity_id) - return None, entity_id + def run(self): + self.start_time = time.perf_counter() + session = boto3.Session(profile_name=self.args.profile, region_name=self.args.region) + sts = session.client('sts') + org = session.client('organizations') + try: - parents = org_client.list_parents(ChildId=entity_id)['Parents'] - p_id = parents[0]['Id'] if parents else None - ou_desc = org_client.describe_organizational_unit(OrganizationalUnitId=entity_id) - ou_name = ou_desc['OrganizationalUnit']['Name'] - p_path, _ = self.get_full_path(org_client, p_id) if p_id else (None, None) - full_path = f"{p_path}:{ou_name}" if p_path else ou_name - self.hierarchy_cache[entity_id] = (full_path, entity_id) - return full_path, entity_id - except: - return f"Error({entity_id})", entity_id - - def load_tasks(self): + self.org_id = org.describe_organization()['Organization']['Id'] + except: pass + + # Load dynamic tasks tasks = [] if self.args.enable_checks: sys.path.append(os.getcwd()) - for module_name in self.args.enable_checks: - try: - m_name = module_name.replace('.py', '') - module = importlib.import_module(m_name) - if hasattr(module, 'account_task'): - tasks.append((m_name, getattr(module, 'account_task'))) - except ImportError as e: - self.log_print(f"Error importing {module_name}: {e}") - return tasks - - def run(self): - self.start_time = time.perf_counter() - if self.args.debug: - boto3.set_stream_logger(name='botocore', level=logging.DEBUG) - - try: - self.session = boto3.Session(profile_name=self.args.profile, region_name=self.args.region) - sts = self.session.client('sts') - org = self.session.client('organizations') - caller = sts.get_caller_identity() - self.partition = caller['Arn'].split(':')[1] + for m in self.args.enable_checks: + module = importlib.import_module(m.replace('.py', '')) + tasks.append(getattr(module, 'account_task')) + + all_accounts = [] + paginator = org.get_paginator('list_accounts') + for page in paginator.paginate(): + all_accounts.extend([acc for acc in page['Accounts'] if acc['Status'] == 'ACTIVE']) + + all_accounts.sort(key=lambda x: x['Name' if self.args.sort == 'name' else 'Id'].lower()) + + self.log_print(f"--- Starting Org Run (v{__version__}) | Accounts: {len(all_accounts)} ---") + + for i, acc in enumerate(all_accounts, 1): + acc_id, acc_name = acc['Id'], acc['Name'] + role_arn = f"arn:aws:iam::{acc_id}:role/{self.args.role_name}" try: - self.org_id = org.describe_organization()['Organization']['Id'] - except: pass + assumed = sts.assume_role(RoleArn=role_arn, RoleSessionName="OrgRunnerTask") + m_sess = boto3.Session( + aws_access_key_id=assumed['Credentials']['AccessKeyId'], + aws_secret_access_key=assumed['Credentials']['SecretAccessKey'], + aws_session_token=assumed['Credentials']['SessionToken'], + region_name=self.args.region + ) + + account_data = {"account_id": acc_id, "account_name": acc_name, "checks": {}} + + for t_func in tasks: + # Capture raw data from task + res = t_func(m_sess, acc_id, acc_name, self.args.region) + account_data["alias"] = res.get("alias") + account_data["checks"] = res.get("data", {}) # Expecting a dict of regions - tasks = self.load_tasks() - - # Filename Logic + self.full_results.append(account_data) + self.log_print(f"[{i}/{len(all_accounts)}] Processed {acc_name}") + + except Exception as e: + self.log_print(f"[{i}/{len(all_accounts)}] FAILED {acc_name}: {e}") + + # EXPORTS + if self.args.output: ds = datetime.now().strftime("%Y-%m-%dT%H%M%S") - base_name = self.args.output if self.args.output and self.args.output != 'DEFAULT' else f"results_{self.org_id}" - txt_file = f"{base_name}.{ds}.txt" - csv_file = f"{base_name}.{ds}.csv" - - self.log_print("-" * 100) - self.log_print(f"AWS ORG TASK RUNNER - v{__version__} | Partition: {self.partition}") - self.log_print(f"Source Identity: {caller['Arn']}") - self.log_print("-" * 100) - - all_accounts = [] - paginator = org.get_paginator('list_accounts') - for page in paginator.paginate(): - all_accounts.extend([acc for acc in page['Accounts'] if acc['Status'] == 'ACTIVE']) + base = self.args.output if self.args.output != 'DEFAULT' else f"audit_{self.org_id}" - all_accounts.sort(key=lambda x: x['Name' if self.args.sort == 'name' else 'Id'].lower()) + # 1. Save JSON + with open(f"{base}.{ds}.json", 'w') as f: + json.dump(self.full_results, f, indent=2) - # Header for Terminal (Truncated for readability) - self.log_print(f"{'#':<4} | {'Account Name':<25} | {'Account ID':<15} | {'Status':<12} | {'OU Path'}") - self.log_print("-" * 120) - - for i, acc in enumerate(all_accounts, 1): - acc_id, acc_name = acc['Id'], acc['Name'] - role_arn = f"arn:{self.partition}:iam::{acc_id}:role/{self.args.role_name}" - - parents = org.list_parents(ChildId=acc_id).get('Parents', []) - raw_path, ou_id = self.get_full_path(org, parents[0]['Id']) if parents else ("Orphaned", "N/A") - ou_path = raw_path if raw_path else "Root" - - sum_key = (ou_path, ou_id) - if sum_key not in self.ou_summary: self.ou_summary[sum_key] = {"success": 0, "fail": 0} - - alias, status_text, details = "N/A", "PENDING", "" - try: - assumed = sts.assume_role(RoleArn=role_arn, RoleSessionName="OrgRunnerTask") - m_sess = boto3.Session( - aws_access_key_id=assumed['Credentials']['AccessKeyId'], - aws_secret_access_key=assumed['Credentials']['SecretAccessKey'], - aws_session_token=assumed['Credentials']['SessionToken'], - region_name=self.session.region_name - ) - - for t_name, t_func in tasks: - res = t_func(m_sess, acc_id, acc_name, self.session.region_name) - status_text = res.get('status', 'SUCCESS') - alias = res.get('alias', 'N/A') - details = res.get('details', '') - - self.ou_summary[sum_key]["success"] += 1 - except Exception as e: - status_text = "ASSUME_FAIL" - details = str(e) - self.ou_summary[sum_key]["fail"] += 1 - - # Log to terminal (keeping it clean) - self.log_print(f"{i:<4} | {acc_name[:25]:<25} | {acc_id:<15} | {status_text:<12} | {ou_path}") - - # Capture full data for CSV (untruncated) - self.csv_data.append({ - "Item": i, "AccountName": acc_name, "AccountID": acc_id, - "Alias": alias, "Status": status_text, "OU_Path": ou_path, "OU_ID": ou_id, - "Details": details - }) - - # End Summary - self.log_print("\n" + "=" * 80) - self.log_print(f"SCAN COMPLETE | Time: {int(time.perf_counter() - self.start_time)}s") - self.log_print("=" * 80) - - if self.args.output: - # Save TXT (Log) - with open(txt_file, 'w') as f: - f.write("\n".join(self.output_log)) - - # Save CSV (Data) - with open(csv_file, 'w', newline='') as f: - writer = csv.DictWriter(f, fieldnames=self.csv_data[0].keys()) - writer.writeheader() - writer.writerows(self.csv_data) - - print(f"Reports saved:\n - {txt_file}\n - {csv_file}") + # 2. Save CSV (Long Format) + with open(f"{base}.{ds}.csv", 'w', newline='') as f: + writer = csv.writer(f) + writer.writerow(["account_id", "account_alias", "region", "field_name", "field_value"]) + for acc in self.full_results: + for reg, fields in acc["checks"].items(): + for key, val in fields.items(): + writer.writerow([acc["account_id"], acc.get("alias"), reg, key, val]) - except Exception as e: - print(f"CRITICAL ERROR: {e}") + self.log_print(f"\nExports complete: {base}.{ds}.json and .csv") if __name__ == "__main__": p = argparse.ArgumentParser() @@ -175,6 +109,4 @@ def run(self): p.add_argument("--sort", choices=['id', 'name'], default='name') p.add_argument("--output", nargs='?', const='DEFAULT') p.add_argument("--enable-checks", nargs='+') - p.add_argument("--debug", action="store_true") OrgTaskRunner(p.parse_args()).run() -