diff --git a/local-app/python-tools/cross-organization/assess_check_config.py b/local-app/python-tools/cross-organization/assess_check_config.py old mode 100644 new mode 100755 diff --git a/local-app/python-tools/cross-organization/check_config.py b/local-app/python-tools/cross-organization/check_config.py index c48d3ed0..478b8c13 100644 --- a/local-app/python-tools/cross-organization/check_config.py +++ b/local-app/python-tools/cross-organization/check_config.py @@ -10,24 +10,16 @@ def get_s3_metrics(session, bucket_name, region): end = datetime.utcnow() start = end - timedelta(days=2) try: - response = cw.get_metric_statistics( - Namespace='AWS/S3', MetricName='BucketSizeBytes', - Dimensions=[{'Name': 'BucketName', 'Value': bucket_name}, {'Name': 'StorageType', 'Value': 'StandardStorage'}], - StartTime=start, EndTime=end, Period=86400, Statistics=['Average'] - ) - if response['Datapoints']: metrics["bucket_size_bytes"] = int(response['Datapoints'][-1]['Average']) - - response = cw.get_metric_statistics( - Namespace='AWS/S3', MetricName='NumberOfObjects', - Dimensions=[{'Name': 'BucketName', 'Value': bucket_name}, {'Name': 'StorageType', 'Value': 'AllStorageTypes'}], - StartTime=start, EndTime=end, Period=86400, Statistics=['Average'] - ) - if response['Datapoints']: metrics["object_count"] = int(response['Datapoints'][-1]['Average']) + r1 = cw.get_metric_statistics(Namespace='AWS/S3', MetricName='BucketSizeBytes', Dimensions=[{'Name': 'BucketName', 'Value': bucket_name}, {'Name': 'StorageType', 'Value': 'StandardStorage'}], StartTime=start, EndTime=end, Period=86400, Statistics=['Average']) + if r1['Datapoints']: metrics["bucket_size_bytes"] = int(r1['Datapoints'][-1]['Average']) + r2 = cw.get_metric_statistics(Namespace='AWS/S3', MetricName='NumberOfObjects', Dimensions=[{'Name': 'BucketName', 'Value': bucket_name}, {'Name': 'StorageType', 'Value': 'AllStorageTypes'}], StartTime=start, EndTime=end, Period=86400, Statistics=['Average']) + if r2['Datapoints']: metrics["object_count"] = int(r2['Datapoints'][-1]['Average']) except: pass return metrics def account_task(account_session, account_id, account_name, region): results = {"alias": "N/A", "data": {}} + global_count = 0 try: results["alias"] = account_session.client('iam').list_account_aliases().get('AccountAliases', ["N/A"])[0] regions = [r['RegionName'] for r in account_session.client('ec2', region_name=region).describe_regions()['Regions']] @@ -35,19 +27,24 @@ def account_task(account_session, account_id, account_name, region): for reg in regions: reg_start = time.perf_counter() config = account_session.client('config', region_name=reg) - channels = config.describe_delivery_channels().get('DeliveryChannels', []) recorders = config.describe_configuration_recorders().get('ConfigurationRecorders', []) + channels = config.describe_delivery_channels().get('DeliveryChannels', []) - reg_data = {"recorder_status": "ON" if recorders else "OFF", "s3_bucket": "N/A"} + is_global = any(r.get('recordingGroup', {}).get('includeGlobalResourceTypes') for r in recorders) + if is_global: global_count += 1 + + reg_data = {"recorder_status": "ON" if recorders else "OFF", "global_recording": str(is_global)} if channels: bucket = channels[0].get('s3BucketName', 'N/A') - reg_data.update({ - "s3_bucket": bucket, - "delivery_freq": channels[0].get('configSnapshotDeliveryProperties', {}).get('deliveryFrequency', 'N/A') - }) + reg_data.update({"s3_bucket": bucket, "delivery_freq": channels[0].get('configSnapshotDeliveryProperties', {}).get('deliveryFrequency', 'N/A')}) if bucket != "N/A": reg_data.update(get_s3_metrics(account_session, bucket, reg)) reg_data["check_elapsed_sec"] = round(time.perf_counter() - reg_start, 3) results["data"][reg] = reg_data + + # Add Per-Account Summary record (using 'global' as the region key for the CSV row) + summary_val = f"OK/1" if global_count == 1 else f"MULTIPLE/{global_count}" if global_count > 1 else "NONE/0" + results["data"]["account_summary"] = {"_summary": summary_val} + except Exception as e: results["error"] = str(e) 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 7974193c..2bf2ef59 100755 --- a/local-app/python-tools/cross-organization/org_runner.py +++ b/local-app/python-tools/cross-organization/org_runner.py @@ -12,30 +12,23 @@ from pathlib import Path from concurrent.futures import ThreadPoolExecutor, as_completed -# tqdm integration try: from tqdm import tqdm except ImportError: - print("Warning: 'tqdm' not found. Install with 'pip install tqdm'.") def tqdm(iterable, **kwargs): return iterable # --- VERSIONING --- -__version__ = "1.5.4" +__version__ = "1.6.0" class OrgTaskRunner: def __init__(self, args): self.args = args - self.output_log = [] self.full_results = [] self.hierarchy_cache = {} self.created_files = [] self.start_time = 0 - def log_print(self, message=""): - self.output_log.append(message) - def get_ou_path(self, org_client, entity_id): - """Recursive OU path resolver with caching.""" 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) @@ -51,24 +44,18 @@ def get_ou_path(self, org_client, entity_id): except: return "Unknown", entity_id def process_account(self, acc, partition, tasks): - """Worker thread for each account.""" thread_session = boto3.Session(profile_name=self.args.profile, region_name=self.args.region) sts, org = thread_session.client('sts'), thread_session.client('organizations') - acc_id, acc_name = acc['Id'], acc['Name'] role_arn = f"arn:{partition}:iam::{acc_id}:role/{self.args.role_name}" parents = org.list_parents(ChildId=acc_id).get('Parents', []) - raw_path, ou_id = self.get_ou_path(org, parents[0]['Id']) if parents else ("Orphaned", "N/A") - ou_path = raw_path if raw_path else "Root" + ou_path, ou_id = self.get_ou_path(org, parents[0]['Id']) if parents else ("Orphaned", "N/A") + ou_path = ou_path if ou_path else "Root" account_data = { - "account_id": acc_id, - "account_name": acc_name, - "alias": "N/A", - "ou_path": ou_path, - "ou_id": ou_id, - "checks": {} + "account_id": acc_id, "account_name": acc_name, "alias": "N/A", + "ou_path": ou_path, "ou_id": ou_id, "checks": {} } try: @@ -79,91 +66,73 @@ def process_account(self, acc, partition, tasks): aws_session_token=assumed['Credentials']['SessionToken'], region_name=self.args.region ) - for t_func in tasks: res = t_func(m_sess, acc_id, acc_name, self.args.region) account_data["alias"] = res.get("alias", "N/A") account_data["checks"].update(res.get("data", {})) - return account_data, None except Exception as e: - return None, f"FAILED {acc_name} ({acc_id}): {str(e)}" + return None, f"FAILED {acc_name}: {str(e)}" 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') - partition = sts.get_caller_identity()['Arn'].split(':')[1] + partition = session.client('sts').get_caller_identity()['Arn'].split(':')[1] - # Load tasks & build dynamic filename suffix tasks, check_names = [], [] if self.args.enable_checks: sys.path.append(os.getcwd()) for m in self.args.enable_checks: - module_name = m.replace('.py', '') - module = importlib.import_module(module_name) + module = importlib.import_module(m.replace('.py', '')) tasks.append(getattr(module, 'account_task')) - check_names.append(module_name) + check_names.append(m.replace('.py', '')) - check_suffix = ".".join(check_names) if check_names else "connectivity" - all_accounts = [acc for page in session.client('organizations').get_paginator('list_accounts').paginate() 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()) - - # UPDATED HEADER - print("-" * 100) - print(f"AWS ORG TASK RUNNER - v{__version__}") - print(f"Runners (Max Workers): {self.args.max_workers}") - print(f"Active Check: {check_suffix}") - print("-" * 100) + + print("-" * 60) + print(f"AWS ORG TASK RUNNER - v{__version__} | Runners: {self.args.max_workers}") + print("-" * 60) with ThreadPoolExecutor(max_workers=self.args.max_workers) as executor: futures = {executor.submit(self.process_account, acc, partition, tasks): acc for acc in all_accounts} with tqdm(total=len(all_accounts), desc="Processing", unit="acc", colour="green") as pbar: - for future in as_completed(futures): - data, err = future.result() + for f in as_completed(futures): + data, _ = f.result() if data: self.full_results.append(data) pbar.update(1) - # FINAL EXPORTS if self.args.output: - ds = datetime.now().strftime("%Y-%m-%dT%H%M%S") - base = self.args.output if self.args.output != 'DEFAULT' else f"audit_results.{check_suffix}" - - # Save CSV - csv_file = f"{base}.{ds}.csv" - with open(csv_file, 'w', newline='') as f: - writer = csv.writer(f) - writer.writerow(["account_id", "account_name", "account_alias", "ou_path", "ou_id", "region", "field_name", "field_value"]) - for acc in self.full_results: - for reg, fields in acc["checks"].items(): - for k, v in fields.items(): - writer.writerow([acc["account_id"], acc["account_name"], acc["alias"], acc["ou_path"], acc["ou_id"], reg, k, v]) - self.created_files.append(csv_file) - - # Save JSON - json_file = f"{base}.{ds}.json" - with open(json_file, 'w') as f: - json.dump(self.full_results, f, indent=2) - self.created_files.append(json_file) - - # UPDATED TRAILER - print("\n" + "=" * 100) - print(f"Run Complete | Time: {round(time.perf_counter() - self.start_time, 2)}s") - if self.created_files: - print("Files Created:") - for f in self.created_files: - print(f" - {f}") - print("=" * 100) + ds = datetime.now().strftime("%Y%m%dT%H%M%S") + # 1. ACCOUNT BASELINE FILES + acc_base = f"audit_results.account.{ds}" + with open(f"{acc_base}.json", 'w') as f: json.dump(self.full_results, f, indent=2) + with open(f"{acc_base}.csv", 'w', newline='') as f: + w = csv.DictWriter(f, fieldnames=["account_id", "account_name", "alias", "ou_path", "ou_id"]) + w.writeheader() + w.writerows([{k: v for k, v in acc.items() if k != 'checks'} for acc in self.full_results]) + self.created_files.extend([f"{acc_base}.json", f"{acc_base}.csv"]) + + # 2. CHECK SPECIFIC FILES + for cn in check_names: + chk_base = f"audit_results.{cn}.{ds}" + with open(f"{chk_base}.csv", 'w', newline='') as f: + w = csv.writer(f) + w.writerow(["account_id", "region", "field_name", "field_value"]) + for acc in self.full_results: + for reg, fields in acc["checks"].items(): + for k, v in fields.items(): + w.writerow([acc["account_id"], reg, k, v]) + self.created_files.append(f"{chk_base}.csv") + + print(f"\nTime: {round(time.perf_counter() - self.start_time, 2)}s\nFiles Created:") + for f in self.created_files: print(f" - {f}") if __name__ == "__main__": p = argparse.ArgumentParser() - p.add_argument("--profile", default=os.environ.get("AWS_PROFILE")) - p.add_argument("--region", default="us-east-1") p.add_argument("--role-name", required=True) - 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("--max-workers", type=int, default=8) + p.add_argument("--profile"); p.add_argument("--region", default="us-east-1"); p.add_argument("--sort", default="name") OrgTaskRunner(p.parse_args()).run()