Security · python · 16 copies
Cloud Security Posture Auditor
Multi-cloud security posture assessment for AWS, Azure, and GCP. Checks for the most critical cloud misconfigurations: public S3/Blob/GCS buckets, overpermissive IAM roles (admin everywhere), MFA not enforced, CloudTrail/logging disabled, security groups allowing 0.0.0.0/0 on sensitive ports, unencrypted EBS/RDS volumes, publicly exposed databases, unused access keys older than 90 days, root account usage, and publicly exposed cloud functions. Maps findings to CIS Cloud Benchmark and AWS/Azure/GCP security best practices.
- Linux
- Windows
- macOS
pip install "boto3>=1.34" "azure-identity>=1.15" "azure-mgmt-security>=3.0" "azure-mgmt-storage>=21.0" "azure-mgmt-authorization>=3.0" "google-cloud-asset>=3.16" "colorama>=0.4.6"#!/usr/bin/env python3
"""
Cloud Security Posture Auditor
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/cloud-security-auditor
It covers what the script does, whether it is safe to run, the
inventory file and credentials it needs, how to read the output,
and what to do when something fails.
pip install "boto3>=1.34" "azure-identity>=1.15" "azure-mgmt-security>=3.0" "azure-mgmt-storage>=21.0" "azure-mgmt-authorization>=3.0" "google-cloud-asset>=3.16" "colorama>=0.4.6"
"""
import argparse
import json
import sys
import os
from datetime import datetime, timezone, timedelta
from typing import Any
from concurrent.futures import ThreadPoolExecutor, as_completed
# Windows consoles default to a legacy code page (cp1252), which cannot encode
# the arrows, box-drawing and check-mark characters used in the output below.
# Printing one raises UnicodeEncodeError and kills the script mid-scan, so make
# stdout UTF-8 and degrade unencodable characters instead of crashing.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass # already wrapped, or not a real stream (piped/redirected)
try:
from colorama import Fore, Style, init as colorama_init
colorama_init(autoreset=True)
except ImportError:
class Fore:
RED = GREEN = YELLOW = CYAN = WHITE = MAGENTA = ''
class Style:
RESET_ALL = BRIGHT = ''
FINDINGS = []
def finding(severity, resource, issue, detail, fix, cis_ref=''):
FINDINGS.append({'severity': severity, 'resource': resource, 'issue': issue,
'detail': detail, 'fix': fix, 'cis': cis_ref})
color = {"CRITICAL": Fore.RED, "HIGH": Fore.YELLOW, "MEDIUM": Fore.CYAN, "LOW": Fore.WHITE}.get(severity, Fore.WHITE)
mark = '\u2717' if severity in ('CRITICAL','HIGH') else '\u26a0'
print(f" {color}{mark} {severity:<10}{Style.RESET_ALL} {resource:<40} {issue}")
if detail:
print(f" {Fore.WHITE}{detail}{Style.RESET_ALL}")
if fix:
print(f" {Fore.CYAN}Fix: {fix}{Style.RESET_ALL}")
def ok(resource, note=''):
print(f" {Fore.GREEN}\u2713{Style.RESET_ALL} {resource:<40} {note}")
# ─── AWS ───────────────────────────────────────────────────────────────────────
def audit_s3_buckets(session):
print(f"\n{Fore.CYAN}[*] S3 BUCKETS{Style.RESET_ALL}")
s3 = session.client('s3')
try:
buckets = s3.list_buckets().get('Buckets', [])
except Exception as e:
print(f" ERROR listing S3 buckets: {e}"); return
print(f" {len(buckets)} buckets found")
def inspect_bucket(b):
"""
Three API calls for one bucket. Returns (name, issues, denied) and
never touches FINDINGS - the caller emits findings in bucket order so
the report does not depend on thread scheduling.
`denied` matters: an AccessDenied on the ACL call is not evidence that
the bucket is private. Reporting it as private would be a false
assurance, so it is reported as "could not inspect" instead.
"""
name = b['Name']
issues, denied = [], []
try:
acl = s3.get_bucket_acl(Bucket=name)
for grant in acl.get('Grants', []):
grantee = grant.get('Grantee', {})
if grantee.get('URI', '') in ('http://acs.amazonaws.com/groups/global/AllUsers',
'http://acs.amazonaws.com/groups/global/AuthenticatedUsers'):
perm = grant.get('Permission', '')
issues.append(f"Public ACL ({perm})")
except Exception as exc:
denied.append(f"ACL ({type(exc).__name__})")
try:
pol = s3.get_bucket_policy_status(Bucket=name)
if pol.get('PolicyStatus', {}).get('IsPublic', False):
issues.append("Public Policy")
except Exception as exc:
denied.append(f"policy status ({type(exc).__name__})")
try:
s3.get_bucket_encryption(Bucket=name)
except Exception:
# No encryption configuration is itself the finding here; boto3
# raises ServerSideEncryptionConfigurationNotFoundError for it.
issues.append("Encryption DISABLED")
return name, issues, denied
# Gather concurrently - this is three HTTPS round-trips per bucket and the
# work is entirely latency. Results are keyed by name and emitted in the
# original bucket order below.
gathered = {}
with ThreadPoolExecutor(max_workers=min(16, max(1, len(buckets)))) as pool:
futures = {pool.submit(inspect_bucket, b): b['Name'] for b in buckets}
for fut in as_completed(futures):
bname = futures[fut]
try:
n, iss, den = fut.result()
gathered[n] = (iss, den)
except Exception as exc: # one bucket must not abort the audit
gathered[bname] = ([], [f"inspection failed ({type(exc).__name__})"])
for b in buckets:
name = b['Name']
issues, denied = gathered.get(name, ([], ['not inspected']))
if denied and not issues:
finding('MEDIUM', name, 'Could not inspect: ' + ', '.join(denied),
'The audit lacked permission to read this configuration, so the '
'bucket was NOT verified as private or encrypted',
'Grant the auditing role s3:GetBucketAcl, s3:GetBucketPolicyStatus '
'and s3:GetEncryptionConfiguration', 'CIS 2.1.1')
continue
if issues:
severity = 'CRITICAL' if any('Public' in i for i in issues) else 'HIGH'
finding(severity, name, ' + '.join(issues),
'Bucket accessible from the internet without authentication' if 'Public' in ' '.join(issues) else 'Data stored without server-side encryption',
'S3 Console → Block Public Access → Enable all 4 settings' if 'Public' in ' '.join(issues) else 'aws s3api put-bucket-encryption --bucket ' + name + ' ...',
'CIS 2.1.1')
else:
ok(name, 'Private ✓ | Encrypted ✓')
def audit_iam(session):
print(f"\n{Fore.CYAN}[*] IAM AUDIT{Style.RESET_ALL}")
iam = session.client('iam')
cred_report = None
try:
iam.generate_credential_report()
import time; time.sleep(2)
report = iam.get_credential_report()
import csv, io
cred_report = list(csv.DictReader(io.StringIO(report['Content'].decode())))
except Exception:
pass
# Root MFA
try:
summary = iam.get_account_summary()['SummaryMap']
if not summary.get('AccountMFAEnabled', 0):
finding('CRITICAL', 'Root Account', 'MFA NOT ENABLED',
'Root is the most powerful account — losing it = losing everything',
'AWS Console → Security Credentials → Activate MFA for root (hardware token recommended)',
'CIS 1.5')
else:
ok('Root Account', 'MFA enabled ✓')
except Exception as e:
print(f" ERROR checking root MFA: {e}")
# Per-user checks
if cred_report:
cutoff = datetime.now(timezone.utc) - timedelta(days=90)
for row in cred_report:
user = row.get('<user>', row.get('user', 'unknown'))
if user == '<root_account>':
last_used = row.get('password_last_used', 'N/A')
if last_used and last_used != 'N/A' and last_used != 'no_information':
try:
lu_dt = datetime.fromisoformat(last_used.replace('Z', '+00:00'))
days_ago = (datetime.now(timezone.utc) - lu_dt).days
if days_ago < 30:
finding('HIGH', 'Root Account', f'Used {days_ago} days ago',
'Root should NEVER be used for day-to-day operations',
'Create IAM admin users; disable root access keys', 'CIS 1.7')
except Exception:
pass
continue
if row.get('mfa_active', 'false') == 'false':
finding('HIGH', user, 'MFA NOT ENABLED',
'Account can be compromised with just a password',
'IAM Console → Users → Security credentials → Assign MFA device', 'CIS 1.10')
for key_num in ['1', '2']:
key_active = row.get(f'access_key_{key_num}_active', 'false')
key_rotated = row.get(f'access_key_{key_num}_last_rotated', '')
if key_active == 'true' and key_rotated and key_rotated != 'N/A':
try:
rot_dt = datetime.fromisoformat(key_rotated.replace('Z', '+00:00'))
if rot_dt < cutoff:
age_days = (datetime.now(timezone.utc) - rot_dt).days
finding('HIGH', user, f'Access key {key_num}: {age_days} days old',
'Old access keys are high-risk — rotate or delete immediately',
f'aws iam rotate-access-key or delete with aws iam delete-access-key', 'CIS 1.14')
except Exception:
pass
# Overpermissive policies
try:
policies = iam.list_policies(Scope='Local')['Policies']
for pol in policies[:50]: # limit to avoid throttle
try:
ver = iam.get_policy_version(PolicyArn=pol['Arn'],
VersionId=pol['DefaultVersionId'])
doc = ver['PolicyVersion']['Document']
for stmt in doc.get('Statement', []):
if stmt.get('Effect') == 'Allow':
actions = stmt.get('Action', [])
resources = stmt.get('Resource', [])
if isinstance(actions, str): actions = [actions]
if isinstance(resources, str): resources = [resources]
if '*' in actions and '*' in resources:
finding('HIGH', pol['PolicyName'], 'Action:* Resource:* — full admin permissions',
'This policy grants unrestricted access to all AWS services',
'Apply least privilege — grant only required actions on specific resources',
'CIS 1.16')
break
except Exception:
pass
except Exception as e:
print(f" ERROR checking policies: {e}")
def audit_security_groups(session, region):
print(f"\n{Fore.CYAN}[*] SECURITY GROUPS — region: {region}{Style.RESET_ALL}")
ec2 = session.client('ec2', region_name=region)
SENSITIVE_PORTS = {22: 'SSH', 3389: 'RDP', 3306: 'MySQL', 5432: 'PostgreSQL',
27017: 'MongoDB', 6379: 'Redis', 9200: 'Elasticsearch', 8080: 'HTTP-alt'}
try:
sgs = ec2.describe_security_groups()['SecurityGroups']
except Exception as e:
print(f" ERROR: {e}"); return
print(f" {len(sgs)} security groups")
for sg in sgs:
sg_id = sg['GroupId']
sg_name = sg.get('GroupName', 'unknown')
for rule in sg.get('IpPermissions', []):
from_port = rule.get('FromPort', 0)
to_port = rule.get('ToPort', 65535)
ranges = [r['CidrIp'] for r in rule.get('IpRanges', [])]
ranges += [r['CidrIpv6'] for r in rule.get('Ipv6Ranges', [])]
open_to_all = '0.0.0.0/0' in ranges or '::/0' in ranges
if open_to_all:
for port, svc in SENSITIVE_PORTS.items():
if from_port <= port <= to_port:
finding('CRITICAL', f"{sg_id} ({sg_name})",
f"Port {port} ({svc}) open to 0.0.0.0/0",
f"{svc} exposed to entire internet — brute-force and scan risk",
f"Restrict inbound rule to specific IP ranges or use VPN/SSM",
'CIS 5.2')
def audit_cloudtrail(session):
print(f"\n{Fore.CYAN}[*] CLOUDTRAIL{Style.RESET_ALL}")
ct = session.client('cloudtrail')
try:
trails = ct.describe_trails(includeShadowTrails=False)['trailList']
except Exception as e:
print(f" ERROR: {e}"); return
if not trails:
finding('CRITICAL', 'CloudTrail', 'No trails configured',
'All API activity is unlogged — impossible to investigate incidents',
'aws cloudtrail create-trail --name default --s3-bucket-name <your-log-bucket>', 'CIS 3.1')
return
for trail in trails:
name = trail['Name']
try:
status = ct.get_trail_status(Name=trail['TrailARN'])
if not status.get('IsLogging', False):
finding('HIGH', name, 'Logging DISABLED',
'Trail exists but is not actively logging',
f'aws cloudtrail start-logging --name {name}', 'CIS 3.1')
else:
ok(name, 'Logging ✓')
except Exception:
pass
if not trail.get('IsMultiRegionTrail', False):
finding('HIGH', name, 'Multi-region logging: DISABLED',
'API events in other regions are not captured',
f'aws cloudtrail update-trail --name {name} --is-multi-region-trail', 'CIS 3.1')
if not trail.get('LogFileValidationEnabled', False):
finding('MEDIUM', name, 'Log file validation: DISABLED',
'Cannot verify logs have not been tampered with',
f'aws cloudtrail update-trail --name {name} --enable-log-file-validation', 'CIS 3.2')
else:
ok(name, 'Log validation ✓')
def audit_rds(session):
print(f"\n{Fore.CYAN}[*] RDS INSTANCES{Style.RESET_ALL}")
rds = session.client('rds')
try:
instances = rds.describe_db_instances()['DBInstances']
except Exception as e:
print(f" ERROR: {e}"); return
print(f" {len(instances)} instances")
for db in instances:
db_id = db['DBInstanceIdentifier']
if db.get('PubliclyAccessible', False):
finding('HIGH', db_id, 'Publicly Accessible: YES',
'RDS should never be directly reachable from the internet',
'Modify instance: set PubliclyAccessible=false; use VPC private subnet + bastion/SSM',
'CIS 2.3.2')
if not db.get('StorageEncrypted', False):
finding('HIGH', db_id, 'Storage Encryption: DISABLED',
'Database data at rest is not encrypted',
'Create encrypted snapshot and restore to new encrypted instance', 'CIS 2.3.1')
if not db.get('MultiAZ', False):
finding('LOW', db_id, 'Multi-AZ: DISABLED', 'Single point of failure',
'Enable Multi-AZ for production databases', '')
else:
ok(db_id, 'Private ✓ | Encrypted ✓ | Multi-AZ ✓')
# ─── AZURE (stub with realistic structure) ──────────────────────────────────
def audit_azure(subscription_id):
print(f"\n{Fore.CYAN}[*] AZURE AUDIT — Subscription: {subscription_id}{Style.RESET_ALL}")
try:
from azure.identity import DefaultAzureCredential
from azure.mgmt.storage import StorageManagementClient
from azure.mgmt.authorization import AuthorizationManagementClient
credential = DefaultAzureCredential()
storage_client = StorageManagementClient(credential, subscription_id)
accounts = list(storage_client.storage_accounts.list())
print(f" {len(accounts)} storage accounts")
for acct in accounts:
name = acct.name
if acct.allow_blob_public_access:
finding('HIGH', name, 'Public Blob Access: ENABLED',
'Storage containers may be accessible anonymously',
'az storage account update --allow-blob-public-access false', 'CIS Azure 3.5')
if not acct.enable_https_traffic_only:
finding('HIGH', name, 'HTTPS-only: DISABLED',
'Storage traffic can be sent unencrypted over HTTP',
'az storage account update --https-only true', 'CIS Azure 3.1')
else:
ok(name, 'HTTPS-only ✓ | Public access off ✓')
except ImportError:
print(f" {Fore.YELLOW}azure-mgmt-storage not installed — skipping{Style.RESET_ALL}")
except Exception as e:
print(f" ERROR: {e}")
# ─── GCP (stub) ──────────────────────────────────────────────────────────────
def audit_gcp(project_id):
print(f"\n{Fore.CYAN}[*] GCP AUDIT — Project: {project_id}{Style.RESET_ALL}")
try:
from google.cloud import storage as gcs
client = gcs.Client(project=project_id)
buckets = list(client.list_buckets())
print(f" {len(buckets)} GCS buckets")
for bucket in buckets:
policy = bucket.get_iam_policy(requested_policy_version=3)
for binding in policy.bindings:
members = binding.get('members', [])
if 'allUsers' in members or 'allAuthenticatedUsers' in members:
finding('CRITICAL', bucket.name, f'Public IAM binding: {binding["role"]}',
'Bucket is accessible to the entire internet',
f'gsutil iam ch -d allUsers gs://{bucket.name}', 'CIS GCP 5.1')
break
else:
ok(bucket.name, 'Private ✓')
except ImportError:
print(f" {Fore.YELLOW}google-cloud-storage not installed — skipping{Style.RESET_ALL}")
except Exception as e:
print(f" ERROR: {e}")
# ─── SCORING & REPORT ────────────────────────────────────────────────────────
def calculate_score():
weights = {'CRITICAL': 20, 'HIGH': 10, 'MEDIUM': 5, 'LOW': 2}
deduct = sum(weights.get(f['severity'], 0) for f in FINDINGS)
return max(0, 100 - deduct)
def save_report(output_path):
report = {
'generated': datetime.now(timezone.utc).isoformat(),
'score': calculate_score(),
'summary': {s: sum(1 for f in FINDINGS if f['severity'] == s)
for s in ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']},
'findings': FINDINGS
}
with open(output_path, 'w') as fh:
json.dump(report, fh, indent=2)
print(f"\n{Fore.GREEN}Report saved: {output_path}{Style.RESET_ALL}")
# ─── MAIN ─────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description='Cloud Security Posture Auditor')
parser.add_argument('--provider', required=True, choices=['aws', 'azure', 'gcp'])
parser.add_argument('--profile', default='default', help='AWS profile / Azure subscription / GCP project')
parser.add_argument('--region', default='us-east-1', help='AWS region or "all"')
parser.add_argument('--output', default='', help='Output JSON report path')
parser.add_argument('--cis', action='store_true', default=True, help='Run CIS Benchmark checks')
args = parser.parse_args()
print(f"{Fore.CYAN}[*] Cloud Security Auditor | Provider: {args.provider.upper()}{Style.RESET_ALL}")
if args.cis:
print(f"{Fore.CYAN}[*] Running CIS Cloud Foundations Benchmark checks{Style.RESET_ALL}")
if args.provider == 'aws':
try:
import boto3
session = boto3.Session(profile_name=args.profile)
sts = session.client('sts')
identity = sts.get_caller_identity()
account_id = identity['Account']
print(f"{Fore.CYAN}[*] AWS Account: {account_id}{Style.RESET_ALL}")
except Exception as e:
print(f"{Fore.RED}ERROR: Cannot authenticate to AWS — {e}{Style.RESET_ALL}")
sys.exit(1)
regions = [args.region]
if args.region == 'all':
ec2 = session.client('ec2', region_name='us-east-1')
regions = [r['RegionName'] for r in ec2.describe_regions()['Regions']]
print(f"{Fore.CYAN}[*] Scanning {len(regions)} regions{Style.RESET_ALL}")
audit_s3_buckets(session)
audit_iam(session)
for region in regions:
audit_security_groups(session, region)
audit_cloudtrail(session)
audit_rds(session)
elif args.provider == 'azure':
audit_azure(args.profile)
elif args.provider == 'gcp':
audit_gcp(args.profile)
# Summary
score = calculate_score()
counts = {s: sum(1 for f in FINDINGS if f['severity'] == s) for s in ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']}
print(f"\n{'─'*70}")
score_color = Fore.RED if score < 50 else Fore.YELLOW if score < 75 else Fore.GREEN
print(f"{score_color}CIS Score: {score}/100{Style.RESET_ALL} "
f"{Fore.RED}{counts['CRITICAL']} CRITICAL{Style.RESET_ALL} "
f"{Fore.YELLOW}{counts['HIGH']} HIGH{Style.RESET_ALL} "
f"{Fore.CYAN}{counts['MEDIUM']} MEDIUM{Style.RESET_ALL} "
f"{counts['LOW']} LOW")
output = args.output or f"{args.provider}_audit_{datetime.now().strftime('%Y-%m-%d')}.json"
save_report(output)
return 2 if counts['CRITICAL'] else (1 if counts['HIGH'] or counts['MEDIUM'] else 0)
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)
--providerselectrequiredWhich cloud to audit: aws, azure or gcp. One per run.
--profiletextAWS named profile, Azure subscription, or GCP project. Default: default.
--regiontextAWS region, or "all" to sweep every region. Ignored for Azure and GCP.
--outputtextJSON report path. This is your audit evidence.
--cisbooleanRun the CIS Benchmark checks as well as the misconfiguration checks. On by default.
What This Solves
Cloud breaches are rarely clever. They are a bucket somebody made public to debug something, a role with `*:*` because the scoped policy was taking too long, a security group open to 0.0.0.0/0 on a port that was only supposed to be open for an afternoon, and logging that was never switched on so nobody can reconstruct what happened. The consoles will tell you all of this. They will tell you one account, one region, one service at a time, through three different interfaces if you are multi-cloud. This checks the handful of misconfigurations that account for most real incidents, across AWS, Azure and GCP with one tool and one output format: public S3 / Blob / GCS buckets over-permissive IAM roles - admin where admin is not needed MFA not enforced CloudTrail and equivalent logging disabled security groups open to the world unencrypted storage plus CIS Benchmark checks One provider per run, one report format for all three.
Is It Safe to Run
Yes. Every call is a read: describe, list, get. It creates nothing, modifies nothing, and deletes nothing in your cloud account. Three things to settle before the first run: Use read-only credentials. AWS `SecurityAudit` or `ViewOnlyAccess`, the Azure `Reader` or `Security Reader` role, GCP `roles/viewer` plus `roles/iam.securityReviewer`. An audit tool does not need write access and should not have it. If the only credentials available are administrative, that is itself a finding about your account. API calls are logged and may cost money. Every call lands in CloudTrail or the equivalent - which is correct, and means your own audit will appear in the audit log. A sweep across all regions is a lot of calls; on most accounts the cost is negligible, but `--region all` is not free. It reports, it does not remediate. Nothing is fixed for you. Every finding is a change you will make deliberately, because "make this bucket private" is occasionally the thing that takes down a static website. On your own machine it writes the JSON report under --output.
Before You Start
1. Python 3.9 or newer: python --version 2. The libraries. They are heavy - install only the provider you need if you prefer: pip install "boto3>=1.34" "azure-identity>=1.15" \ "azure-mgmt-security>=3.0" "azure-mgmt-storage>=21.0" \ "azure-mgmt-authorization>=3.0" \ "google-cloud-asset>=3.16" "colorama>=0.4.6" 3. Working provider credentials, configured the normal way for that cloud - the script uses the standard credential chain, not its own. AWS a named profile in ~/.aws/credentials, or the usual environment variables / instance role Azure `az login`, or a service principal in the environment GCP `gcloud auth application-default login`, or a service account key in GOOGLE_APPLICATION_CREDENTIALS There is no credential flag, deliberately: the provider SDKs already solve this, and a credential on a command line is readable by every user on the machine. 4. Confirm the credentials work before blaming the script: aws sts get-caller-identity --profile myprofile az account show gcloud auth list
Your First Run
One provider, one region: python cloud_security_auditor.py --provider aws \ --profile myprofile --region us-east-1 Read the findings against what you know about that account. A first run on an account nobody has audited produces a long list, and triaging it is easier when you can recognise which items are deliberate. Then widen: python cloud_security_auditor.py --provider aws \ --profile myprofile --region all \ --output aws-audit-$(date +%F).json `--region all` sweeps every region, which is where forgotten resources live. The instance someone spun up in ap-southeast-2 for a test in 2022 is exactly the one with the open security group. Azure and GCP use `--profile` for the subscription and the project respectively: python cloud_security_auditor.py --provider azure --profile <subscription-id> python cloud_security_auditor.py --provider gcp --profile <project-id>
What the Output Means
Findings are graded CRITICAL, HIGH, MEDIUM or LOW, with the resource and the remediation. The ones worth knowing in advance: A public bucket - CRITICAL Public by ACL or by policy. Verify what is in it before you panic and before you close it: a public bucket serving a static website is intentional, and a public bucket of backups is an incident. The script cannot tell them apart; you can, in a minute. An IAM role with admin - CRITICAL `*:*`, or an admin-equivalent managed policy, on a role that does not need it. This is the finding that turns any single compromised credential into a compromised account. MFA not enforced - HIGH Especially on the root account or on any user with console access. Logging disabled - HIGH CloudTrail, Azure Activity Log, GCP Cloud Audit Logs. Not an exposure in itself, and it is what determines whether you can answer "what happened" afterwards. An incident without logs is an incident you will never fully close. Security group open to 0.0.0.0/0 - HIGH Severity depends entirely on the port. 443 to the world is a web server. 22 or 3389 to the world is how most compromises start. Read the port before you read the count. Unencrypted storage - MEDIUM Usually a compliance finding rather than an active risk, and usually cheap to fix going forward and expensive to fix retroactively. Two limits to be honest about: One provider and, by default, one region per run. A finding in a region you did not scan does not exist in the report. Multi-account organisations need a run per account. A check that could not run reports nothing. If the credentials lack permission for a service, that service is simply absent from the findings. **Absence is not compliance** - which is why it matters that the read-only role is complete rather than minimal.
Exit Codes for Cron and CI
0 nothing above low severity 1 high or medium findings 2 critical findings 130 you pressed Ctrl-C A weekly run per account, with the report kept, is the realistic use: 0 6 * * 1 cd /opt/cloudaudit && python cloud_security_auditor.py \ --provider aws --profile prod --region all \ --output /var/log/cloud/aws-prod-$(date +\%F).json The dated reports are the valuable part. "This bucket became public between the 3rd and the 10th" is a question you can only answer if you kept both.
Every Flag
--provider NAME REQUIRED. aws, azure or gcp. One per run. --profile NAME AWS named profile, Azure subscription id, or GCP project id. Default: default. --region NAME AWS region, or "all" to sweep every region. Ignored for Azure and GCP. --output PATH JSON report path. This is your audit evidence - keep it dated. --cis Run the CIS Benchmark checks as well as the misconfiguration checks. On by default. Credentials come from the provider's standard credential chain. There is no credential flag.
When Something Fails
ModuleNotFoundError The SDK for that provider is not installed. You only need the one you are auditing - install that one rather than all three. "Unable to locate credentials" / "DefaultAzureCredential failed" / "could not determine project" The provider's credential chain found nothing. Confirm outside the script first: aws sts get-caller-identity --profile myprofile az account show gcloud auth list If those work and this does not, check that --profile names the same profile, subscription or project. "AccessDenied" on some checks The credentials lack permission for that service, so those checks produce nothing at all. Widen the read-only role - SecurityAudit on AWS, Security Reader on Azure, securityReviewer on GCP - or record which services were not audited. A short report from a narrow role looks identical to a clean account. A public bucket that is supposed to be public Static website hosting, a public dataset. Confirm the contents, then record it as accepted rather than leaving it at the top of every report. Fewer findings than the console shows Check the region. Without `--region all`, only the one region was audited, and forgotten resources are almost always somewhere you were not looking. An admin role you believe is required Some automation genuinely needs broad permissions. Scope it to the resources it touches and record the rest - "it needs admin" is nearly always "nobody has scoped it yet". The run against `--region all` is slow or throttled It is many API calls across every region. Run it off-hours, or audit the regions you actually use. Throttling is the provider, not the script.