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

5d9771378d4f4a65752cabc8cea035298a60bce8

signed_email_mismatch · cmc

author: Claude <noreply@anthropic.com> · 2026-08-05T20:44:18Z
committer: <hello@cleberg.net>

fix: correct password-policy evaluator bugs and harden collector

evaluate_policy.py:
- Fix MaxPasswordAge scoring: it is a maximum (lower is stricter), so
  score actual <= expected. Previously scored as a minimum, which passed
  overly long expiry windows that should fail.
- Stop crashing on absent numeric fields. AWS omits MaxPasswordAge when
  expiry is off (and PasswordReusePrevention when reuse prevention is
  off); comparing the "(missing)" sentinel to an int raised TypeError.
  A required-but-absent item is now reported as FAIL.
- Fix utc_now(): it referenced datetime.datetime (the class has no such
  attribute) and only worked via a module re-import buried in __main__.
  Use datetime.now(timezone.utc) and drop the shadowing import.
- Compare booleans with == and reject cross-type matches (a bool no
  longer satisfies a numeric rule and vice versa).

gather_policy.sh:
- Build the metadata block with `jq --arg/--argjson` so hostnames or
  working directories containing quotes or backslashes cannot produce
  malformed JSON.
 .../aws/aws_password_policy/evaluate_policy.py     | 42 +++++++++++++++-------
 .../aws/aws_password_policy/gather_policy.sh       | 34 ++++++++++--------
 2 files changed, 49 insertions(+), 27 deletions(-)

diff --git a/applications/aws/aws_password_policy/evaluate_policy.py b/applications/aws/aws_password_policy/evaluate_policy.py
index 6169a49..65cd25e 100755
--- a/applications/aws/aws_password_policy/evaluate_policy.py
+++ b/applications/aws/aws_password_policy/evaluate_policy.py
@@ -8,8 +8,12 @@ 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).
+* Most numeric items are treated as **minimums** (actual >= expected → PASS).
+* Maximum password age is treated as a **maximum** (actual <= expected → PASS),
+  because a lower ceiling is the stricter/more‑secure setting.
 * Boolean items are treated as **exact matches** (actual == expected → PASS).
+* A required item that is absent from the policy is reported as FAIL rather
+  than crashing (AWS omits e.g. MaxPasswordAge when password expiry is off).
 * The CSV begins with a small metadata block (same data that the Bash script
   captured) so the audit trail is self‑contained.
 * Usage:
@@ -27,16 +31,19 @@ from typing import Any
 # Mapping of the 10 password‑policy fields we care about
 # (rule_no, json_key, friendly_name, datatype)
 # ----------------------------------------------------------------------
+# Numeric datatypes carry a direction:
+#   "int_min" – actual must be >= expected (higher is stricter)
+#   "int_max" – actual must be <= expected (lower is stricter)
 POLICY_FIELDS = [
-    (1, "MinimumPasswordLength", "Minimum password length", "int"),
+    (1, "MinimumPasswordLength", "Minimum password length", "int_min"),
     (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"),
+    (8, "MaxPasswordAge", "Maximum password age (days)", "int_max"),
+    (9, "PasswordReusePrevention", "Prevent password reuse (last N)", "int_min"),
     (10, "HardExpiry", "Hard expiry (no grace period)", "bool"),
 ]
 
@@ -45,8 +52,8 @@ POLICY_FIELDS = [
 # Helper functions
 # ----------------------------------------------------------------------
 def utc_now() -> datetime:
-    """Return a timezone‑aware UTC datetime (compatible with all Python 3.x)."""
-    return datetime.datetime.now(timezone.utc)
+    """Return a timezone‑aware UTC datetime."""
+    return datetime.now(timezone.utc)
 
 
 def prompt_expected(field_type: str, description: str) -> Any | None:
@@ -62,7 +69,7 @@ def prompt_expected(field_type: str, description: str) -> Any | None:
         ).strip()
         if raw == "":
             return None  # N/A
-        if field_type == "int":
+        if field_type.startswith("int"):
             if raw.isdigit():
                 return int(raw)
             print("Please enter a whole number (or leave blank).")
@@ -79,13 +86,24 @@ def prompt_expected(field_type: str, description: str) -> Any | None:
 
 
 def evaluate(expect: Any | None, actual: Any, field_type: str) -> str:
-    """Return PASS / FAIL / N/A."""
+    """Return PASS / FAIL / N/A.
+
+    A required item (expectation set) that is absent or of the wrong type in
+    the policy is a FAIL, never a crash.
+    """
     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"
+        # bool is a subclass of int, so guard against ints sneaking through.
+        return "PASS" if isinstance(actual, bool) and actual == expect else "FAIL"
+    if field_type.startswith("int"):
+        # Reject non‑numbers (e.g. a missing field rendered as a string) and
+        # booleans (a subclass of int that must not satisfy a numeric rule).
+        if isinstance(actual, bool) or not isinstance(actual, (int, float)):
+            return "FAIL"
+        if field_type == "int_max":
+            return "PASS" if actual <= expect else "FAIL"
+        return "PASS" if actual >= expect else "FAIL"
     return "FAIL"
 
 
@@ -173,6 +191,4 @@ def main() -> None:
 
 
 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
index f2b08e8..87432fd 100644
--- a/applications/aws/aws_password_policy/gather_policy.sh
+++ b/applications/aws/aws_password_policy/gather_policy.sh
@@ -60,20 +60,26 @@ fi
 #   * 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
-)
+# Build with `jq --arg` so values containing quotes/backslashes (e.g. an odd
+# hostname or working directory) can never produce malformed JSON.
+CALLER_IDENTITY=$(aws sts get-caller-identity 2>/dev/null || echo "null")
+METADATA=$(jq -n \
+    --arg ts "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
+    --arg user "$(id -un)" \
+    --arg host "$(hostname)" \
+    --arg cwd "$(pwd)" \
+    --arg profile "${AWS_PROFILE:-default}" \
+    --arg region "${AWS_DEFAULT_REGION:-unknown}" \
+    --argjson caller "$CALLER_IDENTITY" \
+    '{metadata: {
+        report_timestamp_utc: $ts,
+        os_user: $user,
+        hostname: $host,
+        working_directory: $cwd,
+        aws_profile: $profile,
+        aws_region: $region,
+        aws_caller_identity: $caller
+    }}')
 
 # ---------- 3. Merge policy + metadata ----------
 # The final JSON will have two top‑level keys: "metadata" and "PasswordPolicy"