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

add aws password testing
 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(+)

diff --git a/applications/aws/README.md b/applications/aws/README.md
index 5a808fb..5d08f5d 100644
--- a/applications/aws/README.md
+++ b/applications/aws/README.md
@@ -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
diff --git a/applications/aws/aws_password_policy/evaluate_policy.py b/applications/aws/aws_password_policy/evaluate_policy.py
new file mode 100644
index 0000000..a0d22ac
--- /dev/null
+++ b/applications/aws/aws_password_policy/evaluate_policy.py
@@ -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()
diff --git a/applications/aws/aws_password_policy/gather_policy.sh b/applications/aws/aws_password_policy/gather_policy.sh
new file mode 100644
index 0000000..f2b08e8
--- /dev/null
+++ b/applications/aws/aws_password_policy/gather_policy.sh
@@ -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"