winlog-hunter 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- winlog_hunter/__init__.py +3 -0
- winlog_hunter/__main__.py +3 -0
- winlog_hunter/cli.py +153 -0
- winlog_hunter/models.py +64 -0
- winlog_hunter/parsers.py +374 -0
- winlog_hunter/report.py +284 -0
- winlog_hunter/rules.py +1111 -0
- winlog_hunter-0.2.0.dist-info/METADATA +215 -0
- winlog_hunter-0.2.0.dist-info/RECORD +13 -0
- winlog_hunter-0.2.0.dist-info/WHEEL +5 -0
- winlog_hunter-0.2.0.dist-info/entry_points.txt +2 -0
- winlog_hunter-0.2.0.dist-info/licenses/LICENSE +21 -0
- winlog_hunter-0.2.0.dist-info/top_level.txt +1 -0
winlog_hunter/cli.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Interfaz de línea de comandos."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .models import SEVERITIES, SEVERITY_ORDER
|
|
13
|
+
from .parsers import load_events
|
|
14
|
+
from .report import RENDERERS, SEVERITY_LABEL, Stats, render_console
|
|
15
|
+
from .rules import RULES, Config, run_rules
|
|
16
|
+
|
|
17
|
+
FORMATS = ("console", "json", "markdown", "html")
|
|
18
|
+
_EXTENSION_FORMAT = {".json": "json", ".md": "markdown", ".markdown": "markdown", ".html": "html", ".htm": "html"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
22
|
+
parser = argparse.ArgumentParser(
|
|
23
|
+
prog="winlog-hunter",
|
|
24
|
+
description="Analiza logs de Windows (Security, System, Sysmon) y detecta actividad "
|
|
25
|
+
"sospechosa mapeada a MITRE ATT&CK.",
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
28
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
29
|
+
|
|
30
|
+
analyze = sub.add_parser("analyze", help="analiza ficheros o carpetas de logs")
|
|
31
|
+
analyze.add_argument("paths", nargs="+", type=Path, help=".evtx, .xml (wevtutil), .jsonl/.json o carpetas")
|
|
32
|
+
analyze.add_argument("-f", "--format", choices=FORMATS, help="formato de salida (por defecto, según -o o consola)")
|
|
33
|
+
analyze.add_argument("-o", "--output", type=Path, help="fichero donde guardar el informe")
|
|
34
|
+
analyze.add_argument("--min-severity", choices=SEVERITIES, default="low", help="oculta hallazgos por debajo")
|
|
35
|
+
analyze.add_argument("--rules", help="IDs de reglas separados por comas, p. ej. WLH-001,WLH-003")
|
|
36
|
+
analyze.add_argument("--fail-on", choices=SEVERITIES, help="sale con código 1 si hay hallazgos de esa severidad o más")
|
|
37
|
+
analyze.add_argument("--threshold", type=int, default=5, help="fallos para considerar fuerza bruta (5)")
|
|
38
|
+
analyze.add_argument("--spray-accounts", type=int, default=5, help="cuentas distintas para password spraying (5)")
|
|
39
|
+
analyze.add_argument("--window", type=float, default=5, help="ventana de correlación en minutos (5)")
|
|
40
|
+
analyze.add_argument("--lateral-hosts", type=int, default=3, help="equipos distintos para movimiento lateral (3)")
|
|
41
|
+
analyze.add_argument("--no-color", action="store_true", help="desactiva los colores en consola")
|
|
42
|
+
|
|
43
|
+
sub.add_parser("rules", help="lista las reglas de detección")
|
|
44
|
+
return parser
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _enable_ansi() -> bool:
|
|
48
|
+
if os.name != "nt":
|
|
49
|
+
return True
|
|
50
|
+
try:
|
|
51
|
+
import ctypes
|
|
52
|
+
|
|
53
|
+
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
54
|
+
handle = kernel32.GetStdHandle(-11)
|
|
55
|
+
mode = ctypes.c_uint32()
|
|
56
|
+
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
57
|
+
return False
|
|
58
|
+
return bool(kernel32.SetConsoleMode(handle, mode.value | 0x0004))
|
|
59
|
+
except Exception:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _list_rules() -> int:
|
|
64
|
+
for r in RULES:
|
|
65
|
+
print(f"{r.id} [{SEVERITY_LABEL[r.severity]}] {', '.join(r.mitre)}")
|
|
66
|
+
print(f" {r.title}: {r.description}")
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _analyze(args: argparse.Namespace) -> int:
|
|
71
|
+
known = {r.id for r in RULES}
|
|
72
|
+
only = None
|
|
73
|
+
if args.rules:
|
|
74
|
+
only = {rid.strip().upper() for rid in args.rules.split(",") if rid.strip()}
|
|
75
|
+
unknown = only - known
|
|
76
|
+
if unknown:
|
|
77
|
+
print(f"error: reglas desconocidas: {', '.join(sorted(unknown))}", file=sys.stderr)
|
|
78
|
+
return 2
|
|
79
|
+
for name in ("threshold", "spray_accounts", "lateral_hosts"):
|
|
80
|
+
if getattr(args, name) < 1:
|
|
81
|
+
print(f"error: --{name.replace('_', '-')} debe ser al menos 1", file=sys.stderr)
|
|
82
|
+
return 2
|
|
83
|
+
if args.window <= 0:
|
|
84
|
+
print("error: --window debe ser mayor que 0", file=sys.stderr)
|
|
85
|
+
return 2
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
loaded = load_events(args.paths)
|
|
89
|
+
except (FileNotFoundError, PermissionError) as exc:
|
|
90
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
91
|
+
return 2
|
|
92
|
+
if not loaded.events and loaded.warnings:
|
|
93
|
+
print("error: no se pudo leer ningún evento:", file=sys.stderr)
|
|
94
|
+
for warning in loaded.warnings[:10]:
|
|
95
|
+
print(f" - {warning}", file=sys.stderr)
|
|
96
|
+
return 2
|
|
97
|
+
|
|
98
|
+
config = Config(
|
|
99
|
+
window=timedelta(minutes=args.window),
|
|
100
|
+
brute_force_threshold=args.threshold,
|
|
101
|
+
spray_min_accounts=args.spray_accounts,
|
|
102
|
+
lateral_min_hosts=args.lateral_hosts,
|
|
103
|
+
)
|
|
104
|
+
all_findings = run_rules(loaded.events, config, only)
|
|
105
|
+
minimum = SEVERITY_ORDER[args.min_severity]
|
|
106
|
+
findings = [f for f in all_findings if f.rank >= minimum]
|
|
107
|
+
|
|
108
|
+
stats = Stats(
|
|
109
|
+
events=len(loaded.events),
|
|
110
|
+
files=loaded.files,
|
|
111
|
+
first_event=loaded.events[0].timestamp if loaded.events else None,
|
|
112
|
+
last_event=loaded.events[-1].timestamp if loaded.events else None,
|
|
113
|
+
warnings=loaded.warnings,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
fmt = args.format or (_EXTENSION_FORMAT.get(args.output.suffix.lower(), "console") if args.output else "console")
|
|
117
|
+
if fmt == "console":
|
|
118
|
+
color = (
|
|
119
|
+
not args.no_color
|
|
120
|
+
and args.output is None
|
|
121
|
+
and "NO_COLOR" not in os.environ
|
|
122
|
+
and sys.stdout.isatty()
|
|
123
|
+
and _enable_ansi()
|
|
124
|
+
)
|
|
125
|
+
text = render_console(findings, stats, color=color)
|
|
126
|
+
else:
|
|
127
|
+
text = RENDERERS[fmt](findings, stats)
|
|
128
|
+
|
|
129
|
+
if args.output:
|
|
130
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
args.output.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
|
132
|
+
print(f"Informe {fmt} guardado en {args.output} ({len(findings)} hallazgos)", file=sys.stderr)
|
|
133
|
+
else:
|
|
134
|
+
print(text)
|
|
135
|
+
|
|
136
|
+
# --fail-on es independiente de lo que se muestra: se evalúa sobre todos los hallazgos.
|
|
137
|
+
if args.fail_on and any(f.rank >= SEVERITY_ORDER[args.fail_on] for f in all_findings):
|
|
138
|
+
return 1
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main(argv: list[str] | None = None) -> int:
|
|
143
|
+
for stream in (sys.stdout, sys.stderr):
|
|
144
|
+
if hasattr(stream, "reconfigure"):
|
|
145
|
+
stream.reconfigure(errors="replace")
|
|
146
|
+
args = build_parser().parse_args(argv)
|
|
147
|
+
if args.command == "rules":
|
|
148
|
+
return _list_rules()
|
|
149
|
+
return _analyze(args)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
raise SystemExit(main())
|
winlog_hunter/models.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Modelos de datos: eventos normalizados y hallazgos."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
SEVERITY_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3}
|
|
10
|
+
SEVERITIES = tuple(SEVERITY_ORDER)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Event:
|
|
15
|
+
"""Evento de Windows normalizado, venga de EVTX, XML o JSON."""
|
|
16
|
+
|
|
17
|
+
timestamp: datetime
|
|
18
|
+
event_id: int
|
|
19
|
+
computer: str = ""
|
|
20
|
+
channel: str = ""
|
|
21
|
+
provider: str = ""
|
|
22
|
+
data: dict[str, str] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
def get(self, *keys: str) -> str:
|
|
25
|
+
"""Devuelve el primer campo con un valor útil (ignora vacíos y '-')."""
|
|
26
|
+
for key in keys:
|
|
27
|
+
value = self.data.get(key)
|
|
28
|
+
if value is not None and value.strip() not in ("", "-"):
|
|
29
|
+
return value.strip()
|
|
30
|
+
return ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Finding:
|
|
35
|
+
"""Resultado de una regla de detección."""
|
|
36
|
+
|
|
37
|
+
rule_id: str
|
|
38
|
+
title: str
|
|
39
|
+
severity: str
|
|
40
|
+
mitre: list[str]
|
|
41
|
+
summary: str
|
|
42
|
+
first_seen: datetime
|
|
43
|
+
last_seen: datetime
|
|
44
|
+
computers: list[str]
|
|
45
|
+
evidence: dict[str, Any]
|
|
46
|
+
event_count: int
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def rank(self) -> int:
|
|
50
|
+
return SEVERITY_ORDER[self.severity]
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict[str, Any]:
|
|
53
|
+
return {
|
|
54
|
+
"rule_id": self.rule_id,
|
|
55
|
+
"title": self.title,
|
|
56
|
+
"severity": self.severity,
|
|
57
|
+
"mitre": self.mitre,
|
|
58
|
+
"summary": self.summary,
|
|
59
|
+
"first_seen": self.first_seen.isoformat(),
|
|
60
|
+
"last_seen": self.last_seen.isoformat(),
|
|
61
|
+
"computers": self.computers,
|
|
62
|
+
"event_count": self.event_count,
|
|
63
|
+
"evidence": self.evidence,
|
|
64
|
+
}
|
winlog_hunter/parsers.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Lectura y normalización de logs de Windows.
|
|
2
|
+
|
|
3
|
+
Formatos admitidos:
|
|
4
|
+
* .evtx -> ficheros nativos del Visor de eventos (requiere ``python-evtx``).
|
|
5
|
+
* .xml -> salida de ``wevtutil qe <log> /f:xml`` o eventos exportados en XML.
|
|
6
|
+
* .jsonl / .ndjson / .json -> un evento JSON por línea, un array JSON o un único objeto.
|
|
7
|
+
|
|
8
|
+
Todo lo que se lee de un log se trata como dato no confiable: no se resuelven
|
|
9
|
+
entidades XML y se rechazan documentos con DOCTYPE.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import locale
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import xml.etree.ElementTree as ET
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Callable, Iterable, Iterator
|
|
25
|
+
|
|
26
|
+
from .models import Event
|
|
27
|
+
|
|
28
|
+
SUPPORTED_SUFFIXES = {".evtx", ".xml", ".jsonl", ".ndjson", ".json"}
|
|
29
|
+
|
|
30
|
+
Warn = Callable[[str], None]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ParseError(ValueError):
|
|
34
|
+
"""Un registro no se pudo interpretar como evento de Windows."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class LoadResult:
|
|
39
|
+
events: list[Event] = field(default_factory=list)
|
|
40
|
+
files: int = 0
|
|
41
|
+
warnings: list[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --------------------------------------------------------------------------- #
|
|
45
|
+
# Fechas
|
|
46
|
+
# --------------------------------------------------------------------------- #
|
|
47
|
+
|
|
48
|
+
_MS_DATE = re.compile(r"/Date\((-?\d+)\)/")
|
|
49
|
+
_FRACTION = re.compile(r"\.(\d+)")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_timestamp(value: Any) -> datetime:
|
|
53
|
+
"""Convierte las variantes habituales de fecha de Windows a datetime UTC."""
|
|
54
|
+
if isinstance(value, datetime):
|
|
55
|
+
dt = value
|
|
56
|
+
elif isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
57
|
+
seconds = value / 1000 if value > 1e11 else value # epoch en ms o en s
|
|
58
|
+
dt = datetime.fromtimestamp(seconds, tz=timezone.utc)
|
|
59
|
+
elif isinstance(value, str):
|
|
60
|
+
text = value.strip()
|
|
61
|
+
ms = _MS_DATE.fullmatch(text) # ConvertTo-Json de PowerShell 5.1
|
|
62
|
+
if ms:
|
|
63
|
+
return datetime.fromtimestamp(int(ms.group(1)) / 1000, tz=timezone.utc)
|
|
64
|
+
if text[-1:] in ("Z", "z"):
|
|
65
|
+
text = text[:-1] + "+00:00"
|
|
66
|
+
# Windows usa 7 decimales; fromisoformat (3.10) solo acepta 3 o 6.
|
|
67
|
+
text = _FRACTION.sub(lambda m: "." + (m.group(1) + "000000")[:6], text, count=1)
|
|
68
|
+
try:
|
|
69
|
+
dt = datetime.fromisoformat(text)
|
|
70
|
+
except ValueError as exc:
|
|
71
|
+
raise ParseError(f"fecha no reconocida: {value!r}") from exc
|
|
72
|
+
else:
|
|
73
|
+
raise ParseError(f"fecha no reconocida: {value!r}")
|
|
74
|
+
if dt.tzinfo is None:
|
|
75
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
76
|
+
return dt.astimezone(timezone.utc)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --------------------------------------------------------------------------- #
|
|
80
|
+
# JSON
|
|
81
|
+
# --------------------------------------------------------------------------- #
|
|
82
|
+
|
|
83
|
+
_ID_KEYS = ("EventID", "EventId", "Id", "event_id", "eventId", "event.code")
|
|
84
|
+
_TIME_KEYS = ("TimeCreated", "SystemTime", "@timestamp", "timestamp", "time", "UtcTime")
|
|
85
|
+
_COMPUTER_KEYS = ("Computer", "MachineName", "computer", "hostname", "host")
|
|
86
|
+
_CHANNEL_KEYS = ("Channel", "LogName", "channel", "log_name")
|
|
87
|
+
_PROVIDER_KEYS = ("Provider", "ProviderName", "provider", "SourceName")
|
|
88
|
+
_NESTED_KEYS = ("EventData", "event_data", "UserData", "data")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _pop_first(record: dict[str, Any], keys: Iterable[str]) -> Any:
|
|
92
|
+
for key in keys:
|
|
93
|
+
if key in record and record[key] not in (None, ""):
|
|
94
|
+
return record.pop(key)
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _scalar(value: Any) -> Any:
|
|
99
|
+
"""Acepta valores tipo {'#text': '4625'} de conversores XML->JSON."""
|
|
100
|
+
if isinstance(value, dict):
|
|
101
|
+
for key in ("#text", "value", "Value", "Name"):
|
|
102
|
+
if key in value:
|
|
103
|
+
return value[key]
|
|
104
|
+
return value
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
_WINLOG_FIELDS = {
|
|
108
|
+
"event_id": "EventID",
|
|
109
|
+
"channel": "Channel",
|
|
110
|
+
"computer_name": "Computer",
|
|
111
|
+
"provider_name": "Provider",
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _flatten_beats(rec: dict[str, Any]) -> dict[str, Any]:
|
|
116
|
+
"""Adapta el formato de Winlogbeat/ECS (winlog.*, host.name, event.code) al plano."""
|
|
117
|
+
winlog = rec.pop("winlog", None)
|
|
118
|
+
if isinstance(winlog, dict):
|
|
119
|
+
for source, target in _WINLOG_FIELDS.items():
|
|
120
|
+
if winlog.get(source) not in (None, ""):
|
|
121
|
+
rec.setdefault(target, winlog[source])
|
|
122
|
+
for key in ("event_data", "user_data"):
|
|
123
|
+
if isinstance(winlog.get(key), dict):
|
|
124
|
+
rec.setdefault("EventData", {})
|
|
125
|
+
if isinstance(rec["EventData"], dict):
|
|
126
|
+
for k, v in winlog[key].items():
|
|
127
|
+
rec["EventData"].setdefault(k, v)
|
|
128
|
+
host = rec.get("host")
|
|
129
|
+
if isinstance(host, dict):
|
|
130
|
+
rec.pop("host")
|
|
131
|
+
name = host.get("name") or host.get("hostname")
|
|
132
|
+
if name:
|
|
133
|
+
rec.setdefault("Computer", name)
|
|
134
|
+
event = rec.get("event")
|
|
135
|
+
if isinstance(event, dict):
|
|
136
|
+
rec.pop("event")
|
|
137
|
+
if event.get("code") not in (None, ""):
|
|
138
|
+
rec.setdefault("EventID", event["code"])
|
|
139
|
+
if event.get("provider"):
|
|
140
|
+
rec.setdefault("Provider", event["provider"])
|
|
141
|
+
return rec
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def event_from_dict(record: dict[str, Any]) -> Event:
|
|
145
|
+
if not isinstance(record, dict):
|
|
146
|
+
raise ParseError("el registro no es un objeto JSON")
|
|
147
|
+
rec = _flatten_beats(dict(record))
|
|
148
|
+
event_id = _scalar(_pop_first(rec, _ID_KEYS))
|
|
149
|
+
timestamp = _scalar(_pop_first(rec, _TIME_KEYS))
|
|
150
|
+
if event_id is None or timestamp is None:
|
|
151
|
+
raise ParseError("falta EventID o TimeCreated")
|
|
152
|
+
try:
|
|
153
|
+
event_id = int(event_id)
|
|
154
|
+
except (TypeError, ValueError) as exc:
|
|
155
|
+
raise ParseError(f"EventID no numérico: {event_id!r}") from exc
|
|
156
|
+
|
|
157
|
+
computer = _scalar(_pop_first(rec, _COMPUTER_KEYS)) or ""
|
|
158
|
+
channel = _scalar(_pop_first(rec, _CHANNEL_KEYS)) or ""
|
|
159
|
+
provider = _scalar(_pop_first(rec, _PROVIDER_KEYS)) or ""
|
|
160
|
+
|
|
161
|
+
data: dict[str, str] = {}
|
|
162
|
+
for key in _NESTED_KEYS:
|
|
163
|
+
nested = rec.pop(key, None)
|
|
164
|
+
if isinstance(nested, dict):
|
|
165
|
+
for k, v in nested.items():
|
|
166
|
+
if not isinstance(v, (dict, list)):
|
|
167
|
+
data[str(k)] = "" if v is None else str(v)
|
|
168
|
+
for k, v in rec.items():
|
|
169
|
+
if not isinstance(v, (dict, list)):
|
|
170
|
+
data.setdefault(str(k), "" if v is None else str(v))
|
|
171
|
+
|
|
172
|
+
return Event(
|
|
173
|
+
timestamp=parse_timestamp(timestamp),
|
|
174
|
+
event_id=event_id,
|
|
175
|
+
computer=str(computer),
|
|
176
|
+
channel=str(channel),
|
|
177
|
+
provider=str(provider),
|
|
178
|
+
data=data,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def load_json(path: Path, warn: Warn) -> Iterator[Event]:
|
|
183
|
+
text = _read_text(path)
|
|
184
|
+
stripped = text.lstrip()
|
|
185
|
+
|
|
186
|
+
if stripped.startswith("[") or stripped.startswith("{"):
|
|
187
|
+
try:
|
|
188
|
+
parsed = json.loads(stripped)
|
|
189
|
+
except json.JSONDecodeError:
|
|
190
|
+
parsed = None # probablemente JSONL: se lee línea a línea
|
|
191
|
+
if isinstance(parsed, dict):
|
|
192
|
+
parsed = [parsed]
|
|
193
|
+
if isinstance(parsed, list):
|
|
194
|
+
for index, record in enumerate(parsed, 1):
|
|
195
|
+
try:
|
|
196
|
+
yield event_from_dict(record)
|
|
197
|
+
except ParseError as exc:
|
|
198
|
+
warn(f"{path.name}[{index}]: {exc}")
|
|
199
|
+
return
|
|
200
|
+
if stripped.startswith("["):
|
|
201
|
+
warn(f"{path.name}: JSON inválido")
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
for number, line in enumerate(text.splitlines(), 1):
|
|
205
|
+
line = line.strip()
|
|
206
|
+
if not line:
|
|
207
|
+
continue
|
|
208
|
+
try:
|
|
209
|
+
yield event_from_dict(json.loads(line))
|
|
210
|
+
except (json.JSONDecodeError, ParseError) as exc:
|
|
211
|
+
warn(f"{path.name}:{number}: {exc}")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# --------------------------------------------------------------------------- #
|
|
215
|
+
# XML / EVTX
|
|
216
|
+
# --------------------------------------------------------------------------- #
|
|
217
|
+
|
|
218
|
+
_XML_DECL = re.compile(r"<\?xml[^>]*\?>")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _strip_namespaces(root: ET.Element) -> None:
|
|
222
|
+
for element in root.iter():
|
|
223
|
+
if isinstance(element.tag, str) and "}" in element.tag:
|
|
224
|
+
element.tag = element.tag.split("}", 1)[1]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def event_from_xml(element: ET.Element) -> Event:
|
|
228
|
+
"""Convierte un nodo <Event> (sin namespaces) en Event."""
|
|
229
|
+
system = element.find("System")
|
|
230
|
+
if system is None:
|
|
231
|
+
raise ParseError("evento sin nodo <System>")
|
|
232
|
+
event_id = (system.findtext("EventID") or "").strip()
|
|
233
|
+
created = system.find("TimeCreated")
|
|
234
|
+
if not event_id or created is None or not created.get("SystemTime"):
|
|
235
|
+
raise ParseError("falta EventID o TimeCreated")
|
|
236
|
+
provider = system.find("Provider")
|
|
237
|
+
|
|
238
|
+
data: dict[str, str] = {}
|
|
239
|
+
event_data = element.find("EventData")
|
|
240
|
+
if event_data is not None:
|
|
241
|
+
for index, node in enumerate(event_data.findall("Data")):
|
|
242
|
+
name = node.get("Name") or f"Data{index}"
|
|
243
|
+
data[name] = (node.text or "").strip()
|
|
244
|
+
user_data = element.find("UserData")
|
|
245
|
+
if user_data is not None:
|
|
246
|
+
for node in user_data.iter():
|
|
247
|
+
if node is not user_data and len(node) == 0:
|
|
248
|
+
data.setdefault(node.tag, (node.text or "").strip())
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
numeric_id = int(event_id)
|
|
252
|
+
except ValueError as exc:
|
|
253
|
+
raise ParseError(f"EventID no numérico: {event_id!r}") from exc
|
|
254
|
+
|
|
255
|
+
return Event(
|
|
256
|
+
timestamp=parse_timestamp(created.get("SystemTime")),
|
|
257
|
+
event_id=numeric_id,
|
|
258
|
+
computer=(system.findtext("Computer") or "").strip(),
|
|
259
|
+
channel=(system.findtext("Channel") or "").strip(),
|
|
260
|
+
provider=provider.get("Name", "") if provider is not None else "",
|
|
261
|
+
data=data,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def load_xml(path: Path, warn: Warn) -> Iterator[Event]:
|
|
266
|
+
yield from _events_from_xml_text(_read_text(path), path, warn)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _events_from_xml_text(text: str, path: Path, warn: Warn) -> Iterator[Event]:
|
|
270
|
+
if "<!DOCTYPE" in text.upper():
|
|
271
|
+
warn(f"{path.name}: se rechaza XML con DOCTYPE (posible ataque de entidades)")
|
|
272
|
+
return
|
|
273
|
+
# wevtutil concatena <Event> sin nodo raíz: se envuelve siempre.
|
|
274
|
+
body = _XML_DECL.sub("", text)
|
|
275
|
+
try:
|
|
276
|
+
root = ET.fromstring(f"<WlhRoot>{body}</WlhRoot>")
|
|
277
|
+
except ET.ParseError as exc:
|
|
278
|
+
warn(f"{path.name}: XML inválido ({exc})")
|
|
279
|
+
return
|
|
280
|
+
_strip_namespaces(root)
|
|
281
|
+
for index, element in enumerate(root.iter("Event"), 1):
|
|
282
|
+
try:
|
|
283
|
+
yield event_from_xml(element)
|
|
284
|
+
except ParseError as exc:
|
|
285
|
+
warn(f"{path.name}[evento {index}]: {exc}")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _wevtutil_xml(path: Path) -> str | None:
|
|
289
|
+
"""En Windows, convierte el .evtx a XML con wevtutil (cientos de veces más rápido
|
|
290
|
+
que python-evtx y lee registros que este no entiende). None si no está disponible."""
|
|
291
|
+
if os.name != "nt" or shutil.which("wevtutil") is None:
|
|
292
|
+
return None
|
|
293
|
+
try:
|
|
294
|
+
proc = subprocess.run(
|
|
295
|
+
["wevtutil", "qe", str(path.resolve()), "/lf:true", "/f:xml"],
|
|
296
|
+
capture_output=True,
|
|
297
|
+
timeout=900,
|
|
298
|
+
check=False,
|
|
299
|
+
)
|
|
300
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
301
|
+
return None
|
|
302
|
+
if proc.returncode != 0:
|
|
303
|
+
return None
|
|
304
|
+
try:
|
|
305
|
+
return proc.stdout.decode("utf-8")
|
|
306
|
+
except UnicodeDecodeError:
|
|
307
|
+
# wevtutil escribe en la página de códigos ANSI del sistema (p. ej. cp1252).
|
|
308
|
+
return proc.stdout.decode(locale.getpreferredencoding(False), errors="replace")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def load_evtx(path: Path, warn: Warn) -> Iterator[Event]:
|
|
312
|
+
text = _wevtutil_xml(path)
|
|
313
|
+
if text is not None:
|
|
314
|
+
yield from _events_from_xml_text(text, path, warn)
|
|
315
|
+
return
|
|
316
|
+
try:
|
|
317
|
+
from Evtx.Evtx import Evtx # type: ignore[import-not-found]
|
|
318
|
+
except ImportError:
|
|
319
|
+
warn(
|
|
320
|
+
f"{path.name}: para leer .evtx fuera de Windows instala 'python-evtx' "
|
|
321
|
+
"(pip install winlog-hunter[evtx]) o exporta el log con wevtutil a XML"
|
|
322
|
+
)
|
|
323
|
+
return
|
|
324
|
+
with Evtx(str(path)) as log:
|
|
325
|
+
for index, record in enumerate(log.records(), 1):
|
|
326
|
+
try:
|
|
327
|
+
element = ET.fromstring(record.xml())
|
|
328
|
+
_strip_namespaces(element)
|
|
329
|
+
yield event_from_xml(element)
|
|
330
|
+
except (ET.ParseError, ParseError, Exception) as exc: # registros corruptos
|
|
331
|
+
warn(f"{path.name}[registro {index}]: {exc}")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# --------------------------------------------------------------------------- #
|
|
335
|
+
# Entrada principal
|
|
336
|
+
# --------------------------------------------------------------------------- #
|
|
337
|
+
|
|
338
|
+
_LOADERS = {
|
|
339
|
+
".evtx": load_evtx,
|
|
340
|
+
".xml": load_xml,
|
|
341
|
+
".json": load_json,
|
|
342
|
+
".jsonl": load_json,
|
|
343
|
+
".ndjson": load_json,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _read_text(path: Path) -> str:
|
|
348
|
+
"""Lee texto detectando el BOM (PowerShell 5.1 escribe UTF-16 con '>')."""
|
|
349
|
+
raw = path.read_bytes()
|
|
350
|
+
if raw.startswith((b"\xff\xfe", b"\xfe\xff")):
|
|
351
|
+
return raw.decode("utf-16")
|
|
352
|
+
return raw.decode("utf-8-sig", errors="replace")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def iter_files(paths: Iterable[Path]) -> Iterator[Path]:
|
|
356
|
+
for path in paths:
|
|
357
|
+
if path.is_dir():
|
|
358
|
+
yield from sorted(
|
|
359
|
+
p for p in path.rglob("*") if p.is_file() and p.suffix.lower() in SUPPORTED_SUFFIXES
|
|
360
|
+
)
|
|
361
|
+
elif path.is_file():
|
|
362
|
+
yield path
|
|
363
|
+
else:
|
|
364
|
+
raise FileNotFoundError(f"no existe: {path}")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def load_events(paths: Iterable[Path]) -> LoadResult:
|
|
368
|
+
result = LoadResult()
|
|
369
|
+
for path in iter_files(paths):
|
|
370
|
+
result.files += 1
|
|
371
|
+
loader = _LOADERS.get(path.suffix.lower(), load_json)
|
|
372
|
+
result.events.extend(loader(path, result.warnings.append))
|
|
373
|
+
result.events.sort(key=lambda e: e.timestamp)
|
|
374
|
+
return result
|