Last active
August 2, 2026 10:52
-
-
Save gitgotgitgotit/daf99e40e150930609fb63633b363e73 to your computer and use it in GitHub Desktop.
Group3r log to HTML parser
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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} · {gpo_count} GPOs · 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 · click a GPO to expand · 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 "—"}</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">▸ {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