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/__init__.py +55 -0
- wtftools/__main__.py +10 -0
- wtftools/audit.py +809 -0
- wtftools/colors.py +111 -0
- wtftools/config.py +249 -0
- wtftools/cron.py +388 -0
- wtftools/events.py +220 -0
- wtftools/explain.py +290 -0
- wtftools/info.py +90 -0
- wtftools/llm.py +129 -0
- wtftools/main.py +1328 -0
- wtftools/snapshot.py +203 -0
- wtftools/sysinfo.py +1608 -0
- wtftools-0.0.0.data/data/share/bash-completion/completions/wtf.bash-completion +134 -0
- wtftools-0.0.0.dist-info/METADATA +246 -0
- wtftools-0.0.0.dist-info/RECORD +20 -0
- wtftools-0.0.0.dist-info/WHEEL +5 -0
- wtftools-0.0.0.dist-info/entry_points.txt +3 -0
- wtftools-0.0.0.dist-info/licenses/LICENSE +21 -0
- wtftools-0.0.0.dist-info/top_level.txt +1 -0
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
|