Sortable HTML tables, local-time toggle, and RFC 3339 dates everywhere
- All timestamps (last logon, password last set, whenCreated, report generation time) now emit as RFC 3339 UTC in both the Markdown report and raw JSON dump, for both the Python and PowerShell scripts. - md_to_html.py: click any table header to sort ascending/descending (vanilla JS, numeric-aware); a "Show local time" toggle swaps every timestamp between UTC and the viewer's local offset, still RFC 3339.
This commit is contained in:
+13
-8
@@ -74,6 +74,11 @@ $UAC_DONT_REQ_PREAUTH = 0x400000
|
|||||||
$now = Get-Date
|
$now = Get-Date
|
||||||
$staleCutoff = $now.AddDays(-$StaleDays)
|
$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)
|
# Object type counts (whole subtree)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -92,7 +97,7 @@ $ous = Get-ADOrganizationalUnit -SearchBase $SearchBase -Filter * -Properties De
|
|||||||
DN = $_.DistinguishedName
|
DN = $_.DistinguishedName
|
||||||
Name = $_.Name
|
Name = $_.Name
|
||||||
Description = $_.Description
|
Description = $_.Description
|
||||||
Created = $_.whenCreated
|
Created = ConvertTo-Rfc3339 $_.whenCreated
|
||||||
Depth = ([regex]::Matches($_.DistinguishedName, "OU=")).Count
|
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)
|
TrustedForDelegation = [bool]($uac -band $UAC_TRUSTED_FOR_DELEGATION)
|
||||||
KerberosPreAuthDisabled = [bool]($uac -band $UAC_DONT_REQ_PREAUTH)
|
KerberosPreAuthDisabled = [bool]($uac -band $UAC_DONT_REQ_PREAUTH)
|
||||||
AdminCount = [bool]($_.adminCount -gt 0)
|
AdminCount = [bool]($_.adminCount -gt 0)
|
||||||
LastLogon = $lastLogon
|
LastLogon = ConvertTo-Rfc3339 $lastLogon
|
||||||
NeverLoggedOn = $neverLoggedOn -and $_.Enabled
|
NeverLoggedOn = $neverLoggedOn -and $_.Enabled
|
||||||
Stale = $stale
|
Stale = $stale
|
||||||
PasswordLastSet = $_.PasswordLastSet
|
PasswordLastSet = ConvertTo-Rfc3339 $_.PasswordLastSet
|
||||||
Created = $_.whenCreated
|
Created = ConvertTo-Rfc3339 $_.whenCreated
|
||||||
GroupCount = (@($_.MemberOf)).Count
|
GroupCount = (@($_.MemberOf)).Count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,9 +153,9 @@ $computers = Get-ADComputer -SearchBase $SearchBase -Filter * -Properties $compP
|
|||||||
OS = $_.OperatingSystem
|
OS = $_.OperatingSystem
|
||||||
OSVersion = $_.OperatingSystemVersion
|
OSVersion = $_.OperatingSystemVersion
|
||||||
Disabled = -not $_.Enabled
|
Disabled = -not $_.Enabled
|
||||||
LastLogon = $lastLogon
|
LastLogon = ConvertTo-Rfc3339 $lastLogon
|
||||||
Stale = $stale
|
Stale = $stale
|
||||||
Created = $_.whenCreated
|
Created = ConvertTo-Rfc3339 $_.whenCreated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +174,7 @@ $groups = Get-ADGroup -SearchBase $SearchBase -Filter * -Properties Description,
|
|||||||
MemberCount = $memberCount
|
MemberCount = $memberCount
|
||||||
Empty = ($memberCount -eq 0)
|
Empty = ($memberCount -eq 0)
|
||||||
Description = $_.Description
|
Description = $_.Description
|
||||||
Created = $_.whenCreated
|
Created = ConvertTo-Rfc3339 $_.whenCreated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +232,7 @@ Add-Line "# Active Directory Audit Report"
|
|||||||
Add-Line ""
|
Add-Line ""
|
||||||
Add-Line "- Search base: ``$SearchBase``"
|
Add-Line "- Search base: ``$SearchBase``"
|
||||||
if ($Server) { Add-Line "- Domain controller: ``$Server``" }
|
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 "- Stale-account threshold: $StaleDays days of inactivity"
|
||||||
Add-Line ""
|
Add-Line ""
|
||||||
|
|
||||||
|
|||||||
+23
-8
@@ -43,6 +43,21 @@ def filetime_to_datetime(value):
|
|||||||
return None
|
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():
|
def parse_args():
|
||||||
p = argparse.ArgumentParser(description=__doc__)
|
p = argparse.ArgumentParser(description=__doc__)
|
||||||
p.add_argument("--host", required=True, help="DC hostname or IP")
|
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),
|
"dn": str(e.distinguishedName),
|
||||||
"name": str(e.ou) if e.ou else "",
|
"name": str(e.ou) if e.ou else "",
|
||||||
"description": str(e.description) if e.description 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="),
|
"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),
|
"trusted_for_delegation": bool(uac & UAC_TRUSTED_FOR_DELEGATION),
|
||||||
"kerberos_preauth_disabled": bool(uac & UAC_DONT_REQ_PREAUTH),
|
"kerberos_preauth_disabled": bool(uac & UAC_DONT_REQ_PREAUTH),
|
||||||
"admin_count": bool(e.adminCount and int(str(e.adminCount)) > 0),
|
"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,
|
"never_logged_on": never_logged_on,
|
||||||
"stale": stale,
|
"stale": stale,
|
||||||
"pwd_last_set": pwd_last_set.isoformat() if pwd_last_set else None,
|
"pwd_last_set": rfc3339(pwd_last_set) if pwd_last_set else None,
|
||||||
"created": str(e.whenCreated) if e.whenCreated else "",
|
"created": rfc3339(e.whenCreated.value) if e.whenCreated else "",
|
||||||
"group_count": len(e.memberOf.values) if e.memberOf else 0,
|
"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": str(e.operatingSystem) if e.operatingSystem else "",
|
||||||
"os_version": str(e.operatingSystemVersion) if e.operatingSystemVersion else "",
|
"os_version": str(e.operatingSystemVersion) if e.operatingSystemVersion else "",
|
||||||
"disabled": disabled,
|
"disabled": disabled,
|
||||||
"last_logon": last_logon.isoformat() if last_logon else None,
|
"last_logon": rfc3339(last_logon) if last_logon else None,
|
||||||
"stale": stale,
|
"stale": stale,
|
||||||
"created": str(e.whenCreated) if e.whenCreated else "",
|
"created": rfc3339(e.whenCreated.value) if e.whenCreated else "",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return computers
|
return computers
|
||||||
@@ -239,7 +254,7 @@ def audit_groups(conn, base_dn):
|
|||||||
"member_count": len(members),
|
"member_count": len(members),
|
||||||
"empty": len(members) == 0,
|
"empty": len(members) == 0,
|
||||||
"description": str(e.description) if e.description 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 "",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return groups
|
return groups
|
||||||
@@ -428,7 +443,7 @@ def build_report(data, args):
|
|||||||
a(f"")
|
a(f"")
|
||||||
a(f"- Base DN: `{args.base_dn}`")
|
a(f"- Base DN: `{args.base_dn}`")
|
||||||
a(f"- Domain Controller: `{args.host}`")
|
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"- Stale-account threshold: {args.stale_days} days of inactivity")
|
||||||
a(f"")
|
a(f"")
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,21 @@ th {
|
|||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
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); }
|
tbody tr:hover { background: var(--panel); }
|
||||||
ul { padding-left: 1.3rem; }
|
ul { padding-left: 1.3rem; }
|
||||||
li { margin: 0.2rem 0; }
|
li { margin: 0.2rem 0; }
|
||||||
@@ -142,6 +156,96 @@ li { margin: 0.2rem 0; }
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-size: 0.85rem;
|
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 = {
|
SEVERITY_CLASS = {
|
||||||
@@ -177,6 +281,15 @@ def badge_findings_table(html_text):
|
|||||||
return 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'<span class="ts" data-utc="\1">\1</span>', html_text)
|
||||||
|
|
||||||
|
|
||||||
def find_latest_report(reports_dir):
|
def find_latest_report(reports_dir):
|
||||||
candidates = sorted(glob.glob(os.path.join(reports_dir, "ad_audit_report_*.md")))
|
candidates = sorted(glob.glob(os.path.join(reports_dir, "ad_audit_report_*.md")))
|
||||||
if not candidates:
|
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 = markdown.markdown(md_text, extensions=["tables", "fenced_code"])
|
||||||
body_html = badge_findings_table(body_html)
|
body_html = badge_findings_table(body_html)
|
||||||
|
body_html = wrap_timestamps(body_html)
|
||||||
|
|
||||||
title = "Active Directory Audit Report"
|
title = "Active Directory Audit Report"
|
||||||
first_heading = re.search(r"^#\s+(.+)$", md_text, re.MULTILINE)
|
first_heading = re.search(r"^#\s+(.+)$", md_text, re.MULTILINE)
|
||||||
@@ -206,8 +320,11 @@ def convert(md_path, out_path):
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
|
<button id="tz-toggle" class="tz-toggle" type="button">Show local time</button>
|
||||||
{body_html}
|
{body_html}
|
||||||
</div>
|
</div>
|
||||||
|
<script>{SORT_JS}</script>
|
||||||
|
<script>{TZ_JS}</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user