diff --git a/src/Invoke-ADAudit.ps1 b/src/Invoke-ADAudit.ps1 index f331e96..7c0988d 100644 --- a/src/Invoke-ADAudit.ps1 +++ b/src/Invoke-ADAudit.ps1 @@ -74,6 +74,11 @@ $UAC_DONT_REQ_PREAUTH = 0x400000 $now = Get-Date $staleCutoff = $now.AddDays(-$StaleDays) +function ConvertTo-Rfc3339($DateTime) { + if (-not $DateTime) { return $null } + return $DateTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") +} + # --------------------------------------------------------------------------- # Object type counts (whole subtree) # --------------------------------------------------------------------------- @@ -92,7 +97,7 @@ $ous = Get-ADOrganizationalUnit -SearchBase $SearchBase -Filter * -Properties De DN = $_.DistinguishedName Name = $_.Name Description = $_.Description - Created = $_.whenCreated + Created = ConvertTo-Rfc3339 $_.whenCreated Depth = ([regex]::Matches($_.DistinguishedName, "OU=")).Count } } @@ -123,11 +128,11 @@ $users = Get-ADUser -SearchBase $SearchBase -Filter * -Properties $userProps @ad TrustedForDelegation = [bool]($uac -band $UAC_TRUSTED_FOR_DELEGATION) KerberosPreAuthDisabled = [bool]($uac -band $UAC_DONT_REQ_PREAUTH) AdminCount = [bool]($_.adminCount -gt 0) - LastLogon = $lastLogon + LastLogon = ConvertTo-Rfc3339 $lastLogon NeverLoggedOn = $neverLoggedOn -and $_.Enabled Stale = $stale - PasswordLastSet = $_.PasswordLastSet - Created = $_.whenCreated + PasswordLastSet = ConvertTo-Rfc3339 $_.PasswordLastSet + Created = ConvertTo-Rfc3339 $_.whenCreated GroupCount = (@($_.MemberOf)).Count } } @@ -148,9 +153,9 @@ $computers = Get-ADComputer -SearchBase $SearchBase -Filter * -Properties $compP OS = $_.OperatingSystem OSVersion = $_.OperatingSystemVersion Disabled = -not $_.Enabled - LastLogon = $lastLogon + LastLogon = ConvertTo-Rfc3339 $lastLogon Stale = $stale - Created = $_.whenCreated + Created = ConvertTo-Rfc3339 $_.whenCreated } } @@ -169,7 +174,7 @@ $groups = Get-ADGroup -SearchBase $SearchBase -Filter * -Properties Description, MemberCount = $memberCount Empty = ($memberCount -eq 0) Description = $_.Description - Created = $_.whenCreated + Created = ConvertTo-Rfc3339 $_.whenCreated } } @@ -227,7 +232,7 @@ Add-Line "# Active Directory Audit Report" Add-Line "" Add-Line "- Search base: ``$SearchBase``" if ($Server) { Add-Line "- Domain controller: ``$Server``" } -Add-Line "- Generated: $($now.ToUniversalTime().ToString("o"))" +Add-Line "- Generated: $(ConvertTo-Rfc3339 $now)" Add-Line "- Stale-account threshold: $StaleDays days of inactivity" Add-Line "" diff --git a/src/ad_audit.py b/src/ad_audit.py index b5f7059..d08bd0e 100644 --- a/src/ad_audit.py +++ b/src/ad_audit.py @@ -43,6 +43,21 @@ def filetime_to_datetime(value): return None +def rfc3339(dt): + """Format a datetime as RFC 3339 (UTC, second precision, 'Z' suffix). + + Falls back to str(dt) for values ldap3 didn't parse into a datetime + (e.g. if a server's schema doesn't trigger its generalizedTime formatter). + """ + if dt is None: + return "" + if not isinstance(dt, datetime.datetime): + return str(dt) + if dt.tzinfo is not None: + dt = dt.astimezone(datetime.timezone.utc) + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + + def parse_args(): p = argparse.ArgumentParser(description=__doc__) p.add_argument("--host", required=True, help="DC hostname or IP") @@ -114,7 +129,7 @@ def audit_ous(conn, base_dn): "dn": str(e.distinguishedName), "name": str(e.ou) if e.ou else "", "description": str(e.description) if e.description else "", - "created": str(e.whenCreated) if e.whenCreated else "", + "created": rfc3339(e.whenCreated.value) if e.whenCreated else "", "depth": str(e.distinguishedName).count("OU="), } ) @@ -163,11 +178,11 @@ def audit_users(conn, base_dn, stale_days): "trusted_for_delegation": bool(uac & UAC_TRUSTED_FOR_DELEGATION), "kerberos_preauth_disabled": bool(uac & UAC_DONT_REQ_PREAUTH), "admin_count": bool(e.adminCount and int(str(e.adminCount)) > 0), - "last_logon": last_logon.isoformat() if last_logon else None, + "last_logon": rfc3339(last_logon) if last_logon else None, "never_logged_on": never_logged_on, "stale": stale, - "pwd_last_set": pwd_last_set.isoformat() if pwd_last_set else None, - "created": str(e.whenCreated) if e.whenCreated else "", + "pwd_last_set": rfc3339(pwd_last_set) if pwd_last_set else None, + "created": rfc3339(e.whenCreated.value) if e.whenCreated else "", "group_count": len(e.memberOf.values) if e.memberOf else 0, } ) @@ -203,9 +218,9 @@ def audit_computers(conn, base_dn, stale_days): "os": str(e.operatingSystem) if e.operatingSystem else "", "os_version": str(e.operatingSystemVersion) if e.operatingSystemVersion else "", "disabled": disabled, - "last_logon": last_logon.isoformat() if last_logon else None, + "last_logon": rfc3339(last_logon) if last_logon else None, "stale": stale, - "created": str(e.whenCreated) if e.whenCreated else "", + "created": rfc3339(e.whenCreated.value) if e.whenCreated else "", } ) return computers @@ -239,7 +254,7 @@ def audit_groups(conn, base_dn): "member_count": len(members), "empty": len(members) == 0, "description": str(e.description) if e.description else "", - "created": str(e.whenCreated) if e.whenCreated else "", + "created": rfc3339(e.whenCreated.value) if e.whenCreated else "", } ) return groups @@ -428,7 +443,7 @@ def build_report(data, args): a(f"") a(f"- Base DN: `{args.base_dn}`") a(f"- Domain Controller: `{args.host}`") - a(f"- Generated: {datetime.datetime.utcnow().isoformat()}Z") + a(f"- Generated: {rfc3339(datetime.datetime.utcnow())}") a(f"- Stale-account threshold: {args.stale_days} days of inactivity") a(f"") diff --git a/src/md_to_html.py b/src/md_to_html.py index b913323..4c792a6 100644 --- a/src/md_to_html.py +++ b/src/md_to_html.py @@ -119,7 +119,21 @@ th { background: var(--panel); position: sticky; top: 0; + cursor: pointer; + user-select: none; + white-space: nowrap; } +th:hover { color: var(--text); } +th .sort-arrow { + display: inline-block; + margin-left: 0.3rem; + opacity: 0.35; + font-size: 0.7em; +} +th.sort-asc .sort-arrow, th.sort-desc .sort-arrow { opacity: 1; } +th.sort-asc .sort-arrow::after { content: "\\2191"; } +th.sort-desc .sort-arrow::after { content: "\\2193"; } +th:not(.sort-asc):not(.sort-desc) .sort-arrow::after { content: "\\2195"; } tbody tr:hover { background: var(--panel); } ul { padding-left: 1.3rem; } li { margin: 0.2rem 0; } @@ -142,6 +156,96 @@ li { margin: 0.2rem 0; } color: var(--text-dim); font-size: 0.85rem; } +.tz-toggle { + display: inline-block; + margin-bottom: 1rem; + padding: 0.4rem 0.85rem; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--text); + font-size: 0.82rem; + cursor: pointer; +} +.tz-toggle:hover { border-color: var(--accent); color: var(--accent); } +.ts { font-variant-numeric: tabular-nums; } +""" + +SORT_JS = """ +(function () { + function cellValue(td) { + return (td.textContent || "").trim(); + } + + function compareValues(a, b) { + var na = parseFloat(a), nb = parseFloat(b); + var bothNumeric = !isNaN(na) && !isNaN(nb) && /^-?[\\d.]+$/.test(a) && /^-?[\\d.]+$/.test(b); + if (bothNumeric) return na - nb; + return a.localeCompare(b, undefined, { sensitivity: "base" }); + } + + function sortTable(table, columnIndex, ascending) { + var tbody = table.tBodies[0]; + if (!tbody) return; + var rows = Array.prototype.slice.call(tbody.rows); + rows.sort(function (r1, r2) { + var v1 = cellValue(r1.cells[columnIndex]); + var v2 = cellValue(r2.cells[columnIndex]); + var result = compareValues(v1, v2); + return ascending ? result : -result; + }); + rows.forEach(function (row) { tbody.appendChild(row); }); + } + + document.querySelectorAll("table").forEach(function (table) { + var headerRow = table.tHead && table.tHead.rows[0]; + if (!headerRow) return; + Array.prototype.forEach.call(headerRow.cells, function (th, index) { + var arrow = document.createElement("span"); + arrow.className = "sort-arrow"; + th.appendChild(arrow); + + th.addEventListener("click", function () { + var ascending = !th.classList.contains("sort-asc"); + Array.prototype.forEach.call(headerRow.cells, function (other) { + other.classList.remove("sort-asc", "sort-desc"); + }); + th.classList.add(ascending ? "sort-asc" : "sort-desc"); + sortTable(table, index, ascending); + }); + }); + }); +})(); +""" + +TZ_JS = """ +(function () { + function pad(n) { return String(n).padStart(2, "0"); } + + function toLocalRfc3339(utcString) { + var d = new Date(utcString); + if (isNaN(d.getTime())) return utcString; + var offsetMin = -d.getTimezoneOffset(); + var sign = offsetMin >= 0 ? "+" : "-"; + var abs = Math.abs(offsetMin); + var offset = sign + pad(Math.floor(abs / 60)) + ":" + pad(abs % 60); + return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + + "T" + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds()) + offset; + } + + var showingLocal = false; + var button = document.getElementById("tz-toggle"); + if (!button) return; + + button.addEventListener("click", function () { + showingLocal = !showingLocal; + document.querySelectorAll(".ts").forEach(function (el) { + var utc = el.getAttribute("data-utc"); + el.textContent = showingLocal ? toLocalRfc3339(utc) : utc; + }); + button.textContent = showingLocal ? "Show UTC" : "Show local time"; + }); +})(); """ SEVERITY_CLASS = { @@ -177,6 +281,15 @@ def badge_findings_table(html_text): return html_text +RFC3339_UTC_RE = re.compile(r"\b(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)\b") + + +def wrap_timestamps(html_text): + """Wrap RFC 3339 UTC timestamps so the page's local-time toggle can swap + their display between UTC and the viewer's local offset in place.""" + return RFC3339_UTC_RE.sub(r'\1', html_text) + + def find_latest_report(reports_dir): candidates = sorted(glob.glob(os.path.join(reports_dir, "ad_audit_report_*.md"))) if not candidates: @@ -190,6 +303,7 @@ def convert(md_path, out_path): body_html = markdown.markdown(md_text, extensions=["tables", "fenced_code"]) body_html = badge_findings_table(body_html) + body_html = wrap_timestamps(body_html) title = "Active Directory Audit Report" first_heading = re.search(r"^#\s+(.+)$", md_text, re.MULTILINE) @@ -206,8 +320,11 @@ def convert(md_path, out_path):