vllixn 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.
- vllixn/__init__.py +9 -0
- vllixn/checks/__init__.py +30 -0
- vllixn/checks/accounts.py +400 -0
- vllixn/checks/audit.py +152 -0
- vllixn/checks/boot.py +117 -0
- vllixn/checks/kernel.py +130 -0
- vllixn/checks/network.py +191 -0
- vllixn/checks/patches.py +94 -0
- vllixn/checks/permissions.py +171 -0
- vllixn/checks/services.py +124 -0
- vllixn/checks/ssh.py +263 -0
- vllixn/cli.py +108 -0
- vllixn/compare.py +50 -0
- vllixn/model.py +153 -0
- vllixn/probe.py +250 -0
- vllixn/report/__init__.py +9 -0
- vllixn/report/html_export.py +263 -0
- vllixn/report/json_export.py +42 -0
- vllixn/report/terminal.py +109 -0
- vllixn/score.py +45 -0
- vllixn-0.2.0.dist-info/METADATA +92 -0
- vllixn-0.2.0.dist-info/RECORD +26 -0
- vllixn-0.2.0.dist-info/WHEEL +5 -0
- vllixn-0.2.0.dist-info/entry_points.txt +2 -0
- vllixn-0.2.0.dist-info/licenses/LICENSE +21 -0
- vllixn-0.2.0.dist-info/top_level.txt +1 -0
vllixn/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""vllixn: a read-only Linux host security checkup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .model import Finding, Report, Severity, Status
|
|
6
|
+
from .probe import SystemProbe
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.0"
|
|
9
|
+
__all__ = ["Finding", "Report", "Severity", "Status", "SystemProbe", "__version__"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Check registry: run every category against a probe."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..model import Finding
|
|
6
|
+
from ..probe import SystemProbe
|
|
7
|
+
from . import accounts, audit, boot, kernel, network, patches, permissions, services, ssh
|
|
8
|
+
|
|
9
|
+
# Order controls how findings are grouped in reports.
|
|
10
|
+
MODULES = [
|
|
11
|
+
accounts,
|
|
12
|
+
ssh,
|
|
13
|
+
permissions,
|
|
14
|
+
network,
|
|
15
|
+
services,
|
|
16
|
+
kernel,
|
|
17
|
+
patches,
|
|
18
|
+
boot,
|
|
19
|
+
audit,
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
__all__ = ["MODULES", "run_all"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_all(probe: SystemProbe) -> list[Finding]:
|
|
26
|
+
"""Run every registered check module and collect the findings."""
|
|
27
|
+
findings: list[Finding] = []
|
|
28
|
+
for module in MODULES:
|
|
29
|
+
findings.extend(module.run(probe))
|
|
30
|
+
return findings
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Account and privilege checks: UID 0 aliases, empty passwords, sudo, SSH keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..model import Finding, Severity, fail, ok, skip
|
|
6
|
+
from ..probe import SystemProbe
|
|
7
|
+
|
|
8
|
+
CATEGORY = "账户"
|
|
9
|
+
|
|
10
|
+
NOLOGIN_SHELLS = {
|
|
11
|
+
"",
|
|
12
|
+
"/bin/false",
|
|
13
|
+
"/usr/bin/false",
|
|
14
|
+
"/sbin/nologin",
|
|
15
|
+
"/usr/sbin/nologin",
|
|
16
|
+
"/bin/sync",
|
|
17
|
+
"/sbin/halt",
|
|
18
|
+
"/sbin/shutdown",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run(probe: SystemProbe) -> list[Finding]:
|
|
23
|
+
users = parse_passwd(probe)
|
|
24
|
+
findings: list[Finding] = []
|
|
25
|
+
findings.append(_uid_zero(users))
|
|
26
|
+
findings.extend(_password_fields(probe, users))
|
|
27
|
+
findings.append(_system_login_shells(users))
|
|
28
|
+
findings.append(_duplicate_uids(users))
|
|
29
|
+
findings.append(_sudo_nopasswd(probe))
|
|
30
|
+
findings.extend(_ssh_key_perms(probe, users))
|
|
31
|
+
findings.extend(_password_policy(probe))
|
|
32
|
+
findings.append(_pam_quality(probe))
|
|
33
|
+
findings.append(_pam_faillock(probe))
|
|
34
|
+
return findings
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_passwd(probe: SystemProbe) -> list[dict[str, str]]:
|
|
38
|
+
"""Return each /etc/passwd row as a mapping."""
|
|
39
|
+
rows: list[dict[str, str]] = []
|
|
40
|
+
for line in probe.read_lines("/etc/passwd"):
|
|
41
|
+
if not line or line.startswith("#"):
|
|
42
|
+
continue
|
|
43
|
+
parts = line.split(":")
|
|
44
|
+
if len(parts) < 7:
|
|
45
|
+
continue
|
|
46
|
+
rows.append(
|
|
47
|
+
{
|
|
48
|
+
"name": parts[0],
|
|
49
|
+
"password": parts[1],
|
|
50
|
+
"uid": parts[2],
|
|
51
|
+
"gid": parts[3],
|
|
52
|
+
"home": parts[5],
|
|
53
|
+
"shell": parts[6],
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
return rows
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _uid_zero(users: list[dict[str, str]]) -> Finding:
|
|
60
|
+
aliases = [user["name"] for user in users if user["uid"] == "0" and user["name"] != "root"]
|
|
61
|
+
if aliases:
|
|
62
|
+
return fail(
|
|
63
|
+
"accounts.uid_zero",
|
|
64
|
+
CATEGORY,
|
|
65
|
+
Severity.CRITICAL,
|
|
66
|
+
"存在 UID 为 0 的非 root 账户",
|
|
67
|
+
"除 root 外还有账户具备最高权限,等同于多个 root。",
|
|
68
|
+
"确认这些账户是否必要;不需要则锁定或删除,并改为普通账户。",
|
|
69
|
+
", ".join(aliases),
|
|
70
|
+
)
|
|
71
|
+
return ok("accounts.uid_zero", CATEGORY, "只有 root 拥有 UID 0")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _password_fields(probe: SystemProbe, users: list[dict[str, str]]) -> list[Finding]:
|
|
75
|
+
findings: list[Finding] = []
|
|
76
|
+
blank_passwd = [user["name"] for user in users if user["password"] == ""]
|
|
77
|
+
if blank_passwd:
|
|
78
|
+
findings.append(
|
|
79
|
+
fail(
|
|
80
|
+
"accounts.passwd_blank",
|
|
81
|
+
CATEGORY,
|
|
82
|
+
Severity.CRITICAL,
|
|
83
|
+
"/etc/passwd 中存在空密码账户",
|
|
84
|
+
"口令字段为空表示登录无需密码。",
|
|
85
|
+
"为这些账户设置口令或将其锁定。",
|
|
86
|
+
", ".join(blank_passwd),
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
else:
|
|
90
|
+
findings.append(ok("accounts.passwd_blank", CATEGORY, "/etc/passwd 无空密码账户"))
|
|
91
|
+
|
|
92
|
+
if not probe.is_root and not probe.exists("/etc/shadow"):
|
|
93
|
+
findings.append(
|
|
94
|
+
skip(
|
|
95
|
+
"accounts.empty_password",
|
|
96
|
+
CATEGORY,
|
|
97
|
+
"空口令检查",
|
|
98
|
+
"需要 root 才能读取 /etc/shadow。",
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
return findings
|
|
102
|
+
|
|
103
|
+
shadow = _parse_shadow(probe)
|
|
104
|
+
if shadow is None:
|
|
105
|
+
findings.append(
|
|
106
|
+
skip("accounts.empty_password", CATEGORY, "空口令检查", "无法读取 /etc/shadow。")
|
|
107
|
+
)
|
|
108
|
+
return findings
|
|
109
|
+
empty = [name for name, field in shadow.items() if field == ""]
|
|
110
|
+
if empty:
|
|
111
|
+
findings.append(
|
|
112
|
+
fail(
|
|
113
|
+
"accounts.empty_password",
|
|
114
|
+
CATEGORY,
|
|
115
|
+
Severity.CRITICAL,
|
|
116
|
+
"存在空口令账户",
|
|
117
|
+
"这些账户的口令哈希为空,可无需口令登录。",
|
|
118
|
+
"立即设置口令或锁定账户。",
|
|
119
|
+
", ".join(empty),
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
findings.append(ok("accounts.empty_password", CATEGORY, "未发现空口令账户"))
|
|
124
|
+
return findings
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parse_shadow(probe: SystemProbe) -> dict[str, str] | None:
|
|
128
|
+
text = probe.read_text("/etc/shadow", "")
|
|
129
|
+
if not text:
|
|
130
|
+
return None
|
|
131
|
+
rows: dict[str, str] = {}
|
|
132
|
+
for line in text.splitlines():
|
|
133
|
+
if not line or line.startswith("#"):
|
|
134
|
+
continue
|
|
135
|
+
parts = line.split(":")
|
|
136
|
+
if len(parts) >= 2:
|
|
137
|
+
rows[parts[0]] = parts[1]
|
|
138
|
+
return rows
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _system_login_shells(users: list[dict[str, str]]) -> Finding:
|
|
142
|
+
exposed = [
|
|
143
|
+
user["name"]
|
|
144
|
+
for user in users
|
|
145
|
+
if user["shell"] not in NOLOGIN_SHELLS and user["uid"].isdigit() and int(user["uid"]) < 1000 and user["uid"] != "0"
|
|
146
|
+
]
|
|
147
|
+
if exposed:
|
|
148
|
+
return fail(
|
|
149
|
+
"accounts.system_login_shells",
|
|
150
|
+
CATEGORY,
|
|
151
|
+
Severity.LOW,
|
|
152
|
+
"系统账户拥有可登录 shell",
|
|
153
|
+
"UID 小于 1000 的系统账户通常不应允许交互登录。",
|
|
154
|
+
"将这类账户的 shell 改为 /usr/sbin/nologin,除非确有登录需求。",
|
|
155
|
+
", ".join(exposed),
|
|
156
|
+
)
|
|
157
|
+
return ok("accounts.system_login_shells", CATEGORY, "系统账户均不可交互登录")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _duplicate_uids(users: list[dict[str, str]]) -> Finding:
|
|
161
|
+
seen: dict[str, list[str]] = {}
|
|
162
|
+
for user in users:
|
|
163
|
+
seen.setdefault(user["uid"], []).append(user["name"])
|
|
164
|
+
dupes = {uid: names for uid, names in seen.items() if len(names) > 1}
|
|
165
|
+
if dupes:
|
|
166
|
+
detail = "; ".join(f"UID {uid}: {', '.join(names)}" for uid, names in dupes.items())
|
|
167
|
+
return fail(
|
|
168
|
+
"accounts.duplicate_uid",
|
|
169
|
+
CATEGORY,
|
|
170
|
+
Severity.MEDIUM,
|
|
171
|
+
"存在重复的 UID",
|
|
172
|
+
"多个账户共用同一 UID,权限归属难以审计。",
|
|
173
|
+
"为每个账户分配唯一 UID。",
|
|
174
|
+
detail,
|
|
175
|
+
)
|
|
176
|
+
return ok("accounts.duplicate_uid", CATEGORY, "UID 无重复")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _sudo_nopasswd(probe: SystemProbe) -> Finding:
|
|
180
|
+
files = ["/etc/sudoers"] + probe.glob("/etc/sudoers.d/*")
|
|
181
|
+
hits: list[str] = []
|
|
182
|
+
for rel in files:
|
|
183
|
+
for line in probe.read_lines(rel):
|
|
184
|
+
stripped = line.strip()
|
|
185
|
+
if not stripped or stripped.startswith("#"):
|
|
186
|
+
continue
|
|
187
|
+
if "NOPASSWD" in stripped:
|
|
188
|
+
hits.append(f"{rel}: {stripped}")
|
|
189
|
+
if hits:
|
|
190
|
+
return fail(
|
|
191
|
+
"accounts.sudo_nopasswd",
|
|
192
|
+
CATEGORY,
|
|
193
|
+
Severity.MEDIUM,
|
|
194
|
+
"sudo 配置存在 NOPASSWD",
|
|
195
|
+
"sudo 可无需口令执行,口令泄露风险被放大。",
|
|
196
|
+
"仅在确有必要时为特定命令保留 NOPASSWD,其余要求输入口令。",
|
|
197
|
+
" | ".join(hits),
|
|
198
|
+
)
|
|
199
|
+
return ok("accounts.sudo_nopasswd", CATEGORY, "sudo 未配置 NOPASSWD")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _ssh_key_perms(probe: SystemProbe, users: list[dict[str, str]]) -> list[Finding]:
|
|
203
|
+
findings: list[Finding] = []
|
|
204
|
+
dir_bad: list[str] = []
|
|
205
|
+
key_bad: list[str] = []
|
|
206
|
+
for user in users:
|
|
207
|
+
if not user["uid"].isdigit() or int(user["uid"]) < 1000:
|
|
208
|
+
continue
|
|
209
|
+
home = user["home"].rstrip("/") or "/"
|
|
210
|
+
ssh_dir = f"{home}/.ssh"
|
|
211
|
+
info = probe.info(f"{ssh_dir}/authorized_keys")
|
|
212
|
+
if info is not None and info.any_write():
|
|
213
|
+
key_bad.append(f"{ssh_dir}/authorized_keys ({info.perm_text})")
|
|
214
|
+
dir_info = probe.info(ssh_dir)
|
|
215
|
+
if dir_info is not None and dir_info.is_dir and dir_info.perm & 0o077:
|
|
216
|
+
dir_bad.append(f"{ssh_dir} ({dir_info.perm_text})")
|
|
217
|
+
if key_bad:
|
|
218
|
+
findings.append(
|
|
219
|
+
fail(
|
|
220
|
+
"accounts.ssh_key_writable",
|
|
221
|
+
CATEGORY,
|
|
222
|
+
Severity.HIGH,
|
|
223
|
+
"authorized_keys 可被他人写入",
|
|
224
|
+
"组或其他用户可写 authorized_keys,可能被植入公钥。",
|
|
225
|
+
"执行 chmod 600 修正权限,并确认文件内容可信。",
|
|
226
|
+
", ".join(key_bad),
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
findings.append(ok("accounts.ssh_key_writable", CATEGORY, "authorized_keys 权限正常"))
|
|
231
|
+
if dir_bad:
|
|
232
|
+
findings.append(
|
|
233
|
+
fail(
|
|
234
|
+
"accounts.ssh_dir_perms",
|
|
235
|
+
CATEGORY,
|
|
236
|
+
Severity.MEDIUM,
|
|
237
|
+
"~/.ssh 目录权限过宽",
|
|
238
|
+
"目录允许组或其他用户访问。",
|
|
239
|
+
"执行 chmod 700 ~/.ssh 收紧权限。",
|
|
240
|
+
", ".join(dir_bad),
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
else:
|
|
244
|
+
findings.append(ok("accounts.ssh_dir_perms", CATEGORY, ".ssh 目录权限正常"))
|
|
245
|
+
return findings
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
PAM_PASSWORD_FILES = [
|
|
249
|
+
"/etc/pam.d/common-password",
|
|
250
|
+
"/etc/pam.d/system-auth",
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
PAM_AUTH_FILES = [
|
|
254
|
+
"/etc/pam.d/common-auth",
|
|
255
|
+
"/etc/pam.d/system-auth",
|
|
256
|
+
"/etc/pam.d/password-auth",
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _read_login_defs(probe: SystemProbe) -> dict[str, str]:
|
|
261
|
+
values: dict[str, str] = {}
|
|
262
|
+
for line in probe.read_lines("/etc/login.defs"):
|
|
263
|
+
stripped = line.strip()
|
|
264
|
+
if not stripped or stripped.startswith("#"):
|
|
265
|
+
continue
|
|
266
|
+
parts = stripped.split()
|
|
267
|
+
if len(parts) >= 2:
|
|
268
|
+
values.setdefault(parts[0], parts[1])
|
|
269
|
+
return values
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _password_policy(probe: SystemProbe) -> list[Finding]:
|
|
273
|
+
if not probe.exists("/etc/login.defs"):
|
|
274
|
+
return [
|
|
275
|
+
skip(
|
|
276
|
+
"accounts.password_policy",
|
|
277
|
+
CATEGORY,
|
|
278
|
+
"口令有效期策略",
|
|
279
|
+
"未找到 /etc/login.defs,无法评估口令有效期策略。",
|
|
280
|
+
)
|
|
281
|
+
]
|
|
282
|
+
values = _read_login_defs(probe)
|
|
283
|
+
findings: list[Finding] = []
|
|
284
|
+
max_days_raw = values.get("PASS_MAX_DAYS")
|
|
285
|
+
if max_days_raw is None:
|
|
286
|
+
findings.append(
|
|
287
|
+
fail(
|
|
288
|
+
"accounts.password_policy",
|
|
289
|
+
CATEGORY,
|
|
290
|
+
Severity.MEDIUM,
|
|
291
|
+
"未设置口令最长有效期",
|
|
292
|
+
"login.defs 未定义 PASS_MAX_DAYS,口令可能长期不更换。",
|
|
293
|
+
"在 /etc/login.defs 设置 PASS_MAX_DAYS 90 或更小。",
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
else:
|
|
297
|
+
try:
|
|
298
|
+
max_days = int(max_days_raw)
|
|
299
|
+
except ValueError:
|
|
300
|
+
max_days = -1
|
|
301
|
+
if max_days <= 0 or max_days > 90:
|
|
302
|
+
findings.append(
|
|
303
|
+
fail(
|
|
304
|
+
"accounts.password_policy",
|
|
305
|
+
CATEGORY,
|
|
306
|
+
Severity.MEDIUM,
|
|
307
|
+
"口令最长有效期过长",
|
|
308
|
+
f"PASS_MAX_DAYS 当前为 {max_days_raw},口令长期不更换会放大泄露风险。",
|
|
309
|
+
"在 /etc/login.defs 设置 PASS_MAX_DAYS 90 或更小。",
|
|
310
|
+
f"PASS_MAX_DAYS {max_days_raw}",
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
else:
|
|
314
|
+
findings.append(
|
|
315
|
+
ok("accounts.password_policy", CATEGORY, "口令有效期策略合理", f"PASS_MAX_DAYS {max_days_raw}")
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
umask = values.get("UMASK")
|
|
319
|
+
if umask in {"022", "002", None}:
|
|
320
|
+
findings.append(
|
|
321
|
+
fail(
|
|
322
|
+
"accounts.default_umask",
|
|
323
|
+
CATEGORY,
|
|
324
|
+
Severity.LOW,
|
|
325
|
+
"默认 umask 过于宽松",
|
|
326
|
+
"登录默认 umask 为 022 时,用户新建文件默认组可读,易造成敏感文件泄露。",
|
|
327
|
+
"在 /etc/login.defs 设置 UMASK 027 或 077。",
|
|
328
|
+
"UMASK 022" if umask is None else f"UMASK {umask}",
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
else:
|
|
332
|
+
findings.append(ok("accounts.default_umask", CATEGORY, "默认 umask 已收紧", f"UMASK {umask}"))
|
|
333
|
+
return findings
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _pam_quality(probe: SystemProbe) -> Finding:
|
|
337
|
+
enabled = False
|
|
338
|
+
for rel in PAM_PASSWORD_FILES:
|
|
339
|
+
for line in probe.read_lines(rel):
|
|
340
|
+
stripped = line.strip()
|
|
341
|
+
if stripped and not stripped.startswith("#") and "pam_pwquality.so" in stripped:
|
|
342
|
+
enabled = True
|
|
343
|
+
break
|
|
344
|
+
if enabled:
|
|
345
|
+
break
|
|
346
|
+
if not enabled:
|
|
347
|
+
return fail(
|
|
348
|
+
"accounts.pam_pwquality",
|
|
349
|
+
CATEGORY,
|
|
350
|
+
Severity.MEDIUM,
|
|
351
|
+
"未启用口令复杂度校验",
|
|
352
|
+
"PAM 未加载 pam_pwquality,弱口令(如 123456)可直接通过设置。",
|
|
353
|
+
"安装 libpwquality 并在 PAM password 栈启用 pam_pwquality.so。",
|
|
354
|
+
)
|
|
355
|
+
conf = probe.read_text("/etc/security/pwquality.conf", "")
|
|
356
|
+
minlen: int | None = None
|
|
357
|
+
for line in conf.splitlines():
|
|
358
|
+
stripped = line.split("#", 1)[0].strip()
|
|
359
|
+
if stripped.startswith("minlen"):
|
|
360
|
+
parts = stripped.split("=", 1)
|
|
361
|
+
if len(parts) == 2:
|
|
362
|
+
try:
|
|
363
|
+
minlen = int(parts[1].strip())
|
|
364
|
+
except ValueError:
|
|
365
|
+
minlen = None
|
|
366
|
+
break
|
|
367
|
+
if minlen is not None and minlen < 8:
|
|
368
|
+
return fail(
|
|
369
|
+
"accounts.pam_pwquality",
|
|
370
|
+
CATEGORY,
|
|
371
|
+
Severity.LOW,
|
|
372
|
+
"口令最小长度要求偏低",
|
|
373
|
+
f"pwquality minlen 当前为 {minlen},低于 8 位。",
|
|
374
|
+
"在 /etc/security/pwquality.conf 设置 minlen 12 或至少 8。",
|
|
375
|
+
f"minlen = {minlen}",
|
|
376
|
+
)
|
|
377
|
+
return ok("accounts.pam_pwquality", CATEGORY, "口令复杂度校验已启用")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _pam_faillock(probe: SystemProbe) -> Finding:
|
|
381
|
+
for rel in PAM_AUTH_FILES:
|
|
382
|
+
for line in probe.read_lines(rel):
|
|
383
|
+
stripped = line.strip()
|
|
384
|
+
if stripped and not stripped.startswith("#") and "pam_faillock.so" in stripped:
|
|
385
|
+
return ok("accounts.pam_faillock", CATEGORY, "登录失败锁定已启用", rel)
|
|
386
|
+
if not any(probe.exists(rel) for rel in PAM_AUTH_FILES):
|
|
387
|
+
return skip(
|
|
388
|
+
"accounts.pam_faillock",
|
|
389
|
+
CATEGORY,
|
|
390
|
+
"登录失败锁定检查",
|
|
391
|
+
"未找到 PAM 认证配置文件。",
|
|
392
|
+
)
|
|
393
|
+
return fail(
|
|
394
|
+
"accounts.pam_faillock",
|
|
395
|
+
CATEGORY,
|
|
396
|
+
Severity.LOW,
|
|
397
|
+
"未配置登录失败锁定",
|
|
398
|
+
"口令可被无限次尝试,暴力破解不受限制。",
|
|
399
|
+
"在 PAM auth 栈启用 pam_faillock.so,例如 deny=5 unlock_time=900。",
|
|
400
|
+
)
|
vllixn/checks/audit.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Audit and hardening layer checks: MAC, auditd and time sync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..model import Finding, Severity, fail, ok, skip
|
|
6
|
+
from ..probe import SystemProbe
|
|
7
|
+
|
|
8
|
+
CATEGORY = "审计"
|
|
9
|
+
|
|
10
|
+
NTP_CONFIGS = [
|
|
11
|
+
"/etc/chrony.conf",
|
|
12
|
+
"/etc/chrony/chrony.conf",
|
|
13
|
+
"/etc/ntp.conf",
|
|
14
|
+
"/etc/systemd/timesyncd.conf",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run(probe: SystemProbe) -> list[Finding]:
|
|
19
|
+
return [_mac(probe), _auditd(probe), _time_sync(probe), _persistent_logs(probe)]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _mac(probe: SystemProbe) -> Finding:
|
|
23
|
+
result = probe.command(["getenforce"])
|
|
24
|
+
if result.ok:
|
|
25
|
+
mode = result.stdout.strip()
|
|
26
|
+
if mode == "Enforcing":
|
|
27
|
+
return ok("audit.mac", CATEGORY, "SELinux 处于强制模式")
|
|
28
|
+
if mode == "Permissive":
|
|
29
|
+
return fail(
|
|
30
|
+
"audit.mac",
|
|
31
|
+
CATEGORY,
|
|
32
|
+
Severity.MEDIUM,
|
|
33
|
+
"SELinux 处于宽容模式",
|
|
34
|
+
"Permissive 模式只记录违规行为,没有实际拦截。",
|
|
35
|
+
"排查 AVC 拒绝日志后执行 setenforce 1,并持久化为 enforcing。",
|
|
36
|
+
mode,
|
|
37
|
+
)
|
|
38
|
+
apparmor = probe.read_text("/sys/module/apparmor/parameters/enabled").strip()
|
|
39
|
+
if apparmor == "Y":
|
|
40
|
+
return ok("audit.mac", CATEGORY, "AppArmor 已启用")
|
|
41
|
+
if probe.exists("/etc/selinux/config") or probe.exists("/etc/apparmor.d"):
|
|
42
|
+
return skip(
|
|
43
|
+
"audit.mac",
|
|
44
|
+
CATEGORY,
|
|
45
|
+
"强制访问控制检查",
|
|
46
|
+
"检测到 MAC 配置文件,但无法确认运行状态。",
|
|
47
|
+
)
|
|
48
|
+
return fail(
|
|
49
|
+
"audit.mac",
|
|
50
|
+
CATEGORY,
|
|
51
|
+
Severity.LOW,
|
|
52
|
+
"未检测到 SELinux 或 AppArmor",
|
|
53
|
+
"主机缺少强制访问控制层,进程越权后影响范围更大。",
|
|
54
|
+
"安装并启用 SELinux 或 AppArmor,为关键服务配置Profile。",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _auditd(probe: SystemProbe) -> Finding:
|
|
59
|
+
result = probe.command(["auditctl", "-s"])
|
|
60
|
+
if result.ok:
|
|
61
|
+
for line in result.stdout.splitlines():
|
|
62
|
+
parts = line.split()
|
|
63
|
+
if parts and parts[0] == "enabled":
|
|
64
|
+
if len(parts) >= 2 and parts[1] in {"1", "2"}:
|
|
65
|
+
return ok("audit.auditd", CATEGORY, "auditd 审计已启用")
|
|
66
|
+
return fail(
|
|
67
|
+
"audit.auditd",
|
|
68
|
+
CATEGORY,
|
|
69
|
+
Severity.MEDIUM,
|
|
70
|
+
"auditd 已安装但未启用",
|
|
71
|
+
"审计子系统处于关闭状态,安全事件不会被记录。",
|
|
72
|
+
"执行 systemctl enable --now auditd,并部署基础审计规则。",
|
|
73
|
+
line.strip(),
|
|
74
|
+
)
|
|
75
|
+
return skip(
|
|
76
|
+
"audit.auditd",
|
|
77
|
+
CATEGORY,
|
|
78
|
+
"auditd 审计检查",
|
|
79
|
+
"auditctl 输出格式无法解析。",
|
|
80
|
+
)
|
|
81
|
+
if probe.exists("/etc/audit/auditd.conf"):
|
|
82
|
+
return skip(
|
|
83
|
+
"audit.auditd",
|
|
84
|
+
CATEGORY,
|
|
85
|
+
"auditd 审计检查",
|
|
86
|
+
"auditd 已安装,但 auditctl 需要更高权限。",
|
|
87
|
+
)
|
|
88
|
+
return fail(
|
|
89
|
+
"audit.auditd",
|
|
90
|
+
CATEGORY,
|
|
91
|
+
Severity.LOW,
|
|
92
|
+
"未检测到 auditd 审计框架",
|
|
93
|
+
"缺少系统调用级审计能力,入侵行为难以追溯。",
|
|
94
|
+
"安装 audit 与 auditd 软件包,启用服务并配置基础规则。",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _time_sync(probe: SystemProbe) -> Finding:
|
|
99
|
+
result = probe.command(["timedatectl"])
|
|
100
|
+
if result.ok:
|
|
101
|
+
for line in result.stdout.splitlines():
|
|
102
|
+
if "synchronized" in line.lower():
|
|
103
|
+
if line.strip().lower().endswith("yes"):
|
|
104
|
+
return ok("audit.time_sync", CATEGORY, "系统时间已同步")
|
|
105
|
+
return fail(
|
|
106
|
+
"audit.time_sync",
|
|
107
|
+
CATEGORY,
|
|
108
|
+
Severity.LOW,
|
|
109
|
+
"系统时间未同步",
|
|
110
|
+
"时间漂移会破坏日志关联分析,也让证书校验变脆。",
|
|
111
|
+
"启用 systemd-timesyncd 或安装 chrony 进行 NTP 同步。",
|
|
112
|
+
line.strip(),
|
|
113
|
+
)
|
|
114
|
+
for rel in NTP_CONFIGS:
|
|
115
|
+
if probe.exists(rel):
|
|
116
|
+
return skip(
|
|
117
|
+
"audit.time_sync",
|
|
118
|
+
CATEGORY,
|
|
119
|
+
"时间同步检查",
|
|
120
|
+
f"检测到配置文件 {rel},但无法确认实际同步状态。",
|
|
121
|
+
)
|
|
122
|
+
if result.ok:
|
|
123
|
+
return fail(
|
|
124
|
+
"audit.time_sync",
|
|
125
|
+
CATEGORY,
|
|
126
|
+
Severity.LOW,
|
|
127
|
+
"未配置时间同步",
|
|
128
|
+
"未发现 NTP 配置文件,timedatectl 也未报告时间同步。",
|
|
129
|
+
"启用 systemd-timesyncd 或安装 chrony 并指向可信 NTP 源。",
|
|
130
|
+
)
|
|
131
|
+
return skip(
|
|
132
|
+
"audit.time_sync",
|
|
133
|
+
CATEGORY,
|
|
134
|
+
"时间同步检查",
|
|
135
|
+
"timedatectl 不可用且未发现 NTP 配置文件。",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _persistent_logs(probe: SystemProbe) -> Finding:
|
|
140
|
+
if probe.is_dir("/var/log/journal"):
|
|
141
|
+
return ok("audit.persistent_logs", CATEGORY, "journald 日志已持久化", "/var/log/journal")
|
|
142
|
+
for rel in ("/etc/rsyslog.conf", "/etc/syslog-ng.conf"):
|
|
143
|
+
if probe.exists(rel):
|
|
144
|
+
return ok("audit.persistent_logs", CATEGORY, "检测到持久化 syslog 服务", rel)
|
|
145
|
+
return fail(
|
|
146
|
+
"audit.persistent_logs",
|
|
147
|
+
CATEGORY,
|
|
148
|
+
Severity.LOW,
|
|
149
|
+
"日志仅存于内存",
|
|
150
|
+
"未发现 journald 持久化目录或 rsyslog/syslog-ng 配置,重启后日志全部丢失,无法事后追溯。",
|
|
151
|
+
"创建 /var/log/journal 启用 journald 持久化,或安装配置 rsyslog。",
|
|
152
|
+
)
|