86 lines
3.2 KiB
Python
Executable File
86 lines
3.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Extract and triage VBA macros from Office documents.
|
|
|
|
Prefers ``oletools`` (olevba) for robust extraction; falls back to locating ``vbaProject.bin``
|
|
inside OOXML (ZIP) containers and flagging suspicious indicators in the raw bytes. Never opens
|
|
the document in Office and never executes anything.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
AUTOEXEC = ["AutoOpen", "Document_Open", "AutoClose", "Document_Close",
|
|
"Workbook_Open", "Auto_Open", "Auto_Close"]
|
|
SUSPICIOUS = ["Shell", "WScript.Shell", "CreateObject", "powershell", "cmd",
|
|
"URLDownloadToFile", "XMLHTTP", "ADODB.Stream", "Environ",
|
|
"winmgmts", "Base64", "Chr(", "GetObject"]
|
|
|
|
|
|
def _scan_text(text: str) -> dict:
|
|
found_auto = [k for k in AUTOEXEC if k.lower() in text.lower()]
|
|
found_sus = [k for k in SUSPICIOUS if k.lower() in text.lower()]
|
|
urls = sorted(set(re.findall(r"https?://[^\s\"'<>)]+", text)))
|
|
return {"auto_exec": found_auto, "suspicious": found_sus, "urls": urls}
|
|
|
|
|
|
def extract_with_olevba(path: str) -> dict:
|
|
from oletools.olevba import VBA_Parser # type: ignore
|
|
vp = VBA_Parser(path)
|
|
macros = []
|
|
if vp.detect_vba_macros():
|
|
for _, _, name, code in vp.extract_macros():
|
|
macros.append({"name": name, **_scan_text(code),
|
|
"lines": code.count("\n") + 1})
|
|
vp.close()
|
|
return {"engine": "olevba", "has_macros": bool(macros), "macros": macros}
|
|
|
|
|
|
def extract_minimal(path: str) -> dict:
|
|
p = Path(path)
|
|
data = p.read_bytes()
|
|
indicators = {}
|
|
if zipfile.is_zipfile(path):
|
|
with zipfile.ZipFile(path) as zf:
|
|
vba = [n for n in zf.namelist() if n.lower().endswith("vbaproject.bin")]
|
|
if not vba:
|
|
return {"engine": "minimal", "has_macros": False,
|
|
"note": "no vbaProject.bin in OOXML container"}
|
|
blob = zf.read(vba[0])
|
|
# vbaProject.bin is OLE/compressed; scan recoverable ASCII for indicators
|
|
text = blob.decode("latin-1", "replace")
|
|
indicators = _scan_text(text)
|
|
return {"engine": "minimal", "has_macros": True,
|
|
"stream": vba[0], "indicators": indicators,
|
|
"note": "install oletools for full VBA source extraction"}
|
|
# legacy OLE2: scan whole file bytes for indicator strings
|
|
text = data.decode("latin-1", "replace")
|
|
return {"engine": "minimal", "has_macros": b"Attribut" in data,
|
|
"indicators": _scan_text(text),
|
|
"note": "legacy OLE2; install oletools for reliable extraction"}
|
|
|
|
|
|
def analyze(path: str) -> dict:
|
|
try:
|
|
return extract_with_olevba(path)
|
|
except ImportError:
|
|
return extract_minimal(path)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description="Analyze Office macros")
|
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
p = sub.add_parser("extract")
|
|
p.add_argument("file")
|
|
args = parser.parse_args(argv)
|
|
print(json.dumps(analyze(args.file), indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|