Skip to content

Instantly share code, notes, and snippets.

@gitgotgitgotit
Last active August 2, 2026 10:52
Show Gist options
  • Select an option

  • Save gitgotgitgotit/daf99e40e150930609fb63633b363e73 to your computer and use it in GitHub Desktop.

Select an option

Save gitgotgitgotit/daf99e40e150930609fb63633b363e73 to your computer and use it in GitHub Desktop.
Group3r log to HTML parser
#!/usr/bin/env python3
"""
group3r_report.py — turn a Group3r text log (-f group3r.log) into a single,
self-contained, searchable/filterable HTML triage dashboard.
Why this exists: Group3r's own "pretty mode" was removed by the author
(see README - it used to truncate data and was a pain to maintain), and the
one community viewer (Group3rExplorer) is interactive-only, needs pinned
old pandas/plotly, and splits results across N separate treemap HTML files.
This is a single static file, zero dependencies, safe to run on an
air-gapped attack box, and sorts the worst GPOs to the top instead of
leaving you to scroll for them.
Have fun :)
Usage:
python3 group3r_report.py -i group3r.log -o report.html
python3 group3r_report.py --demo -o demo_report.html # try it with sample data
No external packages required (stdlib only). No network calls.
FORMAT NOTE:
The table shapes this parser expects have been verified directly against
Group3r's own vendored formatter source (Group3r/View/NiceGpoPrinter.cs and
ConsoleTables.cs) - by compiling that exact code and generating a real
sample log with it, rather than guessing from a third-party viewer. That
exercise caught and fixed several mismatches versus an earlier draft: the
GPO name cell also carries the GUID/status text and needs cleaning, the
Setting header's two columns are policy-scope-then-type (not the reverse),
every table's markdown "|---|---|" divider row needs to be skipped, GPO
links aren't always OU-shaped (domain-root/site links have no "OU="), and
a word-wrapped Reason/Detail can span several table rows. The parser is
still deliberately defensive beyond that: anything it doesn't recognise is
preserved verbatim in a "Parser notes" section instead of being silently
dropped, and if it can't find ANY GPO blocks at all it falls back to a
raw-text view rather than showing you an empty, falsely-reassuring
dashboard. Different Group3r builds/forks can still drift from this, so
if the shape is ever off, the raw fallback + parser notes should make it
quick to see why.
"""
import argparse
import html
import json
import re
import sys
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Optional
# --------------------------------------------------------------------------
# Severity model
# --------------------------------------------------------------------------
SEVERITY_ORDER = {"BLACK": 4, "RED": 3, "YELLOW": 2, "GREEN": 1, "UNKNOWN": 0}
SEVERITY_ALIASES = {
"BLACK": "BLACK", "CRITICAL": "BLACK", "CRIT": "BLACK",
"RED": "RED", "HIGH": "RED",
"YELLOW": "YELLOW", "MEDIUM": "YELLOW", "AMBER": "YELLOW", "ORANGE": "YELLOW",
"GREEN": "GREEN", "LOW": "GREEN", "INFO": "GREEN", "INFORMATIONAL": "GREEN",
}
def normalize_severity(raw: str) -> str:
if not raw:
return "UNKNOWN"
return SEVERITY_ALIASES.get(raw.strip().upper(), "UNKNOWN")
# --------------------------------------------------------------------------
# Data model
# --------------------------------------------------------------------------
@dataclass
class Finding:
severity: str
reason: str
detail: str
@dataclass
class Setting:
setting_type: str
policy_type: str
fields: list # list of [label, value]
findings: list # list[Finding]
raw: str # verbatim fallback
def max_severity_rank(self):
if not self.findings:
return 0
return max(SEVERITY_ORDER[f.severity] for f in self.findings)
@dataclass
class GPOEntry:
name: str
guid: str
status: str # Current / Morphed / Unknown
linked_ous: list
info: dict
settings: list # list[Setting]
def all_findings(self):
out = []
for s in self.settings:
out.extend(s.findings)
return out
def max_severity_rank(self):
f = self.all_findings()
if not f:
return 0
return max(SEVERITY_ORDER[x.severity] for x in f)
# --------------------------------------------------------------------------
# Parsing helpers
# --------------------------------------------------------------------------
GUID_RE = re.compile(r"\{[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}")
SENTINEL_TAIL_RE = re.compile(r"^\\_+\s*$") # the "\___" tail that precedes an indented Setting/Finding block.
# NOTE: intentionally anchored with NO leading-whitespace allowance. Verified against
# Group3r's real formatter (NiceGpoPrinter.IndentPara): the tail for a top-level Setting
# is emitted with zero indent, while a Finding's tail is indented. Only matching the
# zero-indent form means this only fires between two top-level Settings (which is when
# it's actually safe to close one) and never misfires mid-Setting when a Finding's own
# tail goes by - don't "fix" this to allow leading whitespace, it would break that.
DIVIDER_CELL_RE = re.compile(r"^-+$")
def split_pipe_row(line: str):
"""'| a | b |' -> ['a', 'b']. Returns None if the line isn't a pipe row."""
s = line.strip()
if not s.startswith("|"):
return None
inner = s.strip("|")
return [c.strip() for c in inner.split("|")]
def is_divider_row(cells) -> bool:
"""True for the markdown '|---|---|' separator row that ConsoleTables'
ToMarkDownString() emits after every table header (GPO table, Setting
table, and Finding table alike) - confirmed straight from Group3r's own
vendored ConsoleTables.cs. Without filtering these out they show up as
bogus fields/info rows full of dashes."""
if not cells:
return False
stripped = [c.strip() for c in cells]
return all(s != "" and DIVIDER_CELL_RE.match(s) for s in stripped)
def parse_finding_row(line: str) -> Optional[Finding]:
cells = split_pipe_row(line)
if not cells:
return None
# Walk the cells looking for the Finding / Reason / Detail labels rather
# than assuming a fixed column count — real content can itself contain
# "|" characters (script args, paths), which breaks a naive fixed regex.
#
# Group3r word-wraps any cell value over 80 chars across additional table
# rows with a blank label (its TableAdd() helper). That means Reason and
# Detail can each legitimately span several cells, not just one, so a mode
# stays "sticky" (keeps accumulating) until the NEXT label cell appears,
# rather than resetting after a single value. Blank continuation-row
# labels are just skipped, not treated as new content.
mode = None
parts = {"severity": [], "reason": [], "detail": []}
for cell in cells:
up = cell.strip().upper()
if up == "FINDING":
mode = "severity"
continue
if up == "REASON":
mode = "reason"
continue
if up == "DETAIL":
mode = "detail"
continue
if mode and cell.strip():
parts[mode].append(cell.strip())
severity = parts["severity"][0] if parts["severity"] else ""
reason = " ".join(parts["reason"])
detail = " ".join(parts["detail"])
if not (severity or reason or detail):
return None
return Finding(severity=normalize_severity(severity), reason=reason, detail=detail)
def parse_setting_block(lines):
"""lines: raw lines from '| Setting - ...' up to (excluding) the '\\___' tail.
Real Group3r column order (verified against the actual ConsoleTables-based
formatter in Group3r/View/NiceGpoPrinter.cs, not guessed): column 1 is
"Setting - " + the POLICY scope (e.g. "Computer Policy", "User Policy",
"Package Policy", optionally suffixed "- Morphed"); column 2 is the short
SETTING type (e.g. "Script", "Registry", "Package"). That's the reverse
of a naive left-to-right reading, so it's assigned accordingly below.
"""
header = lines[0] if lines else ""
header_cells = split_pipe_row(header) or []
setting_type, policy_type = "", ""
if header_cells:
first = header_cells[0]
m = re.match(r"Setting\s*-\s*(.*)", first, re.IGNORECASE)
policy_type = m.group(1).strip() if m else first.strip()
if len(header_cells) > 1:
setting_type = header_cells[1].strip()
fields = []
findings = []
cur_finding_lines = []
def flush_finding():
if cur_finding_lines:
merged_cells = []
for fl in cur_finding_lines:
c = split_pipe_row(fl)
if not c or is_divider_row(c):
continue
merged_cells.extend(c)
# rebuild a synthetic row for parse_finding_row
synthetic = "| " + " | ".join(merged_cells) + " |"
f = parse_finding_row(synthetic)
if f:
findings.append(f)
for raw_line in lines[1:]:
cells = split_pipe_row(raw_line)
if cells is None:
continue
if is_divider_row(cells):
continue
first_up = cells[0].strip().upper() if cells else ""
if first_up == "FINDING":
flush_finding()
cur_finding_lines = [raw_line]
continue
if cur_finding_lines:
cur_finding_lines.append(raw_line)
continue
# otherwise: a plain content/body row
non_empty = [c for c in cells if c != ""]
if len(cells) >= 2:
label = cells[0]
value = " | ".join(c for c in cells[1:] if c != "")
fields.append([label, value])
elif non_empty:
fields.append(["", non_empty[0]])
flush_finding()
return Setting(
setting_type=setting_type or "(unlabelled setting)",
policy_type=policy_type,
fields=fields,
findings=findings,
raw="\n".join(lines),
)
def parse_gpo_header(lines):
name = "(unnamed GPO)"
guid = ""
status = "Unknown"
linked_ous = []
info = {}
unparsed = []
joined = "\n".join(lines)
g = GUID_RE.search(joined)
if g:
guid = g.group(0)
if re.search(r"\bmorph", joined, re.IGNORECASE):
status = "Morphed"
elif re.search(r"\bcurrent\b", joined, re.IGNORECASE):
status = "Current"
got_name = False
for raw_line in lines:
cells = split_pipe_row(raw_line)
if not cells:
continue
if is_divider_row(cells):
continue
label = cells[0].strip().upper()
rest = [c for c in cells[1:] if c != ""]
if label == "GPO" and not got_name:
if rest:
# Real Group3r combines "DisplayName {GUID} Current/Morphed"
# into this single cell (verified against NiceGpoPrinter.cs),
# rather than putting the GUID/status on their own rows. Strip
# both back out so the displayed name doesn't just duplicate
# the separate GUID/status badges already shown alongside it.
candidate = rest[0]
cleaned = GUID_RE.sub("", candidate)
cleaned = re.sub(r"\b(Current|Morphed)\b\s*$", "", cleaned, flags=re.IGNORECASE)
cleaned = cleaned.strip()
name = cleaned if cleaned else candidate.strip()
got_name = True
continue
# A row labelled exactly "Link" is always a GPO link in real output -
# its target may be an OU, the domain root, or a site, so don't gate
# this on an "OU=" substring (that silently dropped/overwrote
# non-OU links). The "OU="/"OUS"/"OU"/"LINKED" checks are kept as a
# fallback for other log shapes.
if label == "LINK" or "OU=" in raw_line.upper() or "LINKED" in label or label in ("OUS", "OU"):
linked_ous.extend([r for r in rest if r])
continue
if rest:
info[cells[0].strip() or "(row)"] = " | ".join(rest)
else:
unparsed.append(raw_line.strip())
if unparsed:
info["_unparsed_rows"] = unparsed
return GPOEntry(name=name, guid=guid, status=status, linked_ous=linked_ous, info=info, settings=[])
def parse_log(text: str):
lines = text.splitlines()
gpos = []
parse_warnings = []
i = 0
n = len(lines)
in_gpo = False
header_lines = []
settings_lines_stack = None # None or list of lines of current setting
current_settings = []
def start_gpo():
nonlocal in_gpo, header_lines, current_settings, settings_lines_stack
in_gpo = True
header_lines = []
current_settings = []
settings_lines_stack = None
def flush_setting():
nonlocal settings_lines_stack
if settings_lines_stack:
current_settings.append(parse_setting_block(settings_lines_stack))
settings_lines_stack = None
def flush_gpo():
nonlocal in_gpo
flush_setting()
gpo = parse_gpo_header(header_lines)
gpo.settings = list(current_settings)
gpos.append(gpo)
in_gpo = False
while i < n:
line = lines[i]
stripped = line.strip()
if "[Finish]" in stripped and not stripped.startswith("|"):
break
if stripped == "[GPO]":
if in_gpo:
flush_gpo()
i += 1
continue
cells = split_pipe_row(line)
is_gpo_row = cells is not None and cells and cells[0].strip().upper() == "GPO"
is_setting_row = cells is not None and cells and cells[0].strip().upper().startswith("SETTING")
if is_gpo_row:
if in_gpo:
# no explicit [GPO] sentinel seen - be defensive, close the
# previous record before starting a new one
flush_gpo()
start_gpo()
header_lines.append(line)
i += 1
continue
if not in_gpo:
i += 1
continue
if is_setting_row:
flush_setting()
settings_lines_stack = [line]
i += 1
continue
if settings_lines_stack is not None:
if SENTINEL_TAIL_RE.match(line):
flush_setting()
else:
settings_lines_stack.append(line)
i += 1
continue
header_lines.append(line)
i += 1
if in_gpo:
flush_gpo()
if not gpos:
parse_warnings.append(
"No '| GPO |' blocks were recognised in this file. Either it isn't a "
"Group3r text log, it's empty/filtered down to nothing (-w/-a), or the "
"table format differs from what this parser expects. Raw text is shown "
"below so you can check."
)
return gpos, parse_warnings
# --------------------------------------------------------------------------
# HTML rendering
# --------------------------------------------------------------------------
def esc(s) -> str:
return html.escape(str(s), quote=True)
PAGE_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
:root{{
--bg:#0f1117; --panel:#161923; --panel2:#1b1f2c; --line:#2a2f3f;
--text:#e8e9ee; --muted:#8b90a3; --accent:#4fd1ff;
--green:#33c17a; --yellow:#e0b23d; --red:#ef5350; --black-ring:#b98cff;
--mono: "JetBrains Mono","Fira Code",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
--sans: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}}
*{{box-sizing:border-box;}}
html,body{{margin:0;padding:0;}}
body{{
background:var(--bg); color:var(--text); font-family:var(--sans);
line-height:1.5; -webkit-font-smoothing:antialiased;
}}
a{{color:var(--accent);}}
.wrap{{max-width:1100px;margin:0 auto;padding:28px 20px 80px;}}
header.top{{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:12px;border-bottom:1px solid var(--line);padding-bottom:18px;margin-bottom:22px;}}
header.top h1{{font-family:var(--mono);font-size:20px;letter-spacing:0.02em;margin:0;font-weight:600;}}
header.top h1 span{{color:var(--accent);}}
header.top .meta{{font-family:var(--mono);font-size:12px;color:var(--muted);}}
.summary{{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:14px;}}
@media (min-width:720px){{.summary{{grid-template-columns:repeat(7,1fr);}}}}
.stat{{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 10px;text-align:center;cursor:default;user-select:none;}}
.stat .n{{font-family:var(--mono);font-size:22px;font-weight:700;}}
.stat .l{{font-family:var(--mono);font-size:10px;letter-spacing:0.08em;color:var(--muted);text-transform:uppercase;margin-top:2px;}}
.chip{{cursor:pointer;border-width:1px;border-style:solid;transition:opacity .12s ease,background .12s ease;}}
.chip.dimmed{{opacity:0.35;}}
.chip.active.GREEN{{background:var(--green);}} .chip.active.GREEN .n, .chip.active.GREEN .l{{color:#06110c;}}
.chip.active.YELLOW{{background:var(--yellow);}} .chip.active.YELLOW .n, .chip.active.YELLOW .l{{color:#171204;}}
.chip.active.RED{{background:var(--red);}} .chip.active.RED .n, .chip.active.RED .l{{color:#180404;}}
.chip.active.BLACK{{background:var(--black-ring);}} .chip.active.BLACK .n, .chip.active.BLACK .l{{color:#0a0a0d;}}
.chip.GREEN{{border-color:var(--green);}} .chip.GREEN .n{{color:var(--green);}}
.chip.YELLOW{{border-color:var(--yellow);}} .chip.YELLOW .n{{color:var(--yellow);}}
.chip.RED{{border-color:var(--red);}} .chip.RED .n{{color:var(--red);}}
.chip.BLACK{{border-color:var(--black-ring);}} .chip.BLACK .n{{color:var(--black-ring);}}
.toolbar{{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin:18px 0 22px;font-family:var(--mono);font-size:12px;}}
.toolbar input[type=search]{{
flex:1;min-width:200px;background:var(--panel);border:1px solid var(--line);border-radius:8px;
color:var(--text);padding:9px 12px;font-family:var(--sans);font-size:14px;
}}
.toolbar input[type=search]:focus{{outline:2px solid var(--accent);outline-offset:1px;}}
.toolbar label{{display:flex;align-items:center;gap:6px;color:var(--muted);white-space:nowrap;}}
.toolbar input[type=checkbox]{{accent-color:var(--accent);}}
#count-line{{font-family:var(--mono);font-size:12px;color:var(--muted);margin-bottom:10px;}}
.gpo{{background:var(--panel);border:1px solid var(--line);border-left-width:4px;border-radius:10px;margin-bottom:12px;overflow:hidden;}}
.gpo.sev-GREEN{{border-left-color:var(--green);}}
.gpo.sev-YELLOW{{border-left-color:var(--yellow);}}
.gpo.sev-RED{{border-left-color:var(--red);}}
.gpo.sev-BLACK{{border-left-color:var(--black-ring);}}
.gpo.sev-UNKNOWN{{border-left-color:var(--line);}}
.gpo>summary{{list-style:none;cursor:pointer;padding:14px 16px;display:flex;flex-wrap:wrap;gap:10px;align-items:center;}}
.gpo>summary::-webkit-details-marker{{display:none;}}
.gpo>summary::before{{content:"▸";font-family:var(--mono);color:var(--muted);transition:transform .1s ease;}}
.gpo[open]>summary::before{{transform:rotate(90deg);}}
.gpo-name{{font-family:var(--mono);font-weight:700;font-size:14.5px;}}
.gpo-guid{{font-family:var(--mono);font-size:11px;color:var(--muted);}}
.pill{{font-family:var(--mono);font-size:10px;letter-spacing:.06em;border:1px solid var(--line);border-radius:99px;padding:2px 8px;color:var(--muted);text-transform:uppercase;}}
.pill.morphed{{color:var(--yellow);border-color:var(--yellow);}}
.pill.current{{color:var(--green);border-color:var(--green);}}
.gpo-body{{padding:0 16px 16px 16px;border-top:1px solid var(--line);}}
.gpo-sub{{font-family:var(--mono);font-size:11px;color:var(--muted);margin:12px 0 6px;}}
.ou{{font-family:var(--mono);font-size:11.5px;color:var(--muted);word-break:break-all;}}
.setting{{background:var(--panel2);border:1px solid var(--line);border-radius:8px;margin:10px 0;padding:10px 12px;}}
.setting-head{{display:flex;flex-wrap:wrap;gap:8px;align-items:center;font-family:var(--mono);font-size:12.5px;font-weight:600;}}
.setting-fields{{margin-top:8px;font-size:13px;}}
.setting-fields .row{{display:flex;gap:8px;padding:2px 0;border-bottom:1px dashed var(--line);}}
.setting-fields .row:last-child{{border-bottom:none;}}
.setting-fields .k{{color:var(--muted);font-family:var(--mono);font-size:11.5px;min-width:140px;}}
.setting-fields .v{{word-break:break-word;}}
.finding{{border-radius:8px;padding:9px 11px;margin-top:8px;font-size:13px;border:1px solid;}}
.finding .stamp{{font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;font-weight:700;padding:1px 7px;border-radius:99px;display:inline-block;margin-bottom:5px;}}
.finding.GREEN{{border-color:var(--green);background:rgba(51,193,122,0.08);}}
.finding.GREEN .stamp{{background:var(--green);color:#06110c;}}
.finding.YELLOW{{border-color:var(--yellow);background:rgba(224,178,61,0.08);}}
.finding.YELLOW .stamp{{background:var(--yellow);color:#171204;}}
.finding.RED{{border-color:var(--red);background:rgba(239,83,80,0.08);}}
.finding.RED .stamp{{background:var(--red);color:#180404;}}
.finding.BLACK{{border-color:var(--black-ring);background:#0a0a0d;box-shadow:0 0 0 1px var(--black-ring) inset;}}
.finding.BLACK .stamp{{background:#0a0a0d;color:var(--black-ring);border:1px solid var(--black-ring);}}
.finding.UNKNOWN{{border-color:var(--line);}}
.finding .reason{{font-weight:600;}}
.finding .detail{{color:var(--muted);margin-top:3px;}}
.notes{{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin-top:26px;font-size:13px;color:var(--muted);}}
.notes h2{{font-family:var(--mono);font-size:13px;color:var(--text);margin:0 0 8px;}}
.raw-fallback{{white-space:pre-wrap;word-break:break-word;background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:14px;font-family:var(--mono);font-size:12px;max-height:70vh;overflow:auto;}}
footer{{margin-top:40px;font-family:var(--mono);font-size:11px;color:var(--muted);text-align:center;}}
.hidden{{display:none !important;}}
</style>
</head>
<body>
<div class="wrap">
<header class="top">
<h1>Group3r<span>::</span>Report</h1>
<div class="meta">{source_name} &middot; {gpo_count} GPOs &middot; generated by group3r_report.py</div>
</header>
<div class="summary" id="summary">
<div class="stat"><div class="n">{n_gpo}</div><div class="l">GPOs</div></div>
<div class="stat"><div class="n">{n_settings}</div><div class="l">Settings</div></div>
<div class="stat"><div class="n">{n_findings}</div><div class="l">Findings</div></div>
<div class="stat chip BLACK" data-sev="BLACK"><div class="n">{n_black}</div><div class="l">Black</div></div>
<div class="stat chip RED" data-sev="RED"><div class="n">{n_red}</div><div class="l">Red</div></div>
<div class="stat chip YELLOW" data-sev="YELLOW"><div class="n">{n_yellow}</div><div class="l">Yellow</div></div>
<div class="stat chip GREEN" data-sev="GREEN"><div class="n">{n_green}</div><div class="l">Green</div></div>
</div>
<div class="toolbar">
<input type="search" id="q" placeholder="Search GPO name, GUID, setting, reason, detail...">
<label><input type="checkbox" id="findings-only"> Findings only</label>
<label><input type="checkbox" id="current-only"> Hide morphed</label>
</div>
<div id="count-line"></div>
<div id="gpo-list">
{gpo_html}
</div>
{notes_html}
<footer>Severity chips filter the list &middot; click a GPO to expand &middot; sorted worst-first</footer>
</div>
<script>
const DATA = {data_json};
// Empty selection = no severity filter = show everything. Selecting one or
// more chips isolates the view to just those colors (their union).
let activeSev = new Set();
const qEl = document.getElementById('q');
const findingsOnlyEl = document.getElementById('findings-only');
const currentOnlyEl = document.getElementById('current-only');
const countLine = document.getElementById('count-line');
function refreshChipVisuals() {{
const filterOn = activeSev.size > 0;
document.querySelectorAll('.chip').forEach(chip => {{
const sev = chip.dataset.sev;
chip.classList.toggle('active', activeSev.has(sev));
chip.classList.toggle('dimmed', filterOn && !activeSev.has(sev));
}});
}}
document.querySelectorAll('.chip').forEach(chip => {{
chip.addEventListener('click', () => {{
const sev = chip.dataset.sev;
if (activeSev.has(sev)) {{ activeSev.delete(sev); }}
else {{ activeSev.add(sev); }}
refreshChipVisuals();
applyFilter();
}});
}});
function textMatches(gpoEl, q) {{
if (!q) return true;
return gpoEl.dataset.searchblob.includes(q);
}}
function applyFilter() {{
const q = qEl.value.trim().toLowerCase();
const findingsOnly = findingsOnlyEl.checked;
const currentOnly = currentOnlyEl.checked;
const sevFilterOn = activeSev.size > 0;
let visibleGpo = 0, visibleFindings = 0;
document.querySelectorAll('.gpo').forEach(gpoEl => {{
const status = gpoEl.dataset.status;
let show = true;
if (currentOnly && status === 'Morphed') show = false;
if (!textMatches(gpoEl, q)) show = false;
let anySettingVisible = false;
let gpoVisibleFindings = 0;
gpoEl.querySelectorAll('.setting').forEach(setEl => {{
let settingVisibleFindings = 0;
const findingEls = setEl.querySelectorAll('.finding');
findingEls.forEach(fEl => {{
const fSev = fEl.dataset.sev;
const findingShow = !sevFilterOn || activeSev.has(fSev);
fEl.classList.toggle('hidden', !findingShow);
if (findingShow) settingVisibleFindings++;
}});
const settingHasFindings = findingEls.length > 0;
let setShow = true;
if (findingsOnly && !settingHasFindings) setShow = false;
// Isolating to specific colors: only keep settings that actually have
// a finding in (one of) those colors - not just any setting that
// happens to also contain other, non-matching findings.
if (sevFilterOn && settingVisibleFindings === 0) setShow = false;
setEl.classList.toggle('hidden', !setShow);
if (setShow) {{
anySettingVisible = true;
gpoVisibleFindings += settingVisibleFindings;
}}
}});
if (show && findingsOnly && !anySettingVisible) show = false;
if (show && sevFilterOn && !anySettingVisible) show = false;
gpoEl.classList.toggle('hidden', !show);
if (show) {{
visibleGpo++;
visibleFindings += sevFilterOn ? gpoVisibleFindings : parseInt(gpoEl.dataset.findingcount || '0', 10);
}}
}});
countLine.textContent = sevFilterOn
? `Showing ${{visibleGpo}} of ${{DATA.gpoCount}} GPOs \u00b7 ${{visibleFindings}} matching findings`
: `Showing ${{visibleGpo}} of ${{DATA.gpoCount}} GPOs`;
}}
[qEl, findingsOnlyEl, currentOnlyEl].forEach(el => el.addEventListener('input', applyFilter));
applyFilter();
</script>
</body>
</html>
"""
def render_finding(f: Finding) -> str:
sev = f.severity
return f"""<div class="finding {sev}" data-sev="{esc(sev)}">
<span class="stamp">{esc(sev)}</span>
<div class="reason">{esc(f.reason) if f.reason else '(no reason text captured)'}</div>
{f'<div class="detail">{esc(f.detail)}</div>' if f.detail else ''}
</div>"""
def render_setting(s: Setting) -> str:
sevs = ",".join(sorted({fnd.severity for fnd in s.findings}))
fields_html = "".join(
f'<div class="row"><div class="k">{esc(k) if k else "&mdash;"}</div><div class="v">{esc(v)}</div></div>'
for k, v in s.fields
)
findings_html = "".join(render_finding(f) for f in s.findings)
ptype = f'<span class="pill">{esc(s.policy_type)}</span>' if s.policy_type else ""
return f"""<div class="setting" data-sevs="{esc(sevs)}">
<div class="setting-head">&#9656; {esc(s.setting_type)} {ptype}</div>
{f'<div class="setting-fields">{fields_html}</div>' if fields_html else ''}
{findings_html}
</div>"""
def render_gpo(g: GPOEntry) -> str:
rank_to_name = {4: "BLACK", 3: "RED", 2: "YELLOW", 1: "GREEN", 0: "UNKNOWN"}
max_sev = rank_to_name[g.max_severity_rank()]
findings_count = len(g.all_findings())
status_class = "morphed" if g.status == "Morphed" else ("current" if g.status == "Current" else "")
settings_html = "".join(render_setting(s) for s in g.settings) or '<div class="ou">No settings recorded for this GPO.</div>'
ous_html = "".join(f'<div class="ou">{esc(o)}</div>' for o in g.linked_ous) or '<div class="ou">(none recorded)</div>'
extra_info = "".join(
f'<div class="ou">{esc(k)}: {esc(v)}</div>' for k, v in g.info.items() if k != "_unparsed_rows"
)
searchblob = " ".join([g.name, g.guid] + [s.setting_type for s in g.settings] +
[fnd.reason + " " + fnd.detail for s in g.settings for fnd in s.findings]).lower()
return f"""<details class="gpo sev-{max_sev}" data-maxsev="{max_sev}" data-status="{esc(g.status)}"
data-findingcount="{findings_count}" data-searchblob="{esc(searchblob)}">
<summary>
<span class="gpo-name">{esc(g.name)}</span>
<span class="gpo-guid">{esc(g.guid)}</span>
<span class="pill {status_class}">{esc(g.status)}</span>
<span class="pill">{len(g.settings)} settings</span>
<span class="pill">{findings_count} findings</span>
</summary>
<div class="gpo-body">
<div class="gpo-sub">Linked OUs</div>
{ous_html}
{f'<div class="gpo-sub">Other header fields</div>{extra_info}' if extra_info else ''}
<div class="gpo-sub">Settings</div>
{settings_html}
</div>
</details>"""
def build_report(gpos, parse_warnings, source_name: str) -> str:
gpos_sorted = sorted(gpos, key=lambda g: (-g.max_severity_rank(), -len(g.all_findings()), g.name.lower()))
all_findings = [f for g in gpos for f in g.all_findings()]
counts = {"BLACK": 0, "RED": 0, "YELLOW": 0, "GREEN": 0, "UNKNOWN": 0}
for f in all_findings:
counts[f.severity] += 1
n_settings = sum(len(g.settings) for g in gpos)
gpo_html = "\n".join(render_gpo(g) for g in gpos_sorted)
notes_items = []
if parse_warnings:
notes_items.extend(parse_warnings)
unparsed_rows = []
for g in gpos:
if "_unparsed_rows" in g.info:
unparsed_rows.append(f"{g.name}: {len(g.info['_unparsed_rows'])} header row(s) not confidently classified")
if unparsed_rows:
notes_items.append("Some GPO header rows were kept verbatim but not labelled (shown under 'Other header fields' per-GPO): " + "; ".join(unparsed_rows[:10]) + (" ..." if len(unparsed_rows) > 10 else ""))
notes_items.append(
"This report never contacts a network and embeds no external assets/CDNs - safe to open from a share or attack box."
)
notes_html = ""
if notes_items:
notes_html = '<div class="notes"><h2>Parser notes</h2><ul>' + "".join(f"<li>{esc(n)}</li>" for n in notes_items) + "</ul></div>"
if not gpos:
# raw fallback view
notes_html += "" # already contains the "no blocks found" warning
data_json = json.dumps({"gpoCount": len(gpos)})
return PAGE_TEMPLATE.format(
title=f"Group3r Report - {esc(source_name)}",
source_name=esc(source_name),
gpo_count=len(gpos),
n_gpo=len(gpos),
n_settings=n_settings,
n_findings=len(all_findings),
n_black=counts["BLACK"],
n_red=counts["RED"],
n_yellow=counts["YELLOW"],
n_green=counts["GREEN"],
gpo_html=gpo_html if gpos else '<div class="ou">No GPO blocks parsed - see Parser notes below and the raw text view.</div>',
notes_html=notes_html,
data_json=data_json,
)
def build_raw_fallback(text: str, source_name: str) -> str:
return f"""<!DOCTYPE html><html><head><meta charset="utf-8">
<title>Group3r Report (raw fallback) - {esc(source_name)}</title>
<style>
body{{background:#0f1117;color:#e8e9ee;font-family:ui-monospace,Consolas,monospace;padding:24px;}}
.banner{{background:#1b1f2c;border:1px solid #ef5350;border-radius:8px;padding:14px 16px;margin-bottom:18px;color:#ef5350;font-family:sans-serif;}}
pre{{white-space:pre-wrap;word-break:break-word;font-size:12px;background:#161923;border:1px solid #2a2f3f;border-radius:8px;padding:16px;}}
</style></head><body>
<div class="banner">No recognisable Group3r '| GPO |' blocks were found in <b>{esc(source_name)}</b>.
Showing the raw file below so you can confirm the format / check for an empty or filtered log.</div>
<pre>{esc(text)}</pre>
</body></html>"""
# --------------------------------------------------------------------------
# Demo data (synthetic - for trying the tool without a real engagement log)
# --------------------------------------------------------------------------
DEMO_LOG = r"""
2026-07-27 10:15:32 +00:00 [GPO]
| GPO | Default Domain Policy {31B2F340-016D-11D2-945F-00C04FB984F9} Current |
|-----------------|--------------------------------------------------------------------------------|
| Date Created | 6/15/2012 8:31:32 PM |
| Date Modified | 3/2/2022 4:41:09 PM |
| Path in SYSVOL | \\corp.local\SysVol\corp.local\Policies\{31B2F340-016D-11D2-945F-00C04FB984F9} |
| Computer Policy | Enabled |
| User Policy | Enabled |
| Link | OU=Domain Controllers,DC=corp,DC=local (LinkEnabled) |
\___
| Setting - Computer Policy | System Access |
|---------------------------|---------------|
| MinimumPasswordLength | 7 |
| MaximumPasswordAge | 42 |
2026-07-27 10:15:33 +00:00 [GPO]
| GPO | testgpo123 {6AC1786C-016F-11D2-945F-00C04fB984F9} Current |
|-----------------|--------------------------------------------------------------------------------|
| Date Created | 3/1/2022 9:12:00 AM |
| Date Modified | 3/1/2022 9:20:11 AM |
| Path in SYSVOL | \\corp.local\SysVol\corp.local\Policies\{6AC1786C-016F-11D2-945F-00C04fB984F9} |
| Computer Policy | Enabled |
| User Policy | Disabled |
| Link | OU=Workstations,DC=corp,DC=local (LinkEnabled) |
\___
| Setting - Package Policy | Package |
|--------------------------|----------------------------------------------------------------------------------|
| Display Name | PuTTY |
| Action | Install |
| File | \\corp.local\SYSVOL\corp.local\Policies\{6AC1786C}\Machine\Applications\putty.m- |
| | si |
\___
| Finding | Yellow |
|---------|---------------------------------------------------------------------------------|
| Reason | MSI installs software with a known history of CVEs |
| Detail | Consider checking installed version against current CVEs before relying on this |
| | as an entry point. |
\___
| Setting - Computer Policy | Script |
|---------------------------|---------------------------------------------|
| Script Type | Startup |
| CmdLine | \\corp.local\netlogon\scripts\mapdrives.bat |
| Args | -user svc_backup -pass P@ssw0rd123! |
\___
| Finding | Black |
|---------|--------------------------------------------------------------------------|
| Reason | Hard-coded credential in script arguments |
| Detail | Argument string looks like a plaintext password for svc_backup. Treat as |
| | compromised. |
\___
| Finding | Red |
|---------|---------------------------------------------------------------------------|
| Reason | Script is writable by the current user |
| Detail | Current user has Modify rights on mapdrives.bat - can be used to get code |
| | execution as any computer this GPO applies to. |
2026-07-27 10:15:34 +00:00 [GPO]
| GPO | Legacy WSUS Policy {9F2C1B10-4A11-4E2B-8C60-000000000001} Morphed |
|-----------------|--------------------------------------------------------------------------------|
| Date Created | 1/5/2015 2:00:00 PM |
| Date Modified | 1/5/2015 2:00:00 PM |
| Path in SYSVOL | \\corp.local\SysVol\corp.local\Policies\{9F2C1B10-4A11-4E2B-8C60-000000000001} |
| Computer Policy | Enabled |
| User Policy | Disabled |
| Link | DC=corp,DC=local (LinkEnabled) |
\___
| Setting - Computer Policy - Morphed | Registry |
|-------------------------------------|--------------------------------------------------------|
| Key | HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate |
| Value Name | WUServer |
| Value String | http://wsus.corp.local:8530 |
\___
| Finding | Green |
|---------|---------------------------------------------------------------------------------|
| Reason | WSUS over plain HTTP |
| Detail | Old/replicated copy of a config that pointed WSUS over HTTP, which historically |
| | enabled WSUS spoofing attacks. May no longer be current - verify. |
2026-07-27 10:15:35 +00:00 [GPO]
| GPO | Long Reason Test GPO {AAAAAAAA-0000-0000-0000-000000000004} Current |
|-----------------|--------------------------------------------------------------------------------|
| Date Created | 1/1/2020 1:00:00 PM |
| Date Modified | 1/1/2020 1:00:00 PM |
| Path in SYSVOL | \\corp.local\SysVol\corp.local\Policies\{AAAAAAAA-0000-0000-0000-000000000004} |
| Computer Policy | Enabled |
| User Policy | Disabled |
| Link | OU=Servers,DC=corp,DC=local (LinkEnabled) |
\___
| Setting - Computer Policy | Registry |
|---------------------------|-----------------------------|
| Key | HKLM\Software\Policies\Test |
\___
| Finding | Red |
|---------|----------------------------------------------------------------------------------|
| Reason | This is a deliberately long finding reason string designed to exceed eighty |
| | characters so that Group3r's own TableAdd word-wrap logic kicks in and splits it |
| | across multiple table rows |
| Detail | Short detail. |
2026-07-27 10:15:36 +00:00 [Finish] Done! Processed 4 GPOs.
"""
# --------------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(description="Render a Group3r text log as a single HTML triage report.")
ap.add_argument("-i", "--input", help="Path to Group3r log file (output of -f)")
ap.add_argument("-o", "--output", default="group3r_report.html", help="Output HTML path (default: group3r_report.html)")
ap.add_argument("--demo", action="store_true", help="Ignore -i and render the built-in synthetic sample log instead")
args = ap.parse_args()
if args.demo:
text = DEMO_LOG
source_name = "(demo data - synthetic, for illustration only)"
else:
if not args.input:
ap.error("either -i/--input <log file> or --demo is required")
p = Path(args.input)
if not p.exists():
print(f"error: {p} not found", file=sys.stderr)
sys.exit(1)
text = p.read_text(encoding="utf-8", errors="replace")
source_name = p.name
gpos, warnings = parse_log(text)
if not gpos:
out_html = build_report(gpos, warnings, source_name) + "\n<!--\n" + build_raw_fallback(text, source_name) + "\n-->"
# prefer a clean raw fallback page when nothing parsed at all
out_html = build_raw_fallback(text, source_name)
else:
out_html = build_report(gpos, warnings, source_name)
out_path = Path(args.output)
out_path.write_text(out_html, encoding="utf-8")
n_findings = sum(len(g.all_findings()) for g in gpos)
print(f"Parsed {len(gpos)} GPOs, {n_findings} findings -> {out_path}")
if warnings:
for w in warnings:
print(f"warning: {w}", file=sys.stderr)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment