Files
reverse-skills-max/vendor-new/malware-analysis-claude-skills/malware-triage/scripts/pe_info.py

685 lines
24 KiB
Python
Executable File

#!/usr/bin/env python3
"""
pe_info.py - one-shot static PE triage summary.
Runs entirely on the host against an exported/copied sample file. Never
executes the sample. Prints a plain-text triage report (or JSON with
--json) covering headers, sections, packing heuristics, imports/exports,
signature, resources, overlay, PDB path, version info, TLS callbacks and
rich header.
Usage:
python3 pe_info.py <path-to-pe> [--json]
Requires: pefile (pip install pefile)
"""
import sys
import os
import hashlib
import math
import json
import argparse
from datetime import datetime, timezone
try:
import pefile
except ImportError:
print("pefile is required: pip install pefile", file=sys.stderr)
sys.exit(2)
PACKER_SECTION_NAMES = {
"UPX0", "UPX1", ".THEMIDA", ".VMP0", ".VMP1", ".ASPACK",
".MPRESS1", ".ENIGMA1", ".PETITE", ".NSP0", ".UPACK",
}
SUBSYSTEM_NAMES = {
1: "NATIVE", 2: "GUI", 3: "CUI", 5: "OS2 CUI", 7: "POSIX CUI",
9: "WINDOWS CE GUI", 10: "EFI APPLICATION", 11: "EFI BOOT SERVICE DRIVER",
12: "EFI RUNTIME DRIVER", 13: "EFI ROM", 14: "XBOX",
16: "WINDOWS BOOT APPLICATION",
}
MACHINE_NAMES = {
0x14c: "x86", 0x8664: "x64", 0xaa64: "ARM64",
0x1c0: "ARM", 0x1c4: "ARM Thumb-2", 0x200: "IA64",
}
SUSPICIOUS_CATEGORIES = {
"injection": ["CreateRemoteThread", "WriteProcessMemory", "VirtualAllocEx",
"OpenProcess", "NtUnmapViewOfSection", "SetThreadContext",
"ResumeThread", "QueueUserAPC", "NtCreateThreadEx",
"RtlCreateUserThread"],
"keylogging": ["SetWindowsHookEx", "GetAsyncKeyState", "GetKeyState",
"GetForegroundWindow"],
"network": ["InternetOpen", "InternetOpenUrl", "InternetReadFile",
"HttpOpenRequest", "HttpSendRequest", "URLDownloadToFile",
"WSAStartup", "socket", "connect", "send", "recv",
"WinHttpOpen", "WinHttpConnect", "DnsQuery", "getaddrinfo",
"gethostbyname"],
"persistence": ["RegSetValueEx", "RegCreateKeyEx", "CreateService",
"StartService", "ChangeServiceConfig", "CoCreateInstance"],
"anti-analysis": ["IsDebuggerPresent", "CheckRemoteDebuggerPresent",
"NtQueryInformationProcess", "OutputDebugString",
"GetTickCount", "QueryPerformanceCounter", "FindWindow",
"GetLastInputInfo", "GetCursorPos", "GlobalMemoryStatusEx",
"GetSystemInfo", "EnumProcesses", "CreateToolhelp32Snapshot",
"Process32First", "Process32Next"],
"evasion": ["UpdateProcThreadAttribute", "VirtualProtect", "EtwEventWrite",
"AmsiScanBuffer", "AmsiScanString", "NtTraceEvent",
"AddVectoredExceptionHandler"],
"crypto": ["CryptEncrypt", "CryptDecrypt", "CryptAcquireContext",
"CryptGenKey", "CryptHashData", "BCryptEncrypt", "BCryptDecrypt",
"CryptImportKey"],
"file-ops": ["CreateFile", "WriteFile", "DeleteFile", "MoveFile",
"CopyFile", "FindFirstFile", "FindNextFile", "SetFileAttributes"],
"execution": ["CreateProcess", "ShellExecute", "WinExec", "system",
"CreateProcessAsUser", "CreateProcessWithLogon"],
"callback-exec": ["EnumChildWindows", "EnumDesktopWindows",
"CreateTimerQueueTimer", "EnumSystemLocales",
"EnumTimeFormatsEx", "SetTimer", "TpAllocWork"],
"dynamic-loading": ["LoadLibrary", "LoadLibraryEx", "GetProcAddress",
"LdrLoadDll"],
}
INJECTION_APIS = {n.lower() for n in SUSPICIOUS_CATEGORIES["injection"]}
DYNLOAD_APIS = {n.lower() for n in SUSPICIOUS_CATEGORIES["dynamic-loading"]}
def norm_api_name(name: str) -> str:
"""Normalize a WinAPI import name for matching: strip stdcall decoration
(@N / leading _), strip a trailing Ansi/Wide 'A'/'W' suffix, lowercase."""
n = name.strip()
if n.startswith("_"):
n = n[1:]
if "@" in n:
n = n.split("@")[0]
if len(n) > 1 and n[-1] in ("A", "W") and n[-2].isalpha():
n = n[:-1]
return n.lower()
CATEGORY_LOOKUP = {}
for _cat, _names in SUSPICIOUS_CATEGORIES.items():
for _n in _names:
CATEGORY_LOOKUP.setdefault(norm_api_name(_n), []).append(_cat)
def shannon_entropy(data: bytes) -> float:
if not data:
return 0.0
freq = [0] * 256
for b in data:
freq[b] += 1
length = len(data)
entropy = 0.0
for f in freq:
if f:
p = f / length
entropy -= p * math.log2(p)
return entropy
def hash_file(path: str):
md5, sha1, sha256 = hashlib.md5(), hashlib.sha1(), hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
md5.update(chunk)
sha1.update(chunk)
sha256.update(chunk)
return md5.hexdigest(), sha1.hexdigest(), sha256.hexdigest()
def decode(b):
if b is None:
return None
if isinstance(b, bytes):
return b.rstrip(b"\x00").decode(errors="replace")
return str(b)
class Report:
"""Collects text lines + a parallel structured dict for --json."""
def __init__(self):
self.lines = []
self.data = {}
def h(self, title):
self.lines.append("")
self.lines.append(f"=== {title} ===")
def p(self, text=""):
self.lines.append(text)
def fail(self, section, err):
self.lines.append(f"section failed: {err}")
self.data.setdefault("errors", {})[section] = str(err)
def section_file_info(pe, path, rpt: Report):
rpt.h("File")
size = os.path.getsize(path)
md5, sha1, sha256 = hash_file(path)
with open(path, "rb") as f:
data = f.read()
entropy = shannon_entropy(data)
try:
imphash = pe.get_imphash()
except Exception as e:
imphash = f"n/a ({e})"
magic = pe.OPTIONAL_HEADER.Magic
pe_type = "PE32+" if magic == 0x20B else "PE32" if magic == 0x10B else f"unknown (0x{magic:x})"
machine_val = pe.FILE_HEADER.Machine
machine = MACHINE_NAMES.get(machine_val, f"0x{machine_val:x}")
subsys_val = pe.OPTIONAL_HEADER.Subsystem
subsystem = SUBSYSTEM_NAMES.get(subsys_val, f"0x{subsys_val:x}")
is_dll = pe.is_dll()
kind = "DLL" if is_dll else "EXE"
com_dir = pe.OPTIONAL_HEADER.DATA_DIRECTORY[
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR"]
]
is_dotnet = com_dir.VirtualAddress != 0
ts = pe.FILE_HEADER.TimeDateStamp
ts_flag = ""
try:
ts_dt = datetime.fromtimestamp(ts, tz=timezone.utc)
now = datetime.now(tz=timezone.utc)
if ts_dt > now:
ts_flag = " [FLAG: timestamp is in the future]"
if is_dotnet:
ts_flag += " (normal for .NET deterministic builds: field holds a hash, not a date)"
elif ts_dt.year < 2000:
ts_flag = " [FLAG: timestamp before year 2000]"
ts_str = ts_dt.isoformat()
except (OverflowError, OSError, ValueError):
ts_str = f"invalid raw value 0x{ts:x}"
ts_flag = " [FLAG: unparsable timestamp]"
rpt.p(f"Path: {path}")
rpt.p(f"Size: {size} bytes")
rpt.p(f"MD5: {md5}")
rpt.p(f"SHA1: {sha1}")
rpt.p(f"SHA256: {sha256}")
rpt.p(f"Entropy: {entropy:.2f} (whole file)")
rpt.p(f"Imphash: {imphash}")
rpt.p(f"Format: {pe_type}, machine={machine}, subsystem={subsystem}, {kind}")
rpt.p(f".NET: {'YES - route to specialized-file-analyzer' if is_dotnet else 'no'}")
rpt.p(f"Compiled: {ts_str}{ts_flag}")
rpt.p(f"Linker: {pe.OPTIONAL_HEADER.MajorLinkerVersion}.{pe.OPTIONAL_HEADER.MinorLinkerVersion}")
rpt.data["file"] = {
"path": path, "size": size, "md5": md5, "sha1": sha1, "sha256": sha256,
"entropy": round(entropy, 2), "imphash": imphash, "pe_type": pe_type,
"machine": machine, "subsystem": subsystem, "is_dll": is_dll,
"is_dotnet": is_dotnet, "compile_time_utc": ts_str,
"linker_version": f"{pe.OPTIONAL_HEADER.MajorLinkerVersion}.{pe.OPTIONAL_HEADER.MinorLinkerVersion}",
}
return is_dotnet
def section_sections(pe, rpt: Report):
rpt.h("Sections")
ep = pe.OPTIONAL_HEADER.AddressOfEntryPoint
ep_section = pe.get_section_by_rva(ep)
ep_name = decode(ep_section.Name) if ep_section else None
rows = []
header = f"{'Name':<10} {'VirtSize':>10} {'RawSize':>10} {'Entropy':>8} {'Flags':>6} Notes"
rpt.p(header)
rpt.p("-" * len(header))
for i, sec in enumerate(pe.sections):
name = decode(sec.Name)
vsize = sec.Misc_VirtualSize
rsize = sec.SizeOfRawData
try:
ent = sec.get_entropy()
except Exception:
ent = 0.0
chars = sec.Characteristics
flags = ""
flags += "R" if chars & 0x40000000 else "-"
flags += "W" if chars & 0x80000000 else "-"
flags += "X" if chars & 0x20000000 else "-"
notes = []
packer_hit = name.strip().upper() in PACKER_SECTION_NAMES
high_entropy = ent > 7.0
if packer_hit or high_entropy:
notes.append("!")
if name == ep_name and ep_section is not None:
notes.append("[ENTRY POINT]")
rows.append({
"name": name, "virtual_size": vsize, "raw_size": rsize,
"entropy": round(ent, 2), "flags": flags,
"packer_name_match": packer_hit, "high_entropy": high_entropy,
})
rpt.p(f"{name:<10} {vsize:>10} {rsize:>10} {ent:>8.2f} {flags:>6} {' '.join(notes)}")
if ep_section is None:
rpt.p("WARNING: entry point RVA does not resolve to any section")
else:
if ep_name not in (".text", "CODE"):
rpt.p(f"WARNING: entry point is in section '{ep_name}', not .text/CODE")
if pe.sections and ep_section is pe.sections[-1]:
rpt.p(f"WARNING: entry point is in the LAST section ('{ep_name}') - common packer stub pattern")
rpt.data["sections"] = rows
return rows
def section_packing_verdict(pe, rpt: Report, overall_entropy, sections, total_imports, kernel32_only_loader_pattern, is_dotnet=False):
rpt.h("Packing verdict")
reasons = []
if overall_entropy > 7.0:
reasons.append(f"overall file entropy {overall_entropy:.2f} > 7.0")
exec_high = [s for s in sections if "X" in s["flags"] and s["entropy"] > 6.8]
if exec_high:
names = ", ".join(s["name"] for s in exec_high)
reasons.append(f"executable section entropy > 6.8 in: {names}")
if is_dotnet:
rpt.p("Note: .NET assembly - import-table heuristics skipped (managed code resolves calls via CLR metadata).")
else:
if total_imports < 10:
reasons.append(f"only {total_imports} imported functions total")
if kernel32_only_loader_pattern:
reasons.append("imports are only from KERNEL32 with a LoadLibrary/GetProcAddress/VirtualAlloc/VirtualProtect pattern")
if reasons:
rpt.p("Likely packed. Reasons:")
for r in reasons:
rpt.p(f" - {r}")
else:
rpt.p("No strong packing indicators.")
rpt.data["packing_verdict"] = {"likely_packed": bool(reasons), "reasons": reasons}
def section_imports(pe, rpt: Report):
rpt.h("Imports")
total = 0
category_hits = {}
dll_names = set()
kernel32_functions = set()
all_dll_only_kernel32 = True
if hasattr(pe, "DIRECTORY_ENTRY_IMPORT"):
for entry in pe.DIRECTORY_ENTRY_IMPORT:
dll = decode(entry.dll) or "?"
dll_names.add(dll.lower())
if dll.lower() != "kernel32.dll":
all_dll_only_kernel32 = False
names = []
for imp in entry.imports:
total += 1
fname = decode(imp.name) if imp.name else f"ordinal#{imp.ordinal}"
names.append(fname)
if dll.lower() == "kernel32.dll" and imp.name:
kernel32_functions.add(norm_api_name(fname))
if imp.name:
cats = CATEGORY_LOOKUP.get(norm_api_name(fname))
if cats:
for c in cats:
category_hits.setdefault(c, set()).add(fname)
rpt.p(f"{dll} ({len(names)} functions):")
rpt.p(" " + ", ".join(names))
else:
rpt.p("No import table.")
rpt.p("")
rpt.p("Suspicious imports by category:")
if category_hits:
for cat in sorted(category_hits):
names = ", ".join(sorted(category_hits[cat]))
rpt.p(f" {cat}: {names}")
else:
rpt.p(" none matched")
loader_pattern = {"loadlibrary", "getprocaddress", "virtualalloc", "virtualprotect"}
kernel32_loader_pattern = (
dll_names == {"kernel32.dll"} and loader_pattern.issubset(kernel32_functions)
)
# delay-load imports
rpt.p("")
rpt.p("Delay-load imports:")
if hasattr(pe, "DIRECTORY_ENTRY_DELAY_IMPORT") and pe.DIRECTORY_ENTRY_DELAY_IMPORT:
for entry in pe.DIRECTORY_ENTRY_DELAY_IMPORT:
dll = decode(entry.dll) or "?"
names = [decode(i.name) if i.name else f"ordinal#{i.ordinal}" for i in entry.imports]
rpt.p(f" {dll} ({len(names)}): {', '.join(names)}")
else:
rpt.p(" none")
# exports
rpt.p("")
rpt.p("Exports:")
exports = []
if hasattr(pe, "DIRECTORY_ENTRY_EXPORT"):
for sym in pe.DIRECTORY_ENTRY_EXPORT.symbols[:50]:
exports.append(decode(sym.name) if sym.name else f"ordinal#{sym.ordinal}")
rpt.p(" " + ", ".join(exports) if exports else " (export directory present, no symbols)")
total_exports = len(pe.DIRECTORY_ENTRY_EXPORT.symbols)
if total_exports > 50:
rpt.p(f" ... ({total_exports} total, showing first 50)")
else:
rpt.p(" none")
rpt.data["imports"] = {
"total_functions": total,
"categories": {k: sorted(v) for k, v in category_hits.items()},
"exports_count": len(exports),
}
return total, kernel32_loader_pattern
def section_signature(pe, rpt: Report):
rpt.h("Signature")
sec_dir = pe.OPTIONAL_HEADER.DATA_DIRECTORY[
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_SECURITY"]
]
present = sec_dir.VirtualAddress != 0 and sec_dir.Size != 0
rpt.p(f"Authenticode directory present: {'yes' if present else 'no'} (size={sec_dir.Size})")
rpt.p("Not verifying signature here - verify with `osslsigncode verify` or `signtool verify`.")
rpt.data["signature"] = {"present": present, "size": sec_dir.Size}
def section_resources(pe, rpt: Report):
rpt.h("Resources")
if not hasattr(pe, "DIRECTORY_ENTRY_RESOURCE"):
rpt.p("No resource directory.")
rpt.data["resources"] = {"count": 0}
return
leaves = []
has_manifest = False
def type_name(entry):
if entry.name is not None:
return decode(entry.name.string) if hasattr(entry.name, "string") else str(entry.name)
rid = entry.struct.Id
name = None
try:
name = pefile.RESOURCE_TYPE.get(rid)
except Exception:
name = None
return name or f"RT_UNKNOWN_{rid}"
for res_type in pe.DIRECTORY_ENTRY_RESOURCE.entries:
tname = type_name(res_type)
if tname == "RT_MANIFEST":
has_manifest = True
if not hasattr(res_type, "directory"):
continue
for res_id in res_type.directory.entries:
if not hasattr(res_id, "directory"):
continue
for res_lang in res_id.directory.entries:
if not hasattr(res_lang, "data"):
continue
rva = res_lang.data.struct.OffsetToData
size = res_lang.data.struct.Size
leaves.append((tname, rva, size))
rpt.p(f"Total resource entries: {len(leaves)}")
rpt.p(f"MANIFEST present: {'yes' if has_manifest else 'no'}")
type_counts = {}
for tname, _, _ in leaves:
type_counts[tname] = type_counts.get(tname, 0) + 1
for tname, count in sorted(type_counts.items()):
rpt.p(f" {tname}: {count}")
rpt.p("Entries larger than 4KB:")
large_found = False
large_entries = []
for tname, rva, size in leaves:
if size > 4096:
large_found = True
try:
data = pe.get_data(rva, size)
ent = shannon_entropy(data)
except Exception as e:
ent = None
flag = " [FLAG: possible embedded payload]" if (ent is not None and ent > 7.0) else ""
ent_str = f"{ent:.2f}" if ent is not None else "n/a"
rpt.p(f" {tname}: {size} bytes, entropy={ent_str}{flag}")
large_entries.append({"type": tname, "size": size, "entropy": ent})
if not large_found:
rpt.p(" none")
rpt.data["resources"] = {
"count": len(leaves), "has_manifest": has_manifest,
"type_counts": type_counts, "large_entries": large_entries,
}
def section_overlay(pe, rpt: Report, path):
rpt.h("Overlay")
try:
overlay_offset = pe.get_overlay_data_start_offset()
except Exception:
overlay_offset = None
if overlay_offset is None:
rpt.p("No overlay.")
rpt.data["overlay"] = {"size": 0}
return
file_size = os.path.getsize(path)
size = file_size - overlay_offset
rpt.p(f"Overlay size: {size} bytes (starts at offset 0x{overlay_offset:x})")
guess = "unknown"
head = b""
if size > 0:
with open(path, "rb") as f:
f.seek(overlay_offset)
head = f.read(8)
hex_head = head.hex()
rpt.p(f"First 8 bytes: {hex_head}")
if head[:2] == b"PK":
guess = "PK zip (possible appended archive/installer payload)"
elif head[:2] == b"MZ":
guess = "MZ (possible embedded PE)"
elif head[:6] == b"7z\xbc\xaf\x27\x1c":
guess = "7z archive"
elif head[:4] == b"UPX!":
guess = "UPX marker"
elif b"NullsoftInst" in head:
guess = "NSIS installer marker"
elif b"Inno Setup" in head:
guess = "Inno Setup marker"
else:
sec_dir = pe.OPTIONAL_HEADER.DATA_DIRECTORY[
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_SECURITY"]
]
if sec_dir.VirtualAddress == overlay_offset:
guess = "Authenticode certificate table (not a separate overlay payload)"
rpt.p(f"Guess: {guess}")
rpt.data["overlay"] = {"size": size, "offset": overlay_offset, "first_bytes": head.hex(), "guess": guess}
def section_pdb(pe, rpt: Report):
rpt.h("PDB")
pdb_path = None
if hasattr(pe, "DIRECTORY_ENTRY_DEBUG"):
for dbg in pe.DIRECTORY_ENTRY_DEBUG:
entry = getattr(dbg, "entry", None)
if entry is not None and hasattr(entry, "PdbFileName"):
pdb_path = decode(entry.PdbFileName)
break
rpt.p(f"PDB path: {pdb_path if pdb_path else 'none found'}")
rpt.data["pdb_path"] = pdb_path
def section_version_info(pe, rpt: Report, path):
rpt.h("Version info")
fields = {}
if hasattr(pe, "FileInfo"):
for fileinfo_list in pe.FileInfo:
for entry in fileinfo_list:
if getattr(entry, "Key", None) == b"StringFileInfo":
for st in entry.StringTable:
for k, v in st.entries.items():
fields[decode(k)] = decode(v)
wanted = ["CompanyName", "ProductName", "OriginalFilename", "FileDescription", "FileVersion"]
any_found = False
for w in wanted:
if w in fields:
any_found = True
rpt.p(f"{w}: {fields[w]}")
if not any_found:
rpt.p("No version info resource found.")
orig = fields.get("OriginalFilename")
if orig and orig.lower() != os.path.basename(path).lower():
rpt.p(f"WARNING: OriginalFilename ('{orig}') does not match actual filename ('{os.path.basename(path)}')")
rpt.data["version_info"] = fields
def section_tls(pe, rpt: Report):
rpt.h("TLS callbacks")
if not hasattr(pe, "DIRECTORY_ENTRY_TLS") or pe.DIRECTORY_ENTRY_TLS is None:
rpt.p("No TLS directory.")
rpt.data["tls_callbacks"] = 0
return
cb_rva = pe.DIRECTORY_ENTRY_TLS.struct.AddressOfCallBacks
if not cb_rva:
rpt.p("TLS directory present but no callbacks.")
rpt.data["tls_callbacks"] = 0
return
image_base = pe.OPTIONAL_HEADER.ImageBase
is_64 = pe.OPTIONAL_HEADER.Magic == 0x20B
read_ptr = pe.get_qword_at_rva if is_64 else pe.get_dword_at_rva
cb_va = cb_rva - image_base if cb_rva > image_base else cb_rva
count = 0
try:
for i in range(100):
ptr = read_ptr(cb_va + i * (8 if is_64 else 4))
if not ptr:
break
count += 1
except Exception:
pass
rpt.p(f"TLS callback count: {count}{' [FLAG: possible anti-debug via TLS callback]' if count else ''}")
rpt.data["tls_callbacks"] = count
def section_rich_header(pe, rpt: Report):
rpt.h("Rich header")
rich = getattr(pe, "RICH_HEADER", None)
if rich is None:
rpt.p("No Rich header.")
rpt.data["rich_header"] = {"present": False}
return
values = getattr(rich, "values", []) or []
compiler_ids = len(values) // 2
rpt.p(f"Rich header present: yes ({compiler_ids} compiler-id entries)")
rpt.data["rich_header"] = {"present": True, "compiler_id_count": compiler_ids}
def analyze(path, as_json):
rpt = Report()
try:
pe = pefile.PE(path, fast_load=True)
except pefile.PEFormatError as e:
print(f"Not a valid PE file: {e}", file=sys.stderr)
return 1
except Exception as e:
print(f"Failed to open file: {e}", file=sys.stderr)
return 1
try:
pe.parse_data_directories()
except Exception as e:
rpt.p(f"warning: parse_data_directories partially failed: {e}")
with open(path, "rb") as f:
overall_entropy = shannon_entropy(f.read())
is_dotnet = False
try:
is_dotnet = bool(section_file_info(pe, path, rpt))
except Exception as e:
rpt.fail("file", e)
sections = []
try:
sections = section_sections(pe, rpt)
except Exception as e:
rpt.fail("sections", e)
total_imports = 0
kernel32_loader_pattern = False
try:
total_imports, kernel32_loader_pattern = section_imports(pe, rpt)
except Exception as e:
rpt.fail("imports", e)
try:
section_packing_verdict(pe, rpt, overall_entropy, sections, total_imports, kernel32_loader_pattern, is_dotnet)
except Exception as e:
rpt.fail("packing_verdict", e)
try:
section_signature(pe, rpt)
except Exception as e:
rpt.fail("signature", e)
try:
section_resources(pe, rpt)
except Exception as e:
rpt.fail("resources", e)
try:
section_overlay(pe, rpt, path)
except Exception as e:
rpt.fail("overlay", e)
try:
section_pdb(pe, rpt)
except Exception as e:
rpt.fail("pdb", e)
try:
section_version_info(pe, rpt, path)
except Exception as e:
rpt.fail("version_info", e)
try:
section_tls(pe, rpt)
except Exception as e:
rpt.fail("tls", e)
try:
section_rich_header(pe, rpt)
except Exception as e:
rpt.fail("rich_header", e)
if as_json:
print(json.dumps(rpt.data, indent=2, default=str))
else:
print("\n".join(rpt.lines))
return 0
def main():
parser = argparse.ArgumentParser(description="Static PE triage summary (never executes the sample).")
parser.add_argument("path", help="Path to the PE file to analyze")
parser.add_argument("--json", action="store_true", help="Output JSON instead of plain text")
args = parser.parse_args()
if not os.path.isfile(args.path):
print(f"No such file: {args.path}", file=sys.stderr)
sys.exit(1)
sys.exit(analyze(args.path, args.json))
if __name__ == "__main__":
main()