#!/usr/bin/env python3 """ procmon_summary.py - condense a Procmon CSV export into a behavior summary. Runs on the host against evidence exported from the analysis VM (Procmon: File -> Save -> All events -> CSV). Stdlib only. Usage: python3 procmon_summary.py procmon.csv --process sample.exe python3 procmon_summary.py procmon.csv --all # no process filter python3 procmon_summary.py --self-test --process takes a comma list of image names (case-insensitive). The summary covers those processes plus every descendant found via "Process Create". Without --process (or with --all) every process in the log is included. Output sections: process tree, files written/renamed/deleted, registry writes (persistence keys flagged), network endpoints, non-system image loads. Pipe the output through scripts/ioc_extract.py to get defanged IOCs. """ import argparse import csv import io import re import sys from collections import defaultdict, OrderedDict PERSISTENCE_RE = re.compile( r"CurrentVersion\\(Run|RunOnce|RunServices|Explorer\\(Shell Folders|User Shell Folders))" r"|CurrentControlSet\\Services\\" r"|Winlogon\\(Shell|Userinit|Notify)" r"|Image File Execution Options" r"|Windows\\CurrentVersion\\Policies\\Explorer\\Run" r"|Schedule\\TaskCache" r"|Office\\.*\\Security" r"|AppInit_DLLs" r"|Start Menu\\Programs\\Startup", re.IGNORECASE, ) SYSTEM_DIR_RE = re.compile(r"^[A-Z]:\\Windows\\", re.IGNORECASE) PID_RE = re.compile(r"PID:\s*(\d+)") CMD_RE = re.compile(r"Command line:\s*(.*?)(?:,\s*Current directory:|$)", re.DOTALL) NEWFILE_RE = re.compile(r"OpenResult:\s*(Created|Overwritten|Superseded)") def read_text(path): """Read exported evidence regardless of PowerShell's default encoding (UTF-16LE with BOM).""" raw = open(path, "rb").read() if raw.startswith((b"\xff\xfe", b"\xfe\xff")): return raw.decode("utf-16", errors="replace") return raw.decode("utf-8-sig", errors="replace") def load(path): return list(csv.DictReader(io.StringIO(read_text(path)))) def field(row, *names): for n in names: if n in row and row[n] is not None: return row[n] return "" def build_tree(rows): """Return (children: pid -> [pid], info: pid -> (name, cmdline)).""" children = defaultdict(list) info = {} for r in rows: op = field(r, "Operation") if op == "Process Create": m = PID_RE.search(field(r, "Detail")) if not m: continue child = m.group(1) parent = field(r, "PID") children[parent].append(child) cmd = CMD_RE.search(field(r, "Detail")) # name the child from the created image path (the actor row's name is the parent) created = field(r, "Path") child_name = created.rsplit("\\", 1)[-1] if created else "?" info.setdefault(child, [child_name or "?", cmd.group(1).strip() if cmd else ""]) elif op == "Process Start": pid = field(r, "PID") cmd = CMD_RE.search(field(r, "Detail")) info[pid] = [field(r, "Process Name"), cmd.group(1).strip() if cmd else ""] for r in rows: # fill names for processes seen only as actors pid = field(r, "PID") if pid and pid not in info: info[pid] = [field(r, "Process Name"), ""] elif pid in info and info[pid][0] == "?": info[pid][0] = field(r, "Process Name") return children, info def descendants(seeds, children): seen, stack = set(), list(seeds) while stack: p = stack.pop() if p in seen: continue seen.add(p) stack.extend(children.get(p, [])) return seen def print_tree(pid, children, info, depth, out): name, cmd = info.get(pid, ("?", "")) out.append(f"{' ' * depth}{name} (PID {pid})" + (f" {cmd}" if cmd else "")) for c in children.get(pid, []): print_tree(c, children, info, depth + 1, out) def summarize(rows, process_names=None): children, info = build_tree(rows) if process_names: wanted = {n.lower() for n in process_names} seeds = [pid for pid, (name, _) in info.items() if name.lower() in wanted] scope = descendants(seeds, children) roots = seeds else: scope = set(info) all_children = {c for cs in children.values() for c in cs} roots = [p for p in info if p not in all_children] files = OrderedDict() renames, deletes = OrderedDict(), OrderedDict() reg = OrderedDict() net = OrderedDict() loads = OrderedDict() counts = defaultdict(int) for r in rows: pid = field(r, "PID") if pid not in scope: continue op, path, detail = field(r, "Operation"), field(r, "Path"), field(r, "Detail") who = f"{field(r, 'Process Name')}:{pid}" counts[who] += 1 if op == "CreateFile" and NEWFILE_RE.search(detail): files.setdefault(path, who) elif op == "WriteFile": files.setdefault(path, who) elif op == "SetRenameInformationFile": m = re.search(r"FileName:\s*(.*)$", detail) renames.setdefault(f"{path} -> {m.group(1) if m else '?'}", who) elif op in ("SetDispositionInformationFile", "SetDispositionInformationEx") and "True" in detail: deletes.setdefault(path, who) elif op in ("RegSetValue", "RegDeleteValue", "RegDeleteKey") or ( op == "RegCreateKey" and "REG_CREATED_NEW_KEY" in detail ): data = "" m = re.search(r"Data:\s*(.*)$", detail) if m: data = m.group(1) reg.setdefault(f"{op} {path}" + (f" = {data}" if data else ""), who) elif op.startswith(("TCP", "UDP")) and "->" in path: dst = path.split("->", 1)[1].strip() net.setdefault(f"{op.split()[0]} {dst}", who) elif op == "Load Image" and not SYSTEM_DIR_RE.match(path): loads.setdefault(path, who) out = [f"# Procmon summary ({len(rows)} events, {len(scope)} processes in scope)", "", "## Process tree"] for root in roots: print_tree(root, children, info, 0, out) def section(title, items, limit=200): out.extend(["", f"## {title} ({len(items)})"]) for i, (k, who) in enumerate(items.items()): if i >= limit: out.append(f"... {len(items) - limit} more") break out.append(f"- {k} [{who}]") section("Files created/written", files) section("Files renamed", renames) section("Files deleted", deletes) out.extend(["", f"## Registry writes ({len(reg)})"]) for k, who in reg.items(): flag = " <-- PERSISTENCE" if PERSISTENCE_RE.search(k) else "" out.append(f"- {k} [{who}]{flag}") section("Network endpoints", net) section("Image loads outside C:\\Windows", loads) out.extend(["", "## Event count per process"]) for who, n in sorted(counts.items(), key=lambda x: -x[1])[:20]: out.append(f"- {who}: {n}") return "\n".join(out) SELF_TEST_CSV = '''"Time of Day","Process Name","PID","Operation","Path","Result","Detail" "1","explorer.exe","100","Process Create","C:\\Users\\Public\\sample.exe","SUCCESS","PID: 200, Command line: ""C:\\Users\\Public\\sample.exe"" /install" "2","sample.exe","200","Process Start","","SUCCESS","Parent PID: 100, Command line: ""C:\\Users\\Public\\sample.exe"" /install, Current directory: C:\\Users\\Public\\" "3","sample.exe","200","CreateFile","C:\\Users\\Bob\\AppData\\Roaming\\svc.exe","SUCCESS","Desired Access: Generic Write, OpenResult: Created" "4","sample.exe","200","RegSetValue","HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\svc","SUCCESS","Type: REG_SZ, Length: 60, Data: C:\\Users\\Bob\\AppData\\Roaming\\svc.exe" "5","sample.exe","200","TCP Connect","host.local:49700 -> 203.0.113.5:443","SUCCESS","Length: 0" "6","sample.exe","200","Process Create","C:\\Windows\\System32\\cmd.exe","SUCCESS","PID: 300, Command line: cmd.exe /c del sample.exe" "7","cmd.exe","300","SetDispositionInformationFile","C:\\Users\\Public\\sample.exe","SUCCESS","Delete: True" "8","chrome.exe","999","WriteFile","C:\\Users\\Bob\\cache.dat","SUCCESS","Offset: 0" ''' def self_test(): rows = list(csv.DictReader(io.StringIO(SELF_TEST_CSV))) out = summarize(rows, ["sample.exe"]) assert "cmd.exe (PID 300)" in out and " cmd.exe" in out, "child not nested under sample" assert "svc.exe" in out and "PERSISTENCE" in out, "persistence run key not flagged" assert "203.0.113.5:443" in out, "network endpoint missing" assert "cache.dat" not in out, "unrelated process leaked into scope" assert "Files deleted (1)" in out, "deletion missing" print("self-test OK") def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("csv", nargs="?") ap.add_argument("--process", help="comma list of image names to scope on (plus descendants)") ap.add_argument("--all", action="store_true", help="include every process") ap.add_argument("--self-test", action="store_true") a = ap.parse_args() if a.self_test: return self_test() if not a.csv: ap.error("csv path required") names = None if a.all or not a.process else [n.strip() for n in a.process.split(",")] print(summarize(load(a.csv), names)) if __name__ == "__main__": main()