wtftools 0.0.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.
wtftools/cron.py ADDED
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Crontab checking — vendored from checkcrontab, trimmed for wtftools."""
4
+
5
+ import logging
6
+ import os
7
+ import platform
8
+ import re
9
+ import stat
10
+ import subprocess
11
+ import tempfile
12
+ import traceback
13
+ from typing import List, Optional, Tuple
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ RANGE_PARTS_COUNT = 2
18
+ CRONTAB_PERMISSIONS = 0o644
19
+ CRONTAB_OWNER_UID = int(os.getenv("CRONTAB_OWNER_UID", "0"))
20
+ USER_CRONTAB_MIN_FIELDS = 6
21
+ SYSTEM_CRONTAB_MIN_FIELDS = 7
22
+ SYSTEM_CRONTAB_MAX_FIELDS = 7
23
+ SPECIAL_KEYWORD_MIN_FIELDS = 2
24
+ SYSTEM_SPECIAL_MIN_FIELDS = 3
25
+
26
+ MINUTE_PATTERN = r"^(\*|([0-5]?[0-9])(-([0-5]?[0-9]))?(/([0-9]+))?(,([0-5]?[0-9])(-([0-5]?[0-9]))?(/([0-9]+))?)*|\*/([0-9]+))$"
27
+ HOUR_PATTERN = r"^(\*|([0-9]|1[0-9]|2[0-3])(-([0-9]|1[0-9]|2[0-3]))?(/([0-9]|1[0-9]|2[0-3]))?(,([0-9]|1[0-9]|2[0-3])(-([0-9]|1[0-9]|2[0-3]))?(/([0-9]|1[0-9]|2[0-3]))?)*|\*/([0-9]|1[0-9]|2[0-3]))$"
28
+ DAY_PATTERN = r"^(\*|([1-9]|[12][0-9]|3[01])(-([1-9]|[12][0-9]|3[01]))?(/([1-9]|[12][0-9]|3[01]))?(,([1-9]|[12][0-9]|3[01])(-([1-9]|[12][0-9]|3[01]))?(/([1-9]|[12][0-9]|3[01]))?)*|\*/([1-9]|[12][0-9]|3[01]))$"
29
+ MONTH_PATTERN = r"^(\*|([1-9]|1[0-2])(-([1-9]|1[0-2]))?(/([1-9]|1[0-2]))?(,([1-9]|1[0-2])(-([1-9]|1[0-2]))?(/([1-9]|1[0-2]))?)*|\*/([1-9]|1[0-2]))$"
30
+ WEEKDAY_PATTERN = r"^(\*|([0-7])(-([0-7]))?(/([0-7]))?(,([0-7])(-([0-7]))?)*|\*/([0-7]))$"
31
+ INVALID_NAME_ALLOWED_RE = r"^[A-Za-z0-9_-]+$"
32
+
33
+ VALID_KEYWORDS = ["@reboot", "@yearly", "@annually", "@monthly", "@weekly", "@daily", "@midnight", "@hourly"]
34
+
35
+ DANGEROUS_PATTERNS = [
36
+ (r"\brm\s+-rf\s+/", "dangerous command: 'rm -rf /'"),
37
+ (r":\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;", "dangerous fork bomb"),
38
+ (r"\bmkfs\.\w+\s+/dev/", "dangerous: filesystem creation"),
39
+ (r"\bdd\s+if=.*\s+of=/dev/(sd|nvme|hd)", "dangerous: dd to raw disk"),
40
+ ]
41
+
42
+
43
+ def check_filename(file_path: str) -> str:
44
+ name = os.path.basename(file_path or "")
45
+ if not name:
46
+ return f"{file_path} empty name filename"
47
+ if name.startswith("."):
48
+ return f"{name} wrong filename: starts with '.'"
49
+ if name.endswith("~"):
50
+ return f"{name} wrong filename: ends with '~'"
51
+ if "." in name:
52
+ return f"{name} wrong filename contains '.'"
53
+ if "#" in name:
54
+ return f"{name} wrong filename contains '#'"
55
+ if "," in name:
56
+ return f"{name} wrong filename contains ','"
57
+ if not re.match(INVALID_NAME_ALLOWED_RE, name):
58
+ return f"invalid filename '{name}': contains characters outside [A-Za-z0-9_-]"
59
+ return ""
60
+
61
+
62
+ def check_daemon() -> List[str]:
63
+ errors: List[str] = []
64
+ try:
65
+ result = subprocess.run(["systemctl", "is-active", "cron"], capture_output=True, text=True, timeout=5, check=False)
66
+ if result.returncode != 0 or result.stdout.strip() != "active":
67
+ result2 = subprocess.run(["systemctl", "is-active", "crond"], capture_output=True, text=True, timeout=5, check=False)
68
+ if result2.returncode != 0 or result2.stdout.strip() != "active":
69
+ errors.append("cron daemon is not active")
70
+ except FileNotFoundError:
71
+ errors.append("systemctl not found")
72
+ except Exception as exc:
73
+ errors.append(f"cron daemon check failed: {type(exc).__name__}")
74
+ return errors
75
+
76
+
77
+ def check_kind(path: str, follow_symlink: bool = True) -> str:
78
+ st = os.stat(path) if follow_symlink else os.lstat(path)
79
+ m = st.st_mode
80
+ if stat.S_ISREG(m):
81
+ return "regular_file"
82
+ if stat.S_ISDIR(m):
83
+ return "directory"
84
+ if stat.S_ISLNK(m):
85
+ return "symlink"
86
+ if stat.S_ISCHR(m):
87
+ return "char_device"
88
+ if stat.S_ISBLK(m):
89
+ return "block_device"
90
+ if stat.S_ISSOCK(m):
91
+ return "socket"
92
+ if stat.S_ISFIFO(m):
93
+ return "fifo"
94
+ return "unknown"
95
+
96
+
97
+ def check_owner_and_permissions(file_path: str, owner_uid: int = CRONTAB_OWNER_UID) -> List[str]:
98
+ errors: List[str] = []
99
+ if not os.path.lexists(file_path):
100
+ return [f"{file_path}: file does not exist"]
101
+ try:
102
+ if os.path.islink(file_path):
103
+ link_stat = os.lstat(file_path)
104
+ if link_stat.st_uid != owner_uid:
105
+ errors.append(f"wrong symlink owner: sudo chown -h root:root {file_path}")
106
+ target_path = os.path.realpath(file_path)
107
+ if not os.path.exists(target_path):
108
+ errors.append(f"broken symlink ({target_path} does not exist)")
109
+ return errors
110
+ else:
111
+ target_path = file_path
112
+ kind = check_kind(target_path)
113
+ if kind != "regular_file":
114
+ errors.append(f"{target_path}({kind}): not a regular_file.")
115
+ stat_info = os.stat(target_path)
116
+ mode = stat_info.st_mode & 0o777
117
+ if mode != CRONTAB_PERMISSIONS:
118
+ errors.append(f"wrong permissions ({oct(mode)}): sudo chmod 644 {target_path}")
119
+ if stat_info.st_uid != owner_uid:
120
+ errors.append(f"wrong owner: sudo chown root:root {target_path}")
121
+ except Exception as exc:
122
+ errors.append(f"{exc}")
123
+ return errors
124
+
125
+
126
+ def get_line_content(file_path: str, line_number: int) -> str:
127
+ try:
128
+ with open(file_path, encoding="utf-8", errors="replace") as f:
129
+ lines = f.readlines()
130
+ if 1 <= line_number <= len(lines):
131
+ return lines[line_number - 1].rstrip("\n")
132
+ except Exception as exc:
133
+ logger.debug(f"{type(exc).__name__}: {exc}")
134
+ return ""
135
+
136
+
137
+ def clean_line_for_output(line: str) -> str:
138
+ return re.sub(r" +", " ", line.replace("\t", " "))
139
+
140
+
141
+ def check_dangerous_commands(command: str) -> List[str]:
142
+ for pattern, message in DANGEROUS_PATTERNS:
143
+ if re.search(pattern, command, re.IGNORECASE):
144
+ return [message]
145
+ return []
146
+
147
+
148
+ def validate_single_time_value(value: str, field_name: str, min_val: int, max_val: int) -> List[str]:
149
+ errors: List[str] = []
150
+ if value.startswith("*/"):
151
+ step_part = value[2:]
152
+ if step_part.isdigit():
153
+ step_val = int(step_part)
154
+ if step_val <= 0:
155
+ errors.append(f"step value must be positive in {field_name}: '{value}'")
156
+ if step_val > max_val:
157
+ errors.append(f"step value {step_val} exceeds maximum {max_val} for {field_name}: '{value}'")
158
+ else:
159
+ errors.append(f"invalid step value in {field_name}: '{value}'")
160
+ return errors
161
+ if "-" in value:
162
+ range_parts = value.split("-")
163
+ if len(range_parts) == RANGE_PARTS_COUNT:
164
+ start_str, end_str = range_parts
165
+ if start_str.isdigit() and end_str.isdigit():
166
+ start_val = int(start_str)
167
+ end_val = int(end_str)
168
+ if start_val > end_val:
169
+ errors.append(f"invalid range {start_val}-{end_val} in {field_name}: start > end")
170
+ if start_val < min_val or start_val > max_val:
171
+ errors.append(f"range start {start_val} out of bounds ({min_val}-{max_val}) for {field_name}")
172
+ if end_val < min_val or end_val > max_val:
173
+ errors.append(f"range end {end_val} out of bounds ({min_val}-{max_val}) for {field_name}")
174
+ return errors
175
+ if value.isdigit():
176
+ num_val = int(value)
177
+ if num_val < min_val or num_val > max_val:
178
+ errors.append(f"value {num_val} out of bounds ({min_val}-{max_val}) for {field_name}")
179
+ return errors
180
+
181
+
182
+ def validate_time_field_logic(value: str, field_name: str, min_val: int, max_val: int) -> List[str]:
183
+ errors: List[str] = []
184
+ if value == "*":
185
+ return errors
186
+ if "," in value:
187
+ seen = set()
188
+ for part in value.split(","):
189
+ part = part.strip()
190
+ if not part:
191
+ errors.append(f"empty value in {field_name} list: '{value}'")
192
+ continue
193
+ if part in seen:
194
+ errors.append(f"duplicate value '{part}' in {field_name} list: '{value}'")
195
+ seen.add(part)
196
+ errors.extend(validate_single_time_value(part, field_name, min_val, max_val))
197
+ else:
198
+ errors.extend(validate_single_time_value(value, field_name, min_val, max_val))
199
+ return errors
200
+
201
+
202
+ def check_time_field(value: str, field_name: str, pattern: str, min_val: int, max_val: int) -> List[str]:
203
+ logic_errors = validate_time_field_logic(value, field_name, min_val, max_val)
204
+ if logic_errors:
205
+ return logic_errors
206
+ if not re.match(pattern, value):
207
+ return [f"invalid {field_name} format: '{value}'"]
208
+ return []
209
+
210
+
211
+ def check_user_exists(username: str) -> bool:
212
+ if username in ("root",):
213
+ return True
214
+ try:
215
+ result = subprocess.run(["id", username], capture_output=True, text=True, timeout=5, check=False)
216
+ return result.returncode == 0
217
+ except Exception:
218
+ return True
219
+
220
+
221
+ def check_user(username: str) -> Tuple[List[str], List[str]]:
222
+ errors: List[str] = []
223
+ warnings: List[str] = []
224
+ if not username or username.startswith("#") or '"' in username or "@" in username or " " in username or not re.match(r"^[a-zA-Z][a-zA-Z0-9_-]{0,31}$", username):
225
+ errors.append(f"invalid user format: '{username}'")
226
+ elif platform.system().lower() != "windows" and not check_user_exists(username):
227
+ warnings.append(f"user does not exist: '{username}'")
228
+ return errors, warnings
229
+
230
+
231
+ def check_command(command: str) -> List[str]:
232
+ if not command:
233
+ return ["missing command"]
234
+ return check_dangerous_commands(command)
235
+
236
+
237
+ def check_special(keyword: str, parts: List[str], is_system_crontab: bool) -> List[str]:
238
+ errors: List[str] = []
239
+ if keyword not in VALID_KEYWORDS:
240
+ return [f"invalid special keyword '{keyword}'"]
241
+ if is_system_crontab:
242
+ if len(parts) < SYSTEM_SPECIAL_MIN_FIELDS:
243
+ return [f"minimum {SYSTEM_SPECIAL_MIN_FIELDS} fields required for system crontab"]
244
+ user_errors, _ = check_user(parts[1])
245
+ errors.extend(user_errors)
246
+ errors.extend(check_command(" ".join(parts[2:])))
247
+ elif len(parts) > 1:
248
+ errors.extend(check_command(" ".join(parts[1:])))
249
+ else:
250
+ errors.append("minimum 2 fields required for user crontab")
251
+ return errors
252
+
253
+
254
+ def check_line(line: str, line_number: int, file_name: str, file_path: Optional[str] = None, is_system_crontab: bool = False) -> Tuple[List[str], List[str]]:
255
+ errors: List[str] = []
256
+ warnings: List[str] = []
257
+
258
+ if "=" in line and not any(ch.isdigit() or ch in "*@" for ch in line.split("=")[0]):
259
+ return errors, warnings
260
+
261
+ line_content = get_line_content(file_path, line_number) if file_path else line
262
+ line_content = clean_line_for_output(line_content)
263
+
264
+ def wrap(items: List[str]) -> List[str]:
265
+ return [f"{file_name} (Line {line_number}): {line_content} # {e}" for e in items]
266
+
267
+ if line.startswith("@"):
268
+ parts = line.split()
269
+ if len(parts) < SPECIAL_KEYWORD_MIN_FIELDS:
270
+ return wrap([f"insufficient fields for special keyword (minimum {SPECIAL_KEYWORD_MIN_FIELDS})"]), []
271
+ errors.extend(check_special(parts[0], parts, is_system_crontab))
272
+ return wrap(errors), wrap(warnings)
273
+
274
+ parts = line.split()
275
+ min_fields = SYSTEM_CRONTAB_MIN_FIELDS if is_system_crontab else USER_CRONTAB_MIN_FIELDS
276
+ if len(parts) < min_fields:
277
+ return wrap([f"insufficient fields (minimum {min_fields}, found {len(parts)})"]), []
278
+
279
+ minute, hour, day, month, weekday = parts[:5]
280
+
281
+ if is_system_crontab:
282
+ user = parts[5]
283
+ command = " ".join(parts[6:])
284
+ user_errors, user_warnings = check_user(user)
285
+ errors.extend(user_errors)
286
+ warnings.extend(user_warnings)
287
+ else:
288
+ command = " ".join(parts[5:])
289
+
290
+ errors.extend(check_command(command))
291
+ errors.extend(check_time_field(minute, "minutes", MINUTE_PATTERN, 0, 59))
292
+ errors.extend(check_time_field(hour, "hours", HOUR_PATTERN, 0, 23))
293
+ errors.extend(check_time_field(day, "day of month", DAY_PATTERN, 1, 31))
294
+ errors.extend(check_time_field(month, "month", MONTH_PATTERN, 1, 12))
295
+ errors.extend(check_time_field(weekday, "day of week", WEEKDAY_PATTERN, 0, 7))
296
+
297
+ return wrap(errors), wrap(warnings)
298
+
299
+
300
+ def get_crontab(username: str) -> Optional[str]:
301
+ try:
302
+ result = subprocess.run(["crontab", "-l", "-u", username], capture_output=True, text=True, timeout=10, check=False)
303
+ if result.returncode == 0:
304
+ return result.stdout
305
+ return None
306
+ except Exception as exc:
307
+ logger.debug(f"{type(exc).__name__}: {exc}")
308
+ return None
309
+
310
+
311
+ def check_file(file_path: str, is_system_crontab: bool = False) -> Tuple[int, List[str], List[str]]:
312
+ """Check a single crontab file. Returns (rows_checked, errors, warnings)."""
313
+ errors: List[str] = []
314
+ warnings: List[str] = []
315
+ rows_checked = 0
316
+ try:
317
+ with open(file_path, encoding="utf-8", errors="replace") as f:
318
+ lines = f.readlines()
319
+ except Exception as exc:
320
+ logger.warning(f"{type(exc).__name__}: {exc}\n{traceback.format_exc()}")
321
+ return 0, [f"{file_path}: cannot read ({exc})"], []
322
+
323
+ i = 0
324
+ while i < len(lines):
325
+ line = lines[i].rstrip("\n")
326
+ line_number = i + 1
327
+ if line.endswith("\\"):
328
+ full_line = line[:-1]
329
+ i += 1
330
+ while i < len(lines) and lines[i].startswith((" ", "\t")):
331
+ cont = lines[i].rstrip("\n")
332
+ if cont.endswith("\\"):
333
+ full_line += "\n" + cont[:-1]
334
+ i += 1
335
+ else:
336
+ full_line += "\n" + cont
337
+ i += 1
338
+ break
339
+ line = full_line
340
+ else:
341
+ i += 1
342
+ stripped = line.strip()
343
+ if not stripped or stripped.startswith("#"):
344
+ continue
345
+ rows_checked += 1
346
+ line_errors, line_warnings = check_line(line, line_number, os.path.basename(file_path), file_path, is_system_crontab)
347
+ errors.extend(line_errors)
348
+ warnings.extend(line_warnings)
349
+ return rows_checked, errors, warnings
350
+
351
+
352
+ def find_user_crontab(username: str) -> Optional[str]:
353
+ candidates = [
354
+ f"/var/spool/cron/crontabs/{username}",
355
+ f"/var/spool/cron/{username}",
356
+ ]
357
+ for path in candidates:
358
+ if os.path.exists(path):
359
+ return path
360
+ content = get_crontab(username)
361
+ if content:
362
+ with tempfile.NamedTemporaryFile(mode="w", suffix=f".{username}", delete=False) as tmp:
363
+ tmp.write(content)
364
+ return tmp.name
365
+ return None
366
+
367
+
368
+ def discover_default_targets() -> List[Tuple[str, bool]]:
369
+ """Find typical crontab locations on the system."""
370
+ targets: List[Tuple[str, bool]] = []
371
+ if os.path.exists("/etc/crontab"):
372
+ targets.append(("/etc/crontab", True))
373
+ cron_d = "/etc/cron.d"
374
+ if os.path.isdir(cron_d):
375
+ for name in sorted(os.listdir(cron_d)):
376
+ path = os.path.join(cron_d, name)
377
+ if os.path.isfile(path) and not check_filename(name):
378
+ targets.append((path, True))
379
+ user_spool = "/var/spool/cron/crontabs"
380
+ if os.path.isdir(user_spool):
381
+ try:
382
+ for name in sorted(os.listdir(user_spool)):
383
+ path = os.path.join(user_spool, name)
384
+ if os.path.isfile(path):
385
+ targets.append((path, False))
386
+ except PermissionError:
387
+ pass
388
+ return targets
wtftools/events.py ADDED
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Event timeline: collect significant events from journal/wtmp into one view.
4
+
5
+ Sources (best-effort, each one is skip-able if its underlying tool is missing):
6
+ reboot `last -x reboot`
7
+ oom journalctl kernel oom-killer / "out of memory"
8
+ failed-unit journalctl SYSTEMD_UNIT_RESULT=failed
9
+ kernel-err journalctl -k -p err
10
+ auth-fail journalctl ssh.service "Failed password"
11
+ login `last -F` recent successful sessions
12
+
13
+ The fan-out is intentional: events are easier to read with categorical icons
14
+ than a wall of journal text. Each event normalizes to a single Event row.
15
+ """
16
+
17
+ import logging
18
+ import re
19
+ import shutil
20
+ import time
21
+ from dataclasses import dataclass
22
+ from datetime import datetime, timezone
23
+ from typing import List, Optional
24
+
25
+ from wtftools.sysinfo import run
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ EVENT_KINDS = ("reboot", "oom", "failed-unit", "kernel-err", "auth-fail", "login")
30
+
31
+
32
+ @dataclass
33
+ class Event:
34
+ """One event on the host timeline."""
35
+
36
+ timestamp: float # unix epoch
37
+ kind: str # one of EVENT_KINDS
38
+ message: str
39
+ detail: str = ""
40
+
41
+ def iso(self) -> str:
42
+ try:
43
+ return datetime.fromtimestamp(self.timestamp, tz=timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
44
+ except (OverflowError, OSError, ValueError):
45
+ return "????-??-?? ??:??:??"
46
+
47
+
48
+ def collect_events(hours: int = 24, kinds: Optional[List[str]] = None) -> List[Event]:
49
+ """Gather events from all configured sources, sorted newest-first.
50
+
51
+ `kinds` filters by EVENT_KINDS. None means all.
52
+ """
53
+ wanted = set(kinds) if kinds else set(EVENT_KINDS)
54
+ events: List[Event] = []
55
+ if "reboot" in wanted:
56
+ events.extend(_collect_reboots(hours))
57
+ if "oom" in wanted:
58
+ events.extend(_collect_oom(hours))
59
+ if "failed-unit" in wanted:
60
+ events.extend(_collect_failed_units(hours))
61
+ if "kernel-err" in wanted:
62
+ events.extend(_collect_kernel_errors(hours))
63
+ if "auth-fail" in wanted:
64
+ events.extend(_collect_auth_failures(hours))
65
+ if "login" in wanted:
66
+ events.extend(_collect_logins(hours))
67
+ events.sort(key=lambda e: e.timestamp, reverse=True)
68
+ return events
69
+
70
+
71
+ def _journal_since(hours: int) -> str:
72
+ return f"{hours} hours ago"
73
+
74
+
75
+ def _parse_iso_lines(text: str) -> List[tuple]:
76
+ """Parse journalctl `-o short-iso` output into (epoch, message) tuples."""
77
+ out: List[tuple] = []
78
+ for line in text.splitlines():
79
+ # Examples:
80
+ # 2026-05-20T08:55:01+0000 host kernel: Out of memory: Killed process …
81
+ # 2026-05-20T08:55:01+0300 host sshd[1234]: Failed password for foo
82
+ match = re.match(
83
+ r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:[+-]\d{4})?)\s+(.+)$",
84
+ line,
85
+ )
86
+ if not match:
87
+ continue
88
+ ts_raw, rest = match.group(1), match.group(2)
89
+ try:
90
+ # strptime with %z handles "+0000"; older Python wants tweaking.
91
+ try:
92
+ dt = datetime.strptime(ts_raw, "%Y-%m-%dT%H:%M:%S%z")
93
+ except ValueError:
94
+ dt = datetime.strptime(ts_raw, "%Y-%m-%dT%H:%M:%S")
95
+ dt = dt.replace(tzinfo=timezone.utc)
96
+ epoch = dt.timestamp()
97
+ except (ValueError, OSError):
98
+ continue
99
+ out.append((epoch, rest))
100
+ return out
101
+
102
+
103
+ def _collect_oom(hours: int) -> List[Event]:
104
+ if not shutil.which("journalctl"):
105
+ return []
106
+ rc, out, _ = run(
107
+ ["journalctl", "-k", "--since", _journal_since(hours), "-o", "short-iso", "--no-pager", "-q"],
108
+ timeout=10,
109
+ )
110
+ if rc != 0 or not out:
111
+ return []
112
+ events: List[Event] = []
113
+ for epoch, rest in _parse_iso_lines(out):
114
+ low = rest.lower()
115
+ if "out of memory" in low or "killed process" in low or "oom-killer" in low:
116
+ events.append(Event(timestamp=epoch, kind="oom", message=rest[-180:].strip()))
117
+ return events
118
+
119
+
120
+ def _collect_kernel_errors(hours: int) -> List[Event]:
121
+ if not shutil.which("journalctl"):
122
+ return []
123
+ rc, out, _ = run(
124
+ ["journalctl", "-k", "-p", "err", "--since", _journal_since(hours), "-o", "short-iso", "--no-pager", "-q"],
125
+ timeout=10,
126
+ )
127
+ if rc != 0 or not out:
128
+ return []
129
+ return [Event(timestamp=epoch, kind="kernel-err", message=rest[-180:].strip()) for epoch, rest in _parse_iso_lines(out)]
130
+
131
+
132
+ def _collect_failed_units(hours: int) -> List[Event]:
133
+ """Look for unit-result=failed events in the user/system journal."""
134
+ if not shutil.which("journalctl"):
135
+ return []
136
+ # JOB_RESULT=failed is the systemd-emitted line when a unit transitions
137
+ # to failed state. Fall back to text-grep if the field-match is empty.
138
+ rc, out, _ = run(
139
+ ["journalctl", "--since", _journal_since(hours), "JOB_RESULT=failed", "-o", "short-iso", "--no-pager", "-q"],
140
+ timeout=10,
141
+ )
142
+ events: List[Event] = []
143
+ if rc == 0 and out:
144
+ for epoch, rest in _parse_iso_lines(out):
145
+ events.append(Event(timestamp=epoch, kind="failed-unit", message=rest.strip()))
146
+ return events
147
+
148
+
149
+ def _collect_auth_failures(hours: int) -> List[Event]:
150
+ if not shutil.which("journalctl"):
151
+ return []
152
+ rc, out, _ = run(
153
+ ["journalctl", "--since", _journal_since(hours), "-o", "short-iso", "--no-pager", "-q", "_SYSTEMD_UNIT=ssh.service", "_SYSTEMD_UNIT=sshd.service"],
154
+ timeout=10,
155
+ )
156
+ if rc != 0 or not out:
157
+ return []
158
+ events: List[Event] = []
159
+ for epoch, rest in _parse_iso_lines(out):
160
+ low = rest.lower()
161
+ if "failed password" in low or "invalid user" in low or "authentication failure" in low:
162
+ events.append(Event(timestamp=epoch, kind="auth-fail", message=rest.strip()))
163
+ return events
164
+
165
+
166
+ def _collect_reboots(hours: int) -> List[Event]:
167
+ """Use `last -x reboot --time-format iso` for explicit reboot rows."""
168
+ if not shutil.which("last"):
169
+ return []
170
+ rc, out, _ = run(["last", "-x", "reboot", "--time-format", "iso"], timeout=5)
171
+ if rc != 0 or not out:
172
+ return []
173
+ cutoff = time.time() - hours * 3600
174
+ events: List[Event] = []
175
+ for line in out.splitlines():
176
+ # Example: "reboot system boot 6.11.0-29-generic 2026-05-13T22:30:09+01:00 still running"
177
+ match = re.search(r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:?\d{2})", line)
178
+ if not match:
179
+ continue
180
+ ts_raw = match.group(1)
181
+ # Python wants %z without colon; strip it.
182
+ ts_raw_norm = re.sub(r"([+-]\d{2}):?(\d{2})$", r"\1\2", ts_raw)
183
+ try:
184
+ dt = datetime.strptime(ts_raw_norm, "%Y-%m-%dT%H:%M:%S%z")
185
+ epoch = dt.timestamp()
186
+ except ValueError:
187
+ continue
188
+ if epoch < cutoff:
189
+ continue
190
+ events.append(Event(timestamp=epoch, kind="reboot", message=line.strip()))
191
+ return events
192
+
193
+
194
+ def _collect_logins(hours: int) -> List[Event]:
195
+ if not shutil.which("last"):
196
+ return []
197
+ rc, out, _ = run(["last", "-n", "50", "--time-format", "iso"], timeout=5)
198
+ if rc != 0 or not out:
199
+ return []
200
+ cutoff = time.time() - hours * 3600
201
+ events: List[Event] = []
202
+ for line in out.splitlines():
203
+ # Skip the reboot rows here — they're handled in _collect_reboots.
204
+ if line.strip().startswith("reboot"):
205
+ continue
206
+ if not line.strip() or line.startswith("wtmp"):
207
+ continue
208
+ match = re.search(r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:?\d{2})", line)
209
+ if not match:
210
+ continue
211
+ ts_raw_norm = re.sub(r"([+-]\d{2}):?(\d{2})$", r"\1\2", match.group(1))
212
+ try:
213
+ dt = datetime.strptime(ts_raw_norm, "%Y-%m-%dT%H:%M:%S%z")
214
+ epoch = dt.timestamp()
215
+ except ValueError:
216
+ continue
217
+ if epoch < cutoff:
218
+ continue
219
+ events.append(Event(timestamp=epoch, kind="login", message=line.strip()))
220
+ return events