Final review pass: fix STARTTLS fallback, PS null-count bugs, badge scoping; add README
Ran a full review pass over both audit scripts and the HTML converter, independently cross-checked against an automated code review, and fixed everything that survived verification: - ad_audit.py: connect() ignored the boolean return of conn.start_tls(). ldap3 doesn't raise on STARTTLS failure by default, so a rejected/ downgraded STARTTLS silently fell through to an unencrypted bind -- the bind DN and password would go out in cleartext with no warning. Now aborts before calling bind() if start_tls() returns False. - ad_audit.py: "Enabled accounts that have never logged on" detail rows weren't filtering out disabled accounts, unlike the count used to decide whether to show the finding at all -- so a disabled account could appear in a list titled "enabled accounts." - ad_audit.py: replaced deprecated datetime.datetime.utcnow() (scheduled for removal) with timezone-aware datetime.now(timezone.utc); made FILETIME_EPOCH aware too so the stale-account comparison keeps working without a naive/aware TypeError. - ad_audit.py: removed dead code (unused defaultdict import, unused UAC_NOT_DELEGATED constant, unused scope_bit variable, import os buried inside main() instead of at module level). - Invoke-ADAudit.ps1: GroupCount/MemberCount used (@($x)).Count, which PowerShell evaluates to 1 (not 0) when $x is $null -- @($null) is a one-element array, not an empty one. This silently broke "empty group" detection (the report's own headline feature) for any group with genuinely zero members, and inflated GroupCount for users with no group memberships. Added Get-SafeCount to null-check before counting. - Invoke-ADAudit.ps1: $maxDepth came back as $null (not 0) when a SearchBase had zero OUs, printing a blank instead of "0" in the report. - Invoke-ADAudit.ps1 / ad_audit.py: the h4 finding-detail title wasn't escaped through ConvertTo-MdSafe/md_escape in one of its three occurrences, inconsistent with the other two -- fixed for consistency even though today's titles are all fixed strings. - md_to_html.py: badge_findings_table()'s regex matched any single-word table cell equal to a severity name, not just the Severity column of the findings table -- an OS name, group name, or object class that happened to literally be "Critical"/"High"/etc (plausible under a tiering naming scheme) would get rewritten into a colored badge anywhere in the report. Rescoped to the one table whose header row is literally "Severity | Finding | Count | Notes", which only this generator ever emits. - Added README.md covering both scripts' usage, what each collects, and a documented known limitation (finding-detection logic is duplicated between the Python and PowerShell implementations with no shared source of truth). Every fix verified with a reproduction before and after: a mocked start_tls() failure confirms bind() is never reached; mocked $null MemberOf/Members confirm counts are now 0; a zero-OU domain confirms "Maximum nesting depth: 0" instead of blank; a disabled never-logged-on account confirms it's excluded from the enabled-accounts finding; and AD data literally named "Critical"/"High" confirms it no longer gets badged outside the real findings table.
This commit is contained in:
@@ -0,0 +1,110 @@
|
|||||||
|
# ad-probe
|
||||||
|
|
||||||
|
Read-only Active Directory structure/health audit. Two equivalent
|
||||||
|
implementations depending on what access you have to the target domain,
|
||||||
|
plus a converter for turning the report into a shareable HTML page.
|
||||||
|
|
||||||
|
- **`src/ad_audit.py`** — queries over raw LDAP (`ldap3`) with a basic,
|
||||||
|
non-admin bind account. Works from any Linux/macOS/Windows box with
|
||||||
|
network access to a DC; no RSAT or domain membership required.
|
||||||
|
- **`src/Invoke-ADAudit.ps1`** — the same audit using the `ActiveDirectory`
|
||||||
|
PowerShell module (RSAT). Run it on a domain-joined machine, typically
|
||||||
|
under your own logon.
|
||||||
|
- **`src/md_to_html.py`** — renders either script's Markdown report as a
|
||||||
|
single self-contained, styled HTML file (sortable tables, sidebar TOC,
|
||||||
|
local-time toggle, severity-coded findings).
|
||||||
|
|
||||||
|
Neither audit script writes to the directory or requires elevated rights —
|
||||||
|
they only read what a standard authenticated user/account can already see.
|
||||||
|
|
||||||
|
## What it collects
|
||||||
|
|
||||||
|
- **Object type counts** across the whole subtree
|
||||||
|
- **OUs** — full list, nesting depth
|
||||||
|
- **Users** — enabled/disabled, locked out, password-never-expires,
|
||||||
|
password-not-required (blank password allowed), never-logged-on, stale
|
||||||
|
(configurable inactivity threshold), `adminCount=1`, unconstrained
|
||||||
|
delegation, AS-REP roastable (Kerberos pre-auth disabled)
|
||||||
|
- **Computers** — enabled/disabled, stale, OS breakdown
|
||||||
|
- **Groups** — security vs. distribution, scope (domain-local/global/
|
||||||
|
universal), member counts, empty groups, largest groups
|
||||||
|
|
||||||
|
Each report opens with an **Executive Summary**: headline counts plus a
|
||||||
|
severity-ranked (Critical → Info) **Risk & Cleanup Findings** table, with
|
||||||
|
every finding expanding into the actual list of matching accounts/
|
||||||
|
computers/groups — not just a count — so it's actionable straight out of
|
||||||
|
the box.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Python / LDAP
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
python3 src/ad_audit.py \
|
||||||
|
--host dc01.corp.example.com \
|
||||||
|
--base-dn "DC=corp,DC=example,DC=com" \
|
||||||
|
[--ssl | --starttls] \
|
||||||
|
[--stale-days 90] \
|
||||||
|
[--out-dir reports]
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll be prompted for a bind DN and password at runtime; nothing is
|
||||||
|
stored on disk. Pass `--no-verify-cert` for a DC with a self-signed
|
||||||
|
certificate. Writes `ad_audit_report_<timestamp>.md` and
|
||||||
|
`ad_audit_raw_<timestamp>.json` to `--out-dir` (default `reports/`).
|
||||||
|
|
||||||
|
### PowerShell / RSAT
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\src\Invoke-ADAudit.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs under your current logon by default. Optional parameters:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\src\Invoke-ADAudit.ps1 `
|
||||||
|
-Server dc01.corp.example.com `
|
||||||
|
-SearchBase "OU=Corp,DC=corp,DC=example,DC=com" `
|
||||||
|
-StaleDays 120 `
|
||||||
|
-Credential (Get-Credential) `
|
||||||
|
-OutDir .\reports
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires the `ActiveDirectory` module (RSAT):
|
||||||
|
`Add-WindowsCapability -Online -Name 'Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0'`
|
||||||
|
|
||||||
|
### HTML report
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 src/md_to_html.py # newest report in reports/
|
||||||
|
python3 src/md_to_html.py reports/ad_audit_report_....md -o out.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Produces one `.html` file with no external dependencies — click any table
|
||||||
|
header to sort, use the sidebar to jump to a section or an individual
|
||||||
|
finding, and toggle timestamps between UTC and your local offset (both
|
||||||
|
RFC 3339 either way).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All timestamps in every report (Markdown, JSON, HTML) are RFC 3339 UTC
|
||||||
|
(`YYYY-MM-DDTHH:MM:SSZ`).
|
||||||
|
- `reports/` is gitignored — audit output contains real directory data and
|
||||||
|
should never be committed.
|
||||||
|
- AD attribute values (descriptions, sAMAccountName, OS strings, DNs) are
|
||||||
|
directory content that could be attacker-influenced, not
|
||||||
|
report-generated text. Both audit scripts escape them before they reach
|
||||||
|
a Markdown table cell, and `md_to_html.py` independently neutralizes any
|
||||||
|
raw HTML in its input as defense-in-depth — so a stray `<script>` in an
|
||||||
|
OU description renders as inert text, not a live tag.
|
||||||
|
|
||||||
|
## Known limitation
|
||||||
|
|
||||||
|
The Risk & Cleanup Findings logic (which conditions count as a finding,
|
||||||
|
their thresholds, titles, and severities) is implemented twice — once in
|
||||||
|
`ad_audit.py`, once in `Invoke-ADAudit.ps1` — since they're two different
|
||||||
|
languages with no shared runtime. There's no single source of truth, so a
|
||||||
|
fix or new finding added to one script has to be hand-ported to the other;
|
||||||
|
check both when changing what counts as a finding.
|
||||||
+16
-4
@@ -79,6 +79,17 @@ function ConvertTo-Rfc3339($DateTime) {
|
|||||||
return $DateTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
return $DateTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# @($null).Count is 1, not 0 -- PowerShell wraps $null as a one-element array
|
||||||
|
# rather than an empty one. A multi-valued AD property with zero values comes
|
||||||
|
# back as $null (not an empty array), so counting it via @(...).Count directly
|
||||||
|
# silently reports 1 instead of 0. This breaks "empty group" detection for any
|
||||||
|
# group with truly zero members, and inflates GroupCount for users with no
|
||||||
|
# group memberships.
|
||||||
|
function Get-SafeCount($Value) {
|
||||||
|
if ($null -eq $Value) { return 0 }
|
||||||
|
return @($Value).Count
|
||||||
|
}
|
||||||
|
|
||||||
# AD attributes (descriptions, sAMAccountName, OS strings, DNs, ...) are
|
# AD attributes (descriptions, sAMAccountName, OS strings, DNs, ...) are
|
||||||
# directory content, not report-generated text -- they can contain anything
|
# directory content, not report-generated text -- they can contain anything
|
||||||
# a writer or an attacker put there, including raw HTML. Markdown doesn't
|
# a writer or an attacker put there, including raw HTML. Markdown doesn't
|
||||||
@@ -150,7 +161,7 @@ $users = Get-ADUser -SearchBase $SearchBase -Filter * -Properties $userProps @ad
|
|||||||
Stale = $stale
|
Stale = $stale
|
||||||
PasswordLastSet = ConvertTo-Rfc3339 $_.PasswordLastSet
|
PasswordLastSet = ConvertTo-Rfc3339 $_.PasswordLastSet
|
||||||
Created = ConvertTo-Rfc3339 $_.whenCreated
|
Created = ConvertTo-Rfc3339 $_.whenCreated
|
||||||
GroupCount = (@($_.MemberOf)).Count
|
GroupCount = Get-SafeCount $_.MemberOf
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +193,7 @@ $computers = Get-ADComputer -SearchBase $SearchBase -Filter * -Properties $compP
|
|||||||
Write-Host " - groups..."
|
Write-Host " - groups..."
|
||||||
$groups = Get-ADGroup -SearchBase $SearchBase -Filter * -Properties Description, whenCreated, Members, GroupCategory, GroupScope @adParams |
|
$groups = Get-ADGroup -SearchBase $SearchBase -Filter * -Properties Description, whenCreated, Members, GroupCategory, GroupScope @adParams |
|
||||||
ForEach-Object {
|
ForEach-Object {
|
||||||
$memberCount = (@($_.Members)).Count
|
$memberCount = Get-SafeCount $_.Members
|
||||||
[PSCustomObject]@{
|
[PSCustomObject]@{
|
||||||
DN = $_.DistinguishedName
|
DN = $_.DistinguishedName
|
||||||
SamAccountName = $_.SamAccountName
|
SamAccountName = $_.SamAccountName
|
||||||
@@ -199,7 +210,7 @@ $groups = Get-ADGroup -SearchBase $SearchBase -Filter * -Properties Description,
|
|||||||
# Write raw data
|
# Write raw data
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir | Out-Null }
|
if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir | Out-Null }
|
||||||
$ts = Get-Date -Format "yyyyMMdd_HHmmss"
|
$ts = $now.ToUniversalTime().ToString("yyyyMMdd_HHmmss")
|
||||||
|
|
||||||
$raw = [PSCustomObject]@{
|
$raw = [PSCustomObject]@{
|
||||||
ObjectCounts = $objectCounts
|
ObjectCounts = $objectCounts
|
||||||
@@ -219,6 +230,7 @@ Write-Host " - building report..."
|
|||||||
# Aggregate stats up front so the executive summary and the detailed
|
# Aggregate stats up front so the executive summary and the detailed
|
||||||
# sections below both draw from the same computed values.
|
# sections below both draw from the same computed values.
|
||||||
$maxDepth = ($ous | Measure-Object -Property Depth -Maximum).Maximum
|
$maxDepth = ($ous | Measure-Object -Property Depth -Maximum).Maximum
|
||||||
|
if ($null -eq $maxDepth) { $maxDepth = 0 }
|
||||||
|
|
||||||
$totalUsers = $users.Count
|
$totalUsers = $users.Count
|
||||||
$disabled = ($users | Where-Object Disabled).Count
|
$disabled = ($users | Where-Object Disabled).Count
|
||||||
@@ -324,7 +336,7 @@ if ($findings.Count -gt 0) {
|
|||||||
Add-Line "### Finding Detail Lists"
|
Add-Line "### Finding Detail Lists"
|
||||||
Add-Line ""
|
Add-Line ""
|
||||||
foreach ($f in $findings) {
|
foreach ($f in $findings) {
|
||||||
Add-Line "#### [$($f.Severity)] $($f.Title)"
|
Add-Line "#### [$($f.Severity)] $(ConvertTo-MdSafe $f.Title)"
|
||||||
Add-Line ""
|
Add-Line ""
|
||||||
Add-Line "| $(ConvertTo-MdSafe $f.Cols[0]) | $(ConvertTo-MdSafe $f.Cols[1]) | $(ConvertTo-MdSafe $f.Cols[2]) |"
|
Add-Line "| $(ConvertTo-MdSafe $f.Cols[0]) | $(ConvertTo-MdSafe $f.Cols[1]) | $(ConvertTo-MdSafe $f.Cols[2]) |"
|
||||||
Add-Line "|---|---|---|"
|
Add-Line "|---|---|---|"
|
||||||
|
|||||||
+12
-13
@@ -11,9 +11,10 @@ import argparse
|
|||||||
import datetime
|
import datetime
|
||||||
import getpass
|
import getpass
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter
|
||||||
|
|
||||||
from ldap3 import ALL, SUBTREE, Connection, Server, Tls
|
from ldap3 import ALL, SUBTREE, Connection, Server, Tls
|
||||||
import ssl
|
import ssl
|
||||||
@@ -25,10 +26,9 @@ UAC_PASSWD_NOTREQD = 0x0020
|
|||||||
UAC_DONT_EXPIRE_PASSWD = 0x10000
|
UAC_DONT_EXPIRE_PASSWD = 0x10000
|
||||||
UAC_SMARTCARD_REQUIRED = 0x40000
|
UAC_SMARTCARD_REQUIRED = 0x40000
|
||||||
UAC_TRUSTED_FOR_DELEGATION = 0x80000
|
UAC_TRUSTED_FOR_DELEGATION = 0x80000
|
||||||
UAC_NOT_DELEGATED = 0x100000
|
|
||||||
UAC_DONT_REQ_PREAUTH = 0x400000
|
UAC_DONT_REQ_PREAUTH = 0x400000
|
||||||
|
|
||||||
FILETIME_EPOCH = datetime.datetime(1601, 1, 1)
|
FILETIME_EPOCH = datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def filetime_to_datetime(value):
|
def filetime_to_datetime(value):
|
||||||
@@ -108,7 +108,9 @@ def connect(args):
|
|||||||
conn = Connection(server, user=bind_dn, password=password, auto_bind=False)
|
conn = Connection(server, user=bind_dn, password=password, auto_bind=False)
|
||||||
if args.starttls:
|
if args.starttls:
|
||||||
conn.open()
|
conn.open()
|
||||||
conn.start_tls()
|
if not conn.start_tls():
|
||||||
|
print(f"STARTTLS negotiation failed, aborting rather than binding in cleartext: {conn.result}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
if not conn.bind():
|
if not conn.bind():
|
||||||
print(f"Bind failed: {conn.result}", file=sys.stderr)
|
print(f"Bind failed: {conn.result}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -171,7 +173,7 @@ def audit_users(conn, base_dn, stale_days):
|
|||||||
]
|
]
|
||||||
entries = paged_search(conn, base_dn, "(&(objectCategory=person)(objectClass=user))", attrs)
|
entries = paged_search(conn, base_dn, "(&(objectCategory=person)(objectClass=user))", attrs)
|
||||||
|
|
||||||
now = datetime.datetime.utcnow()
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
stale_cutoff = now - datetime.timedelta(days=stale_days)
|
stale_cutoff = now - datetime.timedelta(days=stale_days)
|
||||||
|
|
||||||
users = []
|
users = []
|
||||||
@@ -222,7 +224,7 @@ def audit_computers(conn, base_dn, stale_days):
|
|||||||
]
|
]
|
||||||
entries = paged_search(conn, base_dn, "(objectCategory=computer)", attrs)
|
entries = paged_search(conn, base_dn, "(objectCategory=computer)", attrs)
|
||||||
|
|
||||||
now = datetime.datetime.utcnow()
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
stale_cutoff = now - datetime.timedelta(days=stale_days)
|
stale_cutoff = now - datetime.timedelta(days=stale_days)
|
||||||
|
|
||||||
computers = []
|
computers = []
|
||||||
@@ -255,7 +257,6 @@ def audit_groups(conn, base_dn):
|
|||||||
for e in entries:
|
for e in entries:
|
||||||
gt = int(str(e.groupType)) if e.groupType else 0
|
gt = int(str(e.groupType)) if e.groupType else 0
|
||||||
is_security = bool(gt & 0x80000000)
|
is_security = bool(gt & 0x80000000)
|
||||||
scope_bit = gt & 0x0C
|
|
||||||
if gt & 0x00000002:
|
if gt & 0x00000002:
|
||||||
scope = "DomainLocal"
|
scope = "DomainLocal"
|
||||||
elif gt & 0x00000004:
|
elif gt & 0x00000004:
|
||||||
@@ -405,7 +406,7 @@ def build_executive_summary(data, stats, args):
|
|||||||
if u["never_logged_on"]:
|
if u["never_logged_on"]:
|
||||||
findings.append(("Low", "Enabled accounts that have never logged on",
|
findings.append(("Low", "Enabled accounts that have never logged on",
|
||||||
"Possibly unused/orphaned provisioning; verify before disabling",
|
"Possibly unused/orphaned provisioning; verify before disabling",
|
||||||
USER_COLS, user_rows(lambda x: x["never_logged_on"])))
|
USER_COLS, user_rows(lambda x: x["never_logged_on"] and not x["disabled"])))
|
||||||
if u["admin_count_flagged"]:
|
if u["admin_count_flagged"]:
|
||||||
findings.append(("Info", "Accounts with adminCount=1 (current or former privileged)",
|
findings.append(("Info", "Accounts with adminCount=1 (current or former privileged)",
|
||||||
"SDProp-protected ACLs persist even after privilege is removed; review membership",
|
"SDProp-protected ACLs persist even after privilege is removed; review membership",
|
||||||
@@ -435,7 +436,7 @@ def build_executive_summary(data, stats, args):
|
|||||||
a("### Finding Detail Lists")
|
a("### Finding Detail Lists")
|
||||||
a("")
|
a("")
|
||||||
for severity, title, notes, cols, rows in findings:
|
for severity, title, notes, cols, rows in findings:
|
||||||
a(f"#### [{severity}] {title}")
|
a(f"#### [{severity}] {md_escape(title)}")
|
||||||
a("")
|
a("")
|
||||||
a(f"| {md_escape(cols[0])} | {md_escape(cols[1])} | {md_escape(cols[2])} |")
|
a(f"| {md_escape(cols[0])} | {md_escape(cols[1])} | {md_escape(cols[2])} |")
|
||||||
a("|---|---|---|")
|
a("|---|---|---|")
|
||||||
@@ -464,7 +465,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: {rfc3339(datetime.datetime.utcnow())}")
|
a(f"- Generated: {rfc3339(datetime.datetime.now(datetime.timezone.utc))}")
|
||||||
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"")
|
||||||
|
|
||||||
@@ -582,10 +583,8 @@ def main():
|
|||||||
|
|
||||||
conn.unbind()
|
conn.unbind()
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
os.makedirs(args.out_dir, exist_ok=True)
|
os.makedirs(args.out_dir, exist_ok=True)
|
||||||
ts = datetime.datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
raw_path = os.path.join(args.out_dir, f"ad_audit_raw_{ts}.json")
|
raw_path = os.path.join(args.out_dir, f"ad_audit_raw_{ts}.json")
|
||||||
with open(raw_path, "w") as f:
|
with open(raw_path, "w") as f:
|
||||||
|
|||||||
+36
-17
@@ -335,27 +335,46 @@ SEVERITY_CLASS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
FINDINGS_TABLE_RE = re.compile(
|
||||||
|
r"(<table>\s*<thead>\s*<tr>\s*<th>Severity</th>\s*<th>Finding</th>"
|
||||||
|
r"\s*<th>Count</th>\s*<th>Notes</th>\s*</tr>\s*</thead>\s*<tbody>)(.*?)(</tbody>\s*</table>)",
|
||||||
|
re.S,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def badge_findings_table(html_text):
|
def badge_findings_table(html_text):
|
||||||
"""Wrap bare 'Critical'/'High'/... cell text with colored badge spans,
|
"""Wrap bare 'Critical'/'High'/... cell text with colored badge spans.
|
||||||
but only inside table cells (so words like "Critical" in prose stay plain)."""
|
|
||||||
|
|
||||||
def replace_cell(match):
|
Scoped to the one table whose header row is literally "Severity |
|
||||||
cell_html = match.group(0)
|
Finding | Count | Notes" -- only our own report generator ever emits
|
||||||
|
that exact header, so this can't collide with AD-controlled data
|
||||||
|
(an OS name, group name, or object class) that happens to equal one of
|
||||||
|
these words elsewhere in the report; several other tables also put
|
||||||
|
AD data in column 1, so matching by column position alone isn't
|
||||||
|
sufficient to disambiguate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def badge_row_start(match, severity, css_class):
|
||||||
|
return re.sub(
|
||||||
|
rf"(<tr>\s*<td>){severity}(</td>)",
|
||||||
|
rf'\1<span class="badge {css_class}">{severity}</span>\2',
|
||||||
|
match,
|
||||||
|
)
|
||||||
|
|
||||||
|
def replace_findings_table(match):
|
||||||
|
head, body, tail = match.group(1), match.group(2), match.group(3)
|
||||||
for severity, css_class in SEVERITY_CLASS.items():
|
for severity, css_class in SEVERITY_CLASS.items():
|
||||||
cell_html = re.sub(
|
body = badge_row_start(body, severity, css_class)
|
||||||
rf"(<td>){severity}(</td>)",
|
return head + body + tail
|
||||||
rf'\1<span class="badge {css_class}">{severity}</span>\2',
|
|
||||||
cell_html,
|
|
||||||
)
|
|
||||||
cell_html = re.sub(
|
|
||||||
rf"(<h4[^>]*>)\[{severity}\]\s*",
|
|
||||||
rf'\1<span class="badge {css_class}">{severity}</span> ',
|
|
||||||
cell_html,
|
|
||||||
)
|
|
||||||
return cell_html
|
|
||||||
|
|
||||||
html_text = re.sub(r"<td>\w+</td>", replace_cell, html_text)
|
html_text = FINDINGS_TABLE_RE.sub(replace_findings_table, html_text)
|
||||||
html_text = re.sub(r"<h4[^>]*>\[\w+\][^<]*", replace_cell, html_text)
|
|
||||||
|
for severity, css_class in SEVERITY_CLASS.items():
|
||||||
|
html_text = re.sub(
|
||||||
|
rf"(<h4[^>]*>)\[{severity}\]\s*",
|
||||||
|
rf'\1<span class="badge {css_class}">{severity}</span> ',
|
||||||
|
html_text,
|
||||||
|
)
|
||||||
return html_text
|
return html_text
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user