122 lines
3.9 KiB
Python
Executable File
122 lines
3.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""First-pass triage for an unknown file: type, hashes, entropy, and strings.
|
|
|
|
Pure static analysis on file bytes. Optionally checks a hash against VirusTotal if a key is
|
|
provided (hash lookup only; it never uploads the file). It never executes the sample.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import re
|
|
import sys
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
# magic-byte signatures -> label
|
|
_MAGIC = [
|
|
(b"MZ", "PE/DOS executable"),
|
|
(b"\x7fELF", "ELF executable"),
|
|
(b"\xca\xfe\xba\xbe", "Mach-O (fat) / Java class"),
|
|
(b"\xfe\xed\xfa", "Mach-O"),
|
|
(b"%PDF", "PDF document"),
|
|
(b"PK\x03\x04", "ZIP/OOXML/JAR archive"),
|
|
(b"\xd0\xcf\x11\xe0", "OLE2 (legacy Office)"),
|
|
(b"Rar!", "RAR archive"),
|
|
(b"\x1f\x8b", "gzip"),
|
|
]
|
|
|
|
_SUSPICIOUS = re.compile(
|
|
rb"(https?://|cmd\.exe|powershell|VirtualAlloc|CreateRemoteThread|WScript|"
|
|
rb"RegSetValue|URLDownloadToFile|\\\\\.\\pipe\\|-enc |FromBase64String)",
|
|
re.I,
|
|
)
|
|
|
|
|
|
def shannon_entropy(data: bytes) -> float:
|
|
if not data:
|
|
return 0.0
|
|
counts = Counter(data)
|
|
length = len(data)
|
|
return -sum((c / length) * math.log2(c / length) for c in counts.values())
|
|
|
|
|
|
def identify(data: bytes) -> str:
|
|
for magic, label in _MAGIC:
|
|
if data.startswith(magic):
|
|
return label
|
|
return "unknown"
|
|
|
|
|
|
def extract_strings(data: bytes, minlen: int = 5) -> list[str]:
|
|
found = re.findall(rb"[\x20-\x7e]{%d,}" % minlen, data)
|
|
return [s.decode("ascii", "replace") for s in found]
|
|
|
|
|
|
def suspicious_strings(data: bytes) -> list[str]:
|
|
return sorted({m.group(0).decode("ascii", "replace") for m in _SUSPICIOUS.finditer(data)})
|
|
|
|
|
|
def triage(filepath: str | Path) -> dict:
|
|
data = Path(filepath).read_bytes()
|
|
ent = shannon_entropy(data)
|
|
sus = suspicious_strings(data)
|
|
embedded_pe = data.count(b"MZ") > 1
|
|
score = 0
|
|
score += 2 if ent > 7.2 else 0
|
|
score += 2 if sus else 0
|
|
score += 2 if embedded_pe else 0
|
|
verdict = "deep-analysis" if score >= 4 else "review" if score >= 2 else "low-signal"
|
|
return {
|
|
"file": str(filepath),
|
|
"type": identify(data),
|
|
"size": len(data),
|
|
"sha256": hashlib.sha256(data).hexdigest(),
|
|
"entropy": round(ent, 3),
|
|
"likely_packed": ent > 7.2,
|
|
"embedded_pe": embedded_pe,
|
|
"suspicious_strings": sus,
|
|
"triage_score": score,
|
|
"recommendation": verdict,
|
|
}
|
|
|
|
|
|
def reputation(sha256: str, vt_key: str) -> dict:
|
|
try:
|
|
import urllib.request
|
|
req = urllib.request.Request(
|
|
f"https://www.virustotal.com/api/v3/files/{sha256}",
|
|
headers={"x-apikey": vt_key},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=20) as resp: # noqa: S310
|
|
body = json.loads(resp.read())
|
|
stats = body["data"]["attributes"]["last_analysis_stats"]
|
|
return {"sha256": sha256, "found": True, "stats": stats}
|
|
except Exception as exc: # noqa: BLE001 - report, do not crash triage
|
|
return {"sha256": sha256, "found": False, "error": str(exc)}
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description="Triage an unknown sample")
|
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
|
|
p_tri = sub.add_parser("triage", help="static triage of a file")
|
|
p_tri.add_argument("file")
|
|
|
|
p_rep = sub.add_parser("reputation", help="VirusTotal hash lookup")
|
|
p_rep.add_argument("--sha256", required=True)
|
|
p_rep.add_argument("--vt-key", required=True)
|
|
|
|
args = parser.parse_args(argv)
|
|
if args.cmd == "triage":
|
|
print(json.dumps(triage(args.file), indent=2))
|
|
elif args.cmd == "reputation":
|
|
print(json.dumps(reputation(args.sha256, args.vt_key), indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|