audit-labs/audit-tools

A collection of scripts, queries, and other goodies you can use in an audit. audit automation compliance evidence scripts

Commit a70cee9b1e

a70cee9b1e475ff61f5ca97183969f16bf931fa8

parent: 5ea48c117a

Unsigned

cmc <hello@cleberg.net> · 2026-08-05 20:44 UTC

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.

Layout: unified · split

applications/aws/aws_password_policy/evaluate_policy.py +29 −13
@@ -8,8 +8,12 @@ a CSV audit report.
88
99Features
1010* Interactive prompts – press <Enter> to mark a rule as N/A.
11* Numeric items are treated as **minimums** (actual >= expected → PASS).
11* Most numeric items are treated as **minimums** (actual >= expected → PASS).
12* Maximum password age is treated as a **maximum** (actual <= expected → PASS),
13 because a lower ceiling is the stricter/more‑secure setting.
1214* Boolean items are treated as **exact matches** (actual == expected → PASS).
15* A required item that is absent from the policy is reported as FAIL rather
16 than crashing (AWS omits e.g. MaxPasswordAge when password expiry is off).
1317* The CSV begins with a small metadata block (same data that the Bash script
1418 captured) so the audit trail is self‑contained.
1519* Usage:
@@ -27,16 +31,19 @@ from typing import Any
2731# Mapping of the 10 password‑policy fields we care about
2832# (rule_no, json_key, friendly_name, datatype)
2933# ----------------------------------------------------------------------
34# Numeric datatypes carry a direction:
35# "int_min" – actual must be >= expected (higher is stricter)
36# "int_max" – actual must be <= expected (lower is stricter)
3037POLICY_FIELDS = [
31 (1, "MinimumPasswordLength", "Minimum password length", "int"),
38 (1, "MinimumPasswordLength", "Minimum password length", "int_min"),
3239 (2, "RequireSymbols", "Require symbols (!@#$…)", "bool"),
3340 (3, "RequireNumbers", "Require numbers (0‑9)", "bool"),
3441 (4, "RequireUppercaseCharacters", "Require uppercase letters (A‑Z)", "bool"),
3542 (5, "RequireLowercaseCharacters", "Require lowercase letters (a‑z)", "bool"),
3643 (6, "AllowUsersToChangePassword", "Allow users to change password", "bool"),
3744 (7, "ExpirePasswords", "Expire passwords (enable aging)", "bool"),
38 (8, "MaxPasswordAge", "Maximum password age (days)", "int"),
39 (9, "PasswordReusePrevention", "Prevent password reuse (last N)", "int"),
45 (8, "MaxPasswordAge", "Maximum password age (days)", "int_max"),
46 (9, "PasswordReusePrevention", "Prevent password reuse (last N)", "int_min"),
4047 (10, "HardExpiry", "Hard expiry (no grace period)", "bool"),
4148]
4249
@@ -45,8 +52,8 @@ POLICY_FIELDS = [
4552# Helper functions
4653# ----------------------------------------------------------------------
4754def utc_now() -> datetime:
48 """Return a timezone‑aware UTC datetime (compatible with all Python 3.x)."""
49 return datetime.datetime.now(timezone.utc)
55 """Return a timezone‑aware UTC datetime."""
56 return datetime.now(timezone.utc)
5057
5158
5259def prompt_expected(field_type: str, description: str) -> Any | None:
@@ -62,7 +69,7 @@ def prompt_expected(field_type: str, description: str) -> Any | None:
6269 ).strip()
6370 if raw == "":
6471 return None # N/A
65 if field_type == "int":
72 if field_type.startswith("int"):
6673 if raw.isdigit():
6774 return int(raw)
6875 print("Please enter a whole number (or leave blank).")
@@ -79,13 +86,24 @@ def prompt_expected(field_type: str, description: str) -> Any | None:
7986
8087
8188def evaluate(expect: Any | None, actual: Any, field_type: str) -> str:
82 """Return PASS / FAIL / N/A."""
89 """Return PASS / FAIL / N/A.
90
91 A required item (expectation set) that is absent or of the wrong type in
92 the policy is a FAIL, never a crash.
93 """
8394 if expect is None:
8495 return "N/A"
85 if field_type == "int":
86 return "PASS" if actual >= expect else "FAIL"
8796 if field_type == "bool":
88 return "PASS" if actual is expect else "FAIL"
97 # bool is a subclass of int, so guard against ints sneaking through.
98 return "PASS" if isinstance(actual, bool) and actual == expect else "FAIL"
99 if field_type.startswith("int"):
100 # Reject non‑numbers (e.g. a missing field rendered as a string) and
101 # booleans (a subclass of int that must not satisfy a numeric rule).
102 if isinstance(actual, bool) or not isinstance(actual, (int, float)):
103 return "FAIL"
104 if field_type == "int_max":
105 return "PASS" if actual <= expect else "FAIL"
106 return "PASS" if actual >= expect else "FAIL"
89107 return "FAIL"
90108
91109
@@ -173,6 +191,4 @@ def main() -> None:
173191
174192
175193if __name__ == "__main__":
176 import datetime # imported here to keep the top of file tidy
177
178194 main()
applications/aws/aws_password_policy/gather_policy.sh +20 −14
@@ -60,20 +60,26 @@ fi
6060# * current working directory (useful for traceability)
6161# * AWS profile & region (if set)
6262# * AWS caller identity (ARN, account id, user id) – proves *who* ran the command
63METADATA=$(cat <<EOF
64{
65 "metadata": {
66 "report_timestamp_utc": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
67 "os_user": "$(id -un)",
68 "hostname": "$(hostname)",
69 "working_directory": "$(pwd)",
70 "aws_profile": "${AWS_PROFILE:-default}",
71 "aws_region": "${AWS_DEFAULT_REGION:-unknown}",
72 "aws_caller_identity": $(aws sts get-caller-identity 2>/dev/null || echo "null")
73 }
74}
75EOF
76)
63# Build with `jq --arg` so values containing quotes/backslashes (e.g. an odd
64# hostname or working directory) can never produce malformed JSON.
65CALLER_IDENTITY=$(aws sts get-caller-identity 2>/dev/null || echo "null")
66METADATA=$(jq -n \
67 --arg ts "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
68 --arg user "$(id -un)" \
69 --arg host "$(hostname)" \
70 --arg cwd "$(pwd)" \
71 --arg profile "${AWS_PROFILE:-default}" \
72 --arg region "${AWS_DEFAULT_REGION:-unknown}" \
73 --argjson caller "$CALLER_IDENTITY" \
74 '{metadata: {
75 report_timestamp_utc: $ts,
76 os_user: $user,
77 hostname: $host,
78 working_directory: $cwd,
79 aws_profile: $profile,
80 aws_region: $region,
81 aws_caller_identity: $caller
82 }}')
7783
7884# ---------- 3. Merge policy + metadata ----------
7985# The final JSON will have two top‑level keys: "metadata" and "PasswordPolicy"