diff --git a/local-app/python-tools/test-cross-organization/check_config.py b/local-app/python-tools/test-cross-organization/check_config.py index fd035509..bdeca6a4 100644 --- a/local-app/python-tools/test-cross-organization/check_config.py +++ b/local-app/python-tools/test-cross-organization/check_config.py @@ -1,20 +1,66 @@ +import boto3 +from botocore.exceptions import ClientError + def account_task(account_session, account_id, account_name, region): """ - Checks for AWS Config Global Resource Recording. + Task: Audits AWS Config across ALL enabled regions in the account. + Captures: Status, Retention, S3 Bucket, SNS Topic, and Delivery Frequency. """ - res = {"alias": "None", "status": "SUCCESS"} + res = {"alias": "None", "status": "PENDING", "details": ""} + 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" - config = account_session.client('config', region_name=region) - recs = config.describe_configuration_recorders().get('ConfigurationRecorders', []) + # 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']] - if any(r.get('recordingGroup', {}).get('includeGlobalResourceTypes') for r in recs): - res["status"] = "GLOBAL_ON" - else: - res["status"] = "GLOBAL_OFF" + report_lines = [] + global_recorder_count = 0 + + for reg in regions: + reg_status = "OFF" + config = account_session.client('config', region_name=reg) + + # Get Recorder Status + 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)" + + if recorders: + reg_status = "ON" + if recorders[0].get('recordingGroup', {}).get('includeGlobalResourceTypes'): + global_recorder_count += 1 + reg_status = "ON+GLOBAL" + + 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') + + if retention: + ret_days = f"{retention[0].get('RetentionPeriodInDays')} days" + + 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']})" except Exception as e: res["status"] = "ERROR" + return res + diff --git a/local-app/python-tools/test-cross-organization/org_runner.py b/local-app/python-tools/test-cross-organization/org_runner.py index d05f389f..3ab0df4c 100755 --- a/local-app/python-tools/test-cross-organization/org_runner.py +++ b/local-app/python-tools/test-cross-organization/org_runner.py @@ -6,18 +6,20 @@ import sys import logging import time +import csv import importlib from datetime import datetime from pathlib import Path from botocore.exceptions import ClientError, ProfileNotFound # --- VERSIONING --- -__version__ = "1.2.0" +__version__ = "1.2.2" class OrgTaskRunner: def __init__(self, args): self.args = args self.output_log = [] + self.csv_data = [] # Stores structured data for CSV export self.hierarchy_cache = {} self.ou_summary = {} self.start_time = 0 @@ -26,12 +28,10 @@ def __init__(self, args): self.org_id = "unknown" def log_print(self, message=""): - """Prints to console and buffers for file output.""" print(message) self.output_log.append(message) def get_full_path(self, org_client, entity_id): - """Resolves hierarchy path and caches results. Strips 'Root'.""" if entity_id in self.hierarchy_cache: return self.hierarchy_cache[entity_id] if entity_id.startswith('r-'): @@ -50,16 +50,15 @@ def get_full_path(self, org_client, entity_id): return f"Error({entity_id})", entity_id def load_tasks(self): - """Dynamically imports account_task from provided module names.""" tasks = [] if self.args.enable_checks: + sys.path.append(os.getcwd()) for module_name in self.args.enable_checks: try: - module = importlib.import_module(module_name) + m_name = module_name.replace('.py', '') + module = importlib.import_module(m_name) if hasattr(module, 'account_task'): - tasks.append((module_name, getattr(module, 'account_task'))) - else: - self.log_print(f"Warning: {module_name} missing 'account_task' function.") + tasks.append((m_name, getattr(module, 'account_task'))) except ImportError as e: self.log_print(f"Error importing {module_name}: {e}") return tasks @@ -73,7 +72,6 @@ def run(self): 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] @@ -84,22 +82,14 @@ def run(self): tasks = self.load_tasks() # Filename Logic - final_filename = None - if self.args.output: - ds = datetime.now().strftime("%Y-%m-%dT%H%M%S") - if self.args.output == 'DEFAULT': - final_filename = f"results_{self.org_id}.{ds}.txt" - else: - p = Path(self.args.output) - final_filename = f"{p.stem}_{ds}{p.suffix}" + 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 - Version {__version__}") - self.log_print("-" * 100) - self.log_print(f" Profile: {self.args.profile or 'Default'}") - self.log_print(f" Identity: {caller['Arn']}") - self.log_print(f" Checks: {[t[0] for t in tasks] if tasks else 'Connectivity Only'}") - if final_filename: self.log_print(f" Output File: {final_filename}") + 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 = [] @@ -107,11 +97,11 @@ def run(self): for page in paginator.paginate(): all_accounts.extend([acc for acc in page['Accounts'] if acc['Status'] == 'ACTIVE']) - sort_key = 'Name' if self.args.sort == 'name' else 'Id' - all_accounts.sort(key=lambda x: x[sort_key].lower()) + all_accounts.sort(key=lambda x: x['Name' if self.args.sort == 'name' else 'Id'].lower()) - self.log_print(f"{'#':<4} | {'Account Name':<25} | {'Alias':<40} | {'Account ID':<15} | {'Status':<12} | {'Full OU Path'}") - self.log_print("-" * 180) + # 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'] @@ -119,12 +109,12 @@ def run(self): 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 "N/A (Root)" + 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 = "None", "PENDING" + alias, status_text, details = "N/A", "PENDING", "" try: assumed = sts.assume_role(RoleArn=role_arn, RoleSessionName="OrgRunnerTask") m_sess = boto3.Session( @@ -134,40 +124,45 @@ def run(self): region_name=self.session.region_name ) - # Default connectivity check + dynamic tasks - if not tasks: - aliases = m_sess.client('iam').list_account_aliases().get('AccountAliases', []) - alias = aliases[0] if aliases else "None" - status_text = "SUCCESS" - else: - 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') + 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 ClientError as e: - status_text = f"FAIL({e.response['Error']['Code']})" + except Exception as e: + status_text = "ASSUME_FAIL" + details = str(e) self.ou_summary[sum_key]["fail"] += 1 - self.log_print(f"{i:<4} | {acc_name[:25]:<25} | {alias[:40]:<40} | {acc_id:<15} | {status_text:<12} | {ou_path}") + # 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) - # Summary Table - end_time = time.perf_counter() - el = divmod(int(end_time - self.start_time), 60) - - self.log_print("\n" + "=" * 85) - self.log_print(f"{'OU PATH SUMMARY':<45} | {'OU ID':<18} | {'SUCC':<6} | {'FAIL':<6}") - self.log_print("=" * 85) - for (path, o_id), counts in sorted(self.ou_summary.items()): - self.log_print(f"{path[:45]:<45} | {o_id:<18} | {counts['success']:<6} | {counts['fail']:<6}") - self.log_print("=" * 85) - self.log_print(f"Time: {el[0]}m {el[1]}s | Accounts: {len(all_accounts)}") - self.log_print("=" * 85) - - if final_filename: - with open(final_filename, 'w') as f: + 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}") except Exception as e: print(f"CRITICAL ERROR: {e}") @@ -182,3 +177,4 @@ def run(self): p.add_argument("--enable-checks", nargs='+') p.add_argument("--debug", action="store_true") OrgTaskRunner(p.parse_args()).run() +