audit-labs/audit-tools
A collection of scripts, queries, and other goodies you can use in an audit.
clone: git clone https://gitbay.org/audit-labs/audit-tools.git
bb1e7eeee9047fa4d548a4b0f1080736a5b593c9
unsigned
author: Christian Cleberg <hello@cleberg.net> · 2025-12-15T01:45:10Z
applications/aws/README.md | 113 +++++++++++++ .../aws/aws_password_policy/evaluate_policy.py | 183 +++++++++++++++++++++ .../aws/aws_password_policy/gather_policy.sh | 85 ++++++++++ 3 files changed, 381 insertions(+) @@ -3,6 +3,7 @@ *Note*: This example uses an account titled `cmc`, which has access provisioned to it through IAM. ``` bash +chmod +x aws_iam_users.sh ./aws_iam_users.sh ``` @@ -92,3 +93,115 @@ cat report_cmc.json } ] ``` + +# `aws_password_policy` + +To test a password policy against AWS, I have created two steps: + +**Step 1: Gather AWS Policy** + +Run the script on your CloudShell or using the `aws` command + +``` bash +chmod +x gather_policy.sh +./gather_policy.sh +``` + +This will produce a JSON file as the output, with both metadata and the password policy/ + +``` json +{ + "metadata": { + "report_timestamp_utc": "2025-12-15T01:29:52Z", + "os_user": "cloudshell-user", + "hostname": "", + "working_directory": "/home/cloudshell-user", + "aws_profile": "default", + "aws_region": "eu-west-1", + "aws_caller_identity": { + "UserId": "214941490075", + "Account": "214941490075", + "Arn": "arn:aws:iam::214941490075:root" + } + }, + "PasswordPolicy": { + "MinimumPasswordLength": 8, + "RequireSymbols": true, + "RequireNumbers": true, + "RequireUppercaseCharacters": true, + "RequireLowercaseCharacters": true, + "AllowUsersToChangePassword": true, + "ExpirePasswords": true, + "MaxPasswordAge": 90, + "PasswordReusePrevention": 4, + "HardExpiry": false + } +} +``` + +**Step 2: Test AWS** + +Use this file as the input to the `evaluate_policy.py` script. This Python script will ask you what you expect the values to be (e.g., what are the requirements in the company's policy?). + +``` bash +uv run evaluate_policy.py policy_report.json +``` + +This will ask you for inputs dynamically (all are optional) and will return both a table of results in the shell, as well as a CSV file for further testing and/or documentation. + +*Shell Output:* + +``` text +=== Expected / Minimum Values (press <Enter> for N/A) === + +Enter expected value for 'Minimum password length' (int) or press <Enter> to skip: 8 +Enter expected value for 'Require symbols (!@#$…)' (bool) or press <Enter> to skip: true +Enter expected value for 'Require numbers (0‑9)' (bool) or press <Enter> to skip: true +Enter expected value for 'Require uppercase letters (A‑Z)' (bool) or press <Enter> to skip: true +Enter expected value for 'Require lowercase letters (a‑z)' (bool) or press <Enter> to skip: true +Enter expected value for 'Allow users to change password' (bool) or press <Enter> to skip: true +Enter expected value for 'Expire passwords (enable aging)' (bool) or press <Enter> to skip: true +Enter expected value for 'Maximum password age (days)' (int) or press <Enter> to skip: 90 +Enter expected value for 'Prevent password reuse (last N)' (int) or press <Enter> to skip: 4 +Enter expected value for 'Hard expiry (no grace period)' (bool) or press <Enter> to skip: false + +Audit CSV written to: policy_audit_20251215T014323Z.csv + +Summary: + 1. Minimum password length → PASS + 2. Require symbols (!@#$…) → PASS + 3. Require numbers (0‑9) → PASS + 4. Require uppercase letters (A‑Z) → PASS + 5. Require lowercase letters (a‑z) → PASS + 6. Allow users to change password → PASS + 7. Expire passwords (enable aging) → PASS + 8. Maximum password age (days) → PASS + 9. Prevent password reuse (last N) → PASS + 10. Hard expiry (no grace period) → PASS + +--- End of report --- +``` + +*CSV Output:* + +``` csv +# report_timestamp_utc: 2025-12-15T01:29:52Z +# os_user: cloudshell-user +# hostname: +# working_directory: /home/cloudshell-user +# aws_profile: default +# aws_region: eu-west-1 +"# aws_caller_identity: {'UserId': '214941490075', 'Account': '214941490075', 'Arn': 'arn:aws:iam::214941490075:root'}" + +Rule#,Policy‑Item,Expected,Actual,Result +1,Minimum password length,8,8,PASS +2,Require symbols (!@#$…),true,true,PASS +3,Require numbers (0‑9),true,true,PASS +4,Require uppercase letters (A‑Z),true,true,PASS +5,Require lowercase letters (a‑z),true,true,PASS +6,Allow users to change password,true,true,PASS +7,Expire passwords (enable aging),true,true,PASS +8,Maximum password age (days),90,90,PASS +9,Prevent password reuse (last N),4,4,PASS +10,Hard expiry (no grace period),false,false,PASS +``` \ No newline at end of file new file mode 100644 @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +""" +evaluate_policy.py +------------------ +Read the JSON file produced by `gather_policy.sh`, ask the auditor for the +expected/minimum values for each of the 10 IAM password‑policy items, and emit +a CSV audit report. + +Features +* Interactive prompts – press <Enter> to mark a rule as N/A. +* Numeric items are treated as **minimums** (actual >= expected → PASS). +* Boolean items are treated as **exact matches** (actual == expected → PASS). +* The CSV begins with a small metadata block (same data that the Bash script + captured) so the audit trail is self‑contained. +* Usage: + python3 evaluate_policy.py policy_report.json +""" + +import csv +import json +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +# ---------------------------------------------------------------------- +# Mapping of the 10 password‑policy fields we care about +# (rule_no, json_key, friendly_name, datatype) +# ---------------------------------------------------------------------- +POLICY_FIELDS = [ + (1, "MinimumPasswordLength", "Minimum password length", "int"), + (2, "RequireSymbols", "Require symbols (!@#$…)", "bool"), + (3, "RequireNumbers", "Require numbers (0‑9)", "bool"), + (4, "RequireUppercaseCharacters", "Require uppercase letters (A‑Z)", "bool"), + (5, "RequireLowercaseCharacters", "Require lowercase letters (a‑z)", "bool"), + (6, "AllowUsersToChangePassword", "Allow users to change password", "bool"), + (7, "ExpirePasswords", "Expire passwords (enable aging)", "bool"), + (8, "MaxPasswordAge", "Maximum password age (days)", "int"), + (9, "PasswordReusePrevention", "Prevent password reuse (last N)", "int"), + (10, "HardExpiry", "Hard expiry (no grace period)", "bool"), +] + + +# ---------------------------------------------------------------------- +# Helper functions +# ---------------------------------------------------------------------- +def utc_now() -> datetime: + """Return a timezone‑aware UTC datetime (compatible with all Python 3.x).""" + return datetime.datetime.now(timezone.utc) + + +def prompt_expected(field_type: str, description: str) -> Optional[Any]: + """ + Ask the auditor for the expected value. + Returns: + - int / bool : the entered expectation + - None : user pressed Enter → rule is N/A + """ + while True: + raw = input( + f"Enter expected value for '{description}' ({field_type}) or press <Enter> to skip: " + ).strip() + if raw == "": + return None # N/A + if field_type == "int": + if raw.isdigit(): + return int(raw) + print("Please enter a whole number (or leave blank).") + elif field_type == "bool": + lowered = raw.lower() + if lowered in {"true", "t", "yes", "y", "1"}: + return True + if lowered in {"false", "f", "no", "n", "0"}: + return False + print("Boolean expected – type yes/no (or true/false).") + else: + # Should never happen + return raw + + +def evaluate(expect: Optional[Any], actual: Any, field_type: str) -> str: + """Return PASS / FAIL / N/A.""" + if expect is None: + return "N/A" + if field_type == "int": + return "PASS" if actual >= expect else "FAIL" + if field_type == "bool": + return "PASS" if actual is expect else "FAIL" + return "FAIL" + + +def load_json(path: Path) -> Dict[str, Any]: + """Read the JSON file generated by the Bash script.""" + try: + with path.open("r", encoding="utf-8") as fh: + return json.load(fh) + except Exception as exc: + sys.exit(f"Could not read JSON file {path}: {exc}") + + +def write_csv( + out_path: Path, + metadata: Dict[str, Any], + rows: List[List[Any]], +) -> None: + """Write the CSV report, including a metadata header block.""" + with out_path.open("w", newline="", encoding="utf-8") as csvfile: + writer = csv.writer(csvfile) + + # ---- metadata block (prefixed with #) ---- + for key, val in metadata.items(): + writer.writerow([f"# {key}: {val}"]) + writer.writerow([]) # blank line + + # ---- column header ---- + writer.writerow(["Rule#", "Policy‑Item", "Expected", "Actual", "Result"]) + + # ---- data rows ---- + for row in rows: + writer.writerow(row) + + +# ---------------------------------------------------------------------- +# Main workflow +# ---------------------------------------------------------------------- +def main() -> None: + if len(sys.argv) != 2: + sys.exit("Usage: python3 evaluate_policy.py <policy_report.json>") + json_path = Path(sys.argv[1]) + data = load_json(json_path) + + # Extract the two top‑level sections we expect + metadata = data.get("metadata", {}) + policy = data.get("PasswordPolicy", {}) + + # -------------------------------------------------------------- + # 1. Prompt the auditor for expectations + # -------------------------------------------------------------- + expectations: Dict[str, Optional[Any]] = {} + print("\n=== Expected / Minimum Values (press <Enter> for N/A) ===\n") + for _, key, friendly, typ in POLICY_FIELDS: + expectations[key] = prompt_expected(typ, friendly) + + # -------------------------------------------------------------- + # 2. Build the CSV rows (including PASS/FAIL) + # -------------------------------------------------------------- + csv_rows: List[List[Any]] = [] + for rule_no, key, friendly, typ in POLICY_FIELDS: + expected = expectations[key] + actual = policy.get(key, "(missing)") + result = evaluate(expected, actual, typ) + + # Normalise booleans for nicer CSV output + actual_str = ( + str(actual).lower() + if isinstance(actual, bool) + else str(actual) + ) + expected_str = "" if expected is None else str(expected).lower() + + csv_rows.append( + [rule_no, friendly, expected_str, actual_str, result] + ) + + # -------------------------------------------------------------- + # 3. Write the CSV file (timestamped) + # -------------------------------------------------------------- + timestamp = utc_now().strftime("%Y%m%dT%H%M%SZ") + out_csv = Path(f"policy_audit_{timestamp}.csv") + write_csv(out_csv, metadata, csv_rows) + + print(f"\nAudit CSV written to: {out_csv}\n") + # Simple on‑screen summary + print("Summary:") + for row in csv_rows: + print(f" {row[0]:2}. {row[1]:35} → {row[4]}") + + print("\n--- End of report ---\n") + + +if __name__ == "__main__": + import datetime # imported here to keep the top of file tidy + main() new file mode 100644 @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# +# gather_policy.sh +# ---------------- +# 1. Calls AWS CLI to fetch the current IAM password policy. +# 2. Captures execution metadata (date, user, host, AWS profile/region, etc.). +# 3. Writes a single JSON document (policy_report.json) that the Python +# script can consume. +# +# Prerequisites: +# • AWS CLI v2 installed and configured (credentials, default region, etc.) +# • jq installed (used to merge JSON objects). If jq is missing the script +# will abort with a helpful message. +# +# Usage: +# $ chmod +x gather_policy.sh +# $ ./gather_policy.sh # creates policy_report.json in the cwd +# $ ./gather_policy.sh -o /tmp/my_report.json # custom output path +# + +set -euo pipefail + +# ---------- Helper ---------- +die() { echo "ERROR: $*" >&2; exit 1; } + +# ---------- Argument parsing ---------- +OUTFILE="policy_report.json" +while [[ $# -gt 0 ]]; do + case "$1" in + -o|--output) + shift + [[ -z "${1:-}" ]] && die "Missing argument for -o|--output" + OUTFILE="$1" + ;; + -h|--help) + echo "Usage: $0 [-o|--output <path-to-json>]" + exit 0 + ;; + *) + die "Unknown option: $1" + ;; + esac + shift +done + +# ---------- Verify prerequisites ---------- +command -v aws >/dev/null || die "AWS CLI not found in PATH" +command -v jq >/dev/null || die "jq not found in PATH – install it (e.g. sudo dnf install jq)" + +# ---------- 1. Pull the IAM password policy ---------- +# If no policy exists, AWS returns a NoSuchEntity error – we capture that +if ! POLICY_JSON=$(aws iam get-account-password-policy 2>/dev/null); then + die "No password policy is defined for this AWS account (AWS returned NoSuchEntity)." +fi + +# ---------- 2. Gather metadata ---------- +# * timestamp (UTC) +# * OS user running the script +# * hostname +# * current working directory (useful for traceability) +# * AWS profile & region (if set) +# * AWS caller identity (ARN, account id, user id) – proves *who* ran the command +METADATA=$(cat <<EOF +{ + "metadata": { + "report_timestamp_utc": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")", + "os_user": "$(id -un)", + "hostname": "$(hostname)", + "working_directory": "$(pwd)", + "aws_profile": "${AWS_PROFILE:-default}", + "aws_region": "${AWS_DEFAULT_REGION:-unknown}", + "aws_caller_identity": $(aws sts get-caller-identity 2>/dev/null || echo "null") + } +} +EOF +) + +# ---------- 3. Merge policy + metadata ---------- +# The final JSON will have two top‑level keys: "metadata" and "PasswordPolicy" +FINAL_JSON=$(jq -s 'reduce .[] as $item ({}; . * $item)' <(echo "$METADATA") <(echo "$POLICY_JSON")) + +# ---------- 4. Write output ---------- +echo "$FINAL_JSON" | jq '.' > "$OUTFILE" + +echo "Password‑policy report written to: $OUTFILE"