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.
@@ -0,0 +1,3 @@
1
+ """winlog-hunter: detección de amenazas en logs de Windows mapeada a MITRE ATT&CK."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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())
@@ -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
+ }
@@ -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