@yottameta/yotta-guardian 0.1.0
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.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/NOTICE +11 -0
- package/README.md +154 -0
- package/SKILL.md +77 -0
- package/assets/banner.png +0 -0
- package/bin/install.js +163 -0
- package/install.sh +132 -0
- package/package.json +32 -0
- package/references/intent-verifier.md +76 -0
- package/references/policies.md +96 -0
- package/references/rules.md +118 -0
- package/scripts/__pycache__/guardian_rules.cpython-38.pyc +0 -0
- package/scripts/__pycache__/test_yotta_guardian.cpython-38.pyc +0 -0
- package/scripts/__pycache__/yotta_guardian.cpython-38.pyc +0 -0
- package/scripts/guardian_rules.py +175 -0
- package/scripts/test_yotta_guardian.py +454 -0
- package/scripts/yotta_guardian.py +1115 -0
|
@@ -0,0 +1,1115 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""yotta_guardian.py — YottaMeta 元盾(yotta-guardian):工具调用拦截护栏。
|
|
4
|
+
|
|
5
|
+
确定性规则引擎 + 可插拔意图验证,拦截危险 exec / write / edit / read / run / shell
|
|
6
|
+
工具调用,提供审计日志与机器可读判定。纯 Python 3.8+ 标准库,零外部依赖,
|
|
7
|
+
Windows + Linux + macOS 通用,跨智能体(Claude Code / Cursor / Codex / 通用 Agent)可用。
|
|
8
|
+
|
|
9
|
+
子命令:
|
|
10
|
+
check 对一条或多条工具调用做安全评估(--batch 批量、--json 机器输出)
|
|
11
|
+
audit 查询审计日志(JSONL)
|
|
12
|
+
rules 打印内置规则摘要 / 校验自定义规则文件
|
|
13
|
+
version 打印版本
|
|
14
|
+
|
|
15
|
+
退出码(与元安 / 元审家族一致):
|
|
16
|
+
0 = 允许(无 finding 或仅 low)
|
|
17
|
+
1 = 允许但带警告(存在 medium / 宽松策略下的 high)
|
|
18
|
+
2 = 拒绝(high)
|
|
19
|
+
3 = 拒绝(critical)
|
|
20
|
+
4 = 用法错误 / 致命异常
|
|
21
|
+
|
|
22
|
+
用法示例:
|
|
23
|
+
python3 yotta_guardian.py check exec --cmd "git status"
|
|
24
|
+
python3 yotta_guardian.py check exec --cmd "npm install" --policy default
|
|
25
|
+
python3 yotta_guardian.py check write --path /etc/passwd --content "..."
|
|
26
|
+
python3 yotta_guardian.py check --batch calls.json --json
|
|
27
|
+
python3 yotta_guardian.py audit --file .yotta-guardian/audit.jsonl --tail 10
|
|
28
|
+
"""
|
|
29
|
+
import argparse
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import shlex
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
from datetime import datetime, timezone
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
try:
|
|
46
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
_HERE = Path(__file__).resolve().parent
|
|
51
|
+
sys.path.insert(0, str(_HERE))
|
|
52
|
+
import guardian_rules as GR # noqa: E402
|
|
53
|
+
|
|
54
|
+
VERSION = "0.1.0"
|
|
55
|
+
TOOL_NAME = "yotta-guardian"
|
|
56
|
+
TOOL_CN = "元盾"
|
|
57
|
+
|
|
58
|
+
TOOL_TYPES = ("exec", "run", "shell", "write", "edit", "read")
|
|
59
|
+
_DENY_VERDICT = "deny"
|
|
60
|
+
_ALLOW_VERDICT = "allow"
|
|
61
|
+
_REVIEW_VERDICT = "review"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ── 基础数据 ───────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
def _sev(w):
|
|
67
|
+
return GR.severity_value(w)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _worst(findings):
|
|
71
|
+
worst = "info"
|
|
72
|
+
for f in findings:
|
|
73
|
+
if _sev(f.severity) > _sev(worst):
|
|
74
|
+
worst = f.severity
|
|
75
|
+
return worst
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_win_root(p):
|
|
79
|
+
return bool(re.match(r"^[a-zA-Z]:[\\/]?$", p.strip()))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Finding:
|
|
83
|
+
"""单条规则命中。"""
|
|
84
|
+
|
|
85
|
+
__slots__ = ("rule_id", "severity", "category", "reason", "confidence")
|
|
86
|
+
|
|
87
|
+
def __init__(self, rule_id, severity, category, reason, confidence=60):
|
|
88
|
+
self.rule_id = rule_id
|
|
89
|
+
self.severity = severity
|
|
90
|
+
self.category = category
|
|
91
|
+
self.reason = reason
|
|
92
|
+
self.confidence = confidence
|
|
93
|
+
|
|
94
|
+
def to_dict(self):
|
|
95
|
+
return {"rule_id": self.rule_id, "severity": self.severity,
|
|
96
|
+
"category": self.category, "reason": self.reason,
|
|
97
|
+
"confidence": self.confidence}
|
|
98
|
+
|
|
99
|
+
def __repr__(self):
|
|
100
|
+
return "[%s] %s: %s" % (self.severity.upper(), self.rule_id, self.reason)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ToolCall:
|
|
104
|
+
"""一次结构化的工具调用。"""
|
|
105
|
+
|
|
106
|
+
__slots__ = ("tool", "cmd", "path", "content", "old", "new", "target")
|
|
107
|
+
|
|
108
|
+
def __init__(self, tool="exec", cmd="", path="", content="", old="", new="",
|
|
109
|
+
target=""):
|
|
110
|
+
self.tool = (tool or "exec").lower()
|
|
111
|
+
self.cmd = cmd or ""
|
|
112
|
+
self.path = path or ""
|
|
113
|
+
self.content = content or ""
|
|
114
|
+
self.old = old or ""
|
|
115
|
+
self.new = new or ""
|
|
116
|
+
self.target = target or ""
|
|
117
|
+
|
|
118
|
+
def describe(self, limit=200):
|
|
119
|
+
if self.tool in ("exec", "run", "shell"):
|
|
120
|
+
return self.cmd[:limit]
|
|
121
|
+
if self.tool in ("write", "edit"):
|
|
122
|
+
return "%s %s" % (self.tool, self.path[:limit])
|
|
123
|
+
if self.tool == "read":
|
|
124
|
+
return "read %s" % self.path[:limit]
|
|
125
|
+
return (self.target or self.cmd or self.path or self.content)[:limit]
|
|
126
|
+
|
|
127
|
+
def to_dict(self):
|
|
128
|
+
d = {"tool": self.tool}
|
|
129
|
+
for k in ("cmd", "path", "content", "old", "new", "target"):
|
|
130
|
+
v = getattr(self, k)
|
|
131
|
+
if v:
|
|
132
|
+
d[k] = v
|
|
133
|
+
return d
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Verdict:
|
|
137
|
+
"""一次评估的结论。"""
|
|
138
|
+
|
|
139
|
+
__slots__ = ("allowed", "severity", "reason", "rule_ids", "findings",
|
|
140
|
+
"verifier")
|
|
141
|
+
|
|
142
|
+
def __init__(self, allowed, severity, reason, rule_ids, findings,
|
|
143
|
+
verifier=None):
|
|
144
|
+
self.allowed = allowed
|
|
145
|
+
self.severity = severity
|
|
146
|
+
self.reason = reason
|
|
147
|
+
self.rule_ids = rule_ids
|
|
148
|
+
self.findings = findings
|
|
149
|
+
self.verifier = verifier
|
|
150
|
+
|
|
151
|
+
def exit_code(self):
|
|
152
|
+
if not self.allowed:
|
|
153
|
+
return _sev(self.severity)
|
|
154
|
+
if self.severity in ("high", "medium"):
|
|
155
|
+
return 1
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
def to_dict(self):
|
|
159
|
+
return {"verdict": _ALLOW_VERDICT if self.allowed else _DENY_VERDICT,
|
|
160
|
+
"allowed": self.allowed, "severity": self.severity,
|
|
161
|
+
"reason": self.reason, "rule_ids": self.rule_ids,
|
|
162
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
163
|
+
"verifier": self.verifier, "exit": self.exit_code()}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── 确定性规则引擎 ─────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
class RuleEngine:
|
|
169
|
+
"""确定性规则引擎:文本模式 + argv 级分析 + 敏感路径 + 内容规则。
|
|
170
|
+
|
|
171
|
+
policy: default(拒绝 high+,中危警告) / strict(拒绝 medium+) /
|
|
172
|
+
loose(仅拒绝 critical)。
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(self, policy="default", allow_patterns=None, allow_paths=None,
|
|
176
|
+
config_path=None, cwd=None, verifier=None):
|
|
177
|
+
self.policy = policy
|
|
178
|
+
self.cwd = str(Path(cwd or os.getcwd()).resolve())
|
|
179
|
+
self.compiled = GR.compile_rules()
|
|
180
|
+
self.allow_text = [re.compile(p) for p in GR.ALLOW_PATTERNS]
|
|
181
|
+
self.allow_patterns = [re.compile(p) for p in (allow_patterns or [])]
|
|
182
|
+
self.allow_paths = [self._norm_abs(p) for p in (allow_paths or [])]
|
|
183
|
+
self.custom_deny = []
|
|
184
|
+
self.config = self._load_config(config_path)
|
|
185
|
+
if self.config.get("policy"):
|
|
186
|
+
self.policy = self.config["policy"]
|
|
187
|
+
for p in self.config.get("allow", []):
|
|
188
|
+
try:
|
|
189
|
+
self.allow_patterns.append(re.compile(p))
|
|
190
|
+
except re.error as e:
|
|
191
|
+
self._die("自定义放行模式编译失败: %s (%s)" % (p, e))
|
|
192
|
+
for p in self.config.get("allow_paths", []):
|
|
193
|
+
self.allow_paths.append(self._norm_abs(p))
|
|
194
|
+
for p in self.config.get("deny", []):
|
|
195
|
+
try:
|
|
196
|
+
self.custom_deny.append(re.compile(p))
|
|
197
|
+
except re.error as e:
|
|
198
|
+
self._die("自定义拒绝模式编译失败: %s (%s)" % (p, e))
|
|
199
|
+
if verifier is None:
|
|
200
|
+
cfg_v = self.config.get("verifier") or {}
|
|
201
|
+
if cfg_v.get("heuristic"):
|
|
202
|
+
verifier = HeuristicVerifier()
|
|
203
|
+
elif cfg_v.get("command"):
|
|
204
|
+
verifier = ExternalVerifier(cfg_v["command"],
|
|
205
|
+
cfg_v.get("timeout", 30))
|
|
206
|
+
self.verifier = verifier
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def _die(msg):
|
|
210
|
+
print("错误: %s" % msg, file=sys.stderr)
|
|
211
|
+
raise SystemExit(4)
|
|
212
|
+
|
|
213
|
+
def _load_config(self, path):
|
|
214
|
+
if not path:
|
|
215
|
+
return {}
|
|
216
|
+
try:
|
|
217
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
218
|
+
except Exception as e:
|
|
219
|
+
self._die("配置文件读取/解析失败: %s (%s)" % (path, e))
|
|
220
|
+
if not isinstance(data, dict):
|
|
221
|
+
self._die("配置文件需为 JSON 对象: %s" % path)
|
|
222
|
+
return data
|
|
223
|
+
|
|
224
|
+
def _norm_abs(self, p):
|
|
225
|
+
p = str(p or "").strip().strip("\"'")
|
|
226
|
+
if not p:
|
|
227
|
+
return ""
|
|
228
|
+
p = os.path.expanduser(p)
|
|
229
|
+
if not os.path.isabs(p):
|
|
230
|
+
p = os.path.join(self.cwd, p)
|
|
231
|
+
return os.path.normpath(os.path.abspath(p))
|
|
232
|
+
|
|
233
|
+
def _in_cwd(self, abs_p):
|
|
234
|
+
cwd = os.path.normcase(self.cwd)
|
|
235
|
+
p = os.path.normcase(abs_p)
|
|
236
|
+
return p == cwd or p.startswith(cwd + os.sep)
|
|
237
|
+
|
|
238
|
+
# ── 规则匹配 ──────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
def _text_findings(self, text, scope):
|
|
241
|
+
if not text:
|
|
242
|
+
return []
|
|
243
|
+
findings = []
|
|
244
|
+
for r in GR.TEXT_PATTERNS:
|
|
245
|
+
if r.scope != "both" and r.scope != scope:
|
|
246
|
+
continue
|
|
247
|
+
if self.compiled[r.id].search(text):
|
|
248
|
+
findings.append(Finding(r.id, r.severity, r.category,
|
|
249
|
+
r.reason, r.confidence))
|
|
250
|
+
for rx in self.custom_deny:
|
|
251
|
+
if rx.search(text):
|
|
252
|
+
findings.append(Finding("CFG-DENY", "high", "config",
|
|
253
|
+
"命中自定义拒绝规则", 90))
|
|
254
|
+
return findings
|
|
255
|
+
|
|
256
|
+
def _path_findings(self, path, cap=None):
|
|
257
|
+
if not path:
|
|
258
|
+
return []
|
|
259
|
+
abs_p = self._norm_abs(path)
|
|
260
|
+
if not abs_p:
|
|
261
|
+
return []
|
|
262
|
+
findings = []
|
|
263
|
+
for r in GR.PATH_PATTERNS:
|
|
264
|
+
if self.compiled[r.id].search(os.path.normcase(abs_p)):
|
|
265
|
+
findings.append(Finding(r.id, r.severity, "path", r.reason,
|
|
266
|
+
r.confidence))
|
|
267
|
+
if not self._in_cwd(abs_p):
|
|
268
|
+
findings.append(Finding("OUT-001", "medium", "path",
|
|
269
|
+
"目标在当前工作目录之外", 60))
|
|
270
|
+
if cap is not None:
|
|
271
|
+
capped = []
|
|
272
|
+
for f in findings:
|
|
273
|
+
sev = f.severity if _sev(f.severity) <= _sev(cap) else cap
|
|
274
|
+
capped.append(Finding(f.rule_id, sev, f.category, f.reason,
|
|
275
|
+
f.confidence))
|
|
276
|
+
findings = capped
|
|
277
|
+
return findings
|
|
278
|
+
|
|
279
|
+
def _argv_findings(self, call):
|
|
280
|
+
if call.tool not in ("exec", "run", "shell"):
|
|
281
|
+
return []
|
|
282
|
+
argv = _tokenize(call.cmd)
|
|
283
|
+
if not argv:
|
|
284
|
+
return []
|
|
285
|
+
verb = os.path.basename(argv[0]).lower()
|
|
286
|
+
findings = []
|
|
287
|
+
if verb in _RM_VERBS:
|
|
288
|
+
findings += _analyze_rm(argv)
|
|
289
|
+
elif verb in _FORMAT_VERBS:
|
|
290
|
+
findings += _analyze_format(argv)
|
|
291
|
+
elif verb in _DD_VERBS:
|
|
292
|
+
findings += _analyze_dd(argv)
|
|
293
|
+
elif verb in _POWER_VERBS:
|
|
294
|
+
findings += _analyze_power(argv)
|
|
295
|
+
elif verb in _CHMOD_VERBS:
|
|
296
|
+
findings += _analyze_chmod(argv)
|
|
297
|
+
elif verb in _CHOWN_VERBS:
|
|
298
|
+
findings += _analyze_chown(argv)
|
|
299
|
+
elif verb in _PRIV_VERBS:
|
|
300
|
+
findings += _analyze_priv(argv)
|
|
301
|
+
elif verb in _FIREWALL_VERBS:
|
|
302
|
+
findings += _analyze_firewall(argv)
|
|
303
|
+
elif verb in _SERVICE_VERBS:
|
|
304
|
+
findings += _analyze_service(argv)
|
|
305
|
+
elif verb in _PERSIST_VERBS:
|
|
306
|
+
findings += _analyze_persist(argv)
|
|
307
|
+
elif verb in _REVSHELL_VERBS:
|
|
308
|
+
findings += _analyze_revshell(argv)
|
|
309
|
+
elif verb in ("powershell", "pwsh"):
|
|
310
|
+
for a in argv[1:]:
|
|
311
|
+
low = a.lower()
|
|
312
|
+
if low in ("-enc", "-encodedcommand") or low.startswith("-enc"):
|
|
313
|
+
findings.append(Finding("ARG-PS-ENCODED", "critical",
|
|
314
|
+
"command",
|
|
315
|
+
"PowerShell 编码命令执行", 90))
|
|
316
|
+
break
|
|
317
|
+
return findings
|
|
318
|
+
|
|
319
|
+
def _allow_hit(self, call):
|
|
320
|
+
if call.tool in ("exec", "run", "shell"):
|
|
321
|
+
for rx in self.allow_text + self.allow_patterns:
|
|
322
|
+
if rx.search(call.cmd):
|
|
323
|
+
return True
|
|
324
|
+
if call.tool in ("write", "edit", "read"):
|
|
325
|
+
abs_p = self._norm_abs(call.path)
|
|
326
|
+
if abs_p:
|
|
327
|
+
for ap in self.allow_paths:
|
|
328
|
+
if ap and (abs_p == ap or abs_p.startswith(ap + os.sep)):
|
|
329
|
+
return True
|
|
330
|
+
return False
|
|
331
|
+
|
|
332
|
+
# ── 主评估 ────────────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
def evaluate(self, call):
|
|
335
|
+
findings = []
|
|
336
|
+
if call.tool in ("exec", "run", "shell"):
|
|
337
|
+
findings += self._text_findings(call.cmd, "command")
|
|
338
|
+
findings += self._argv_findings(call)
|
|
339
|
+
elif call.tool in ("write", "edit"):
|
|
340
|
+
findings += self._path_findings(call.path)
|
|
341
|
+
findings += self._text_findings(call.content, "content")
|
|
342
|
+
if call.new:
|
|
343
|
+
findings += self._text_findings(call.new, "content")
|
|
344
|
+
elif call.tool == "read":
|
|
345
|
+
findings += self._path_findings(call.path, cap="medium")
|
|
346
|
+
allow_hit = self._allow_hit(call)
|
|
347
|
+
verdict = self._decide(findings, allow_hit)
|
|
348
|
+
if self.verifier and verdict.allowed:
|
|
349
|
+
verdict = self._apply_verifier(call, verdict)
|
|
350
|
+
return verdict
|
|
351
|
+
|
|
352
|
+
def _decide(self, findings, allow_hit):
|
|
353
|
+
worst = _worst(findings)
|
|
354
|
+
w = _sev(worst)
|
|
355
|
+
if not findings:
|
|
356
|
+
return Verdict(True, "low", "允许:未命中风险规则", [], [])
|
|
357
|
+
if self.policy == "strict":
|
|
358
|
+
if w >= 3:
|
|
359
|
+
return self._deny("critical", findings)
|
|
360
|
+
if w == 2:
|
|
361
|
+
if allow_hit:
|
|
362
|
+
return self._warn("high", "允许(放行规则覆盖高危)", findings)
|
|
363
|
+
return self._deny("high", findings)
|
|
364
|
+
if w == 1:
|
|
365
|
+
if allow_hit:
|
|
366
|
+
return self._warn("medium", "允许(放行规则覆盖中危)", findings)
|
|
367
|
+
return self._deny("medium", findings)
|
|
368
|
+
return self._warn("low", "允许(严格策略,低危提示)", findings)
|
|
369
|
+
if self.policy == "loose":
|
|
370
|
+
if w >= 3:
|
|
371
|
+
return self._deny("critical", findings)
|
|
372
|
+
if w == 2:
|
|
373
|
+
return self._warn("high", "允许(宽松策略,高危仅警告)", findings)
|
|
374
|
+
if w == 1:
|
|
375
|
+
return Verdict(True, "low", "允许(宽松策略)", _ids(findings),
|
|
376
|
+
findings)
|
|
377
|
+
return Verdict(True, "low", "允许", _ids(findings), findings)
|
|
378
|
+
# default
|
|
379
|
+
if w >= 3:
|
|
380
|
+
return self._deny("critical", findings)
|
|
381
|
+
if w == 2:
|
|
382
|
+
if allow_hit:
|
|
383
|
+
return self._warn("high", "允许(放行规则覆盖高危)", findings)
|
|
384
|
+
return self._deny("high", findings)
|
|
385
|
+
if w == 1:
|
|
386
|
+
if allow_hit:
|
|
387
|
+
return Verdict(True, "low", "允许(放行规则覆盖中危)",
|
|
388
|
+
_ids(findings), findings)
|
|
389
|
+
return self._warn("medium", "允许但建议复核(存在中危)", findings)
|
|
390
|
+
return Verdict(True, "low", "允许", _ids(findings), findings)
|
|
391
|
+
|
|
392
|
+
@staticmethod
|
|
393
|
+
def _deny(severity, findings):
|
|
394
|
+
return Verdict(False, severity, "拒绝:%s" % _first_reason(findings),
|
|
395
|
+
_ids(findings), findings)
|
|
396
|
+
|
|
397
|
+
@staticmethod
|
|
398
|
+
def _warn(severity, reason, findings):
|
|
399
|
+
return Verdict(True, severity, reason, _ids(findings), findings)
|
|
400
|
+
|
|
401
|
+
def _apply_verifier(self, call, verdict):
|
|
402
|
+
req = {"tool": call.tool, "cmd": call.cmd, "path": call.path,
|
|
403
|
+
"content_preview": call.content[:500],
|
|
404
|
+
"target": call.target, "policy": self.policy,
|
|
405
|
+
"cwd": self.cwd,
|
|
406
|
+
"findings": [f.to_dict() for f in verdict.findings]}
|
|
407
|
+
resp = self.verifier.verify(req)
|
|
408
|
+
if not resp:
|
|
409
|
+
resp = {"verdict": _REVIEW_VERDICT, "severity": "medium",
|
|
410
|
+
"reason": "意图验证器无响应(按复核处理)"}
|
|
411
|
+
v = resp.get("verdict", _REVIEW_VERDICT)
|
|
412
|
+
if v == _DENY_VERDICT:
|
|
413
|
+
sev = resp.get("severity", "high")
|
|
414
|
+
if sev not in GR.SEVERITY_ORDER:
|
|
415
|
+
sev = "high"
|
|
416
|
+
return Verdict(False, sev,
|
|
417
|
+
"意图验证拒绝:%s" % resp.get("reason", "未说明"),
|
|
418
|
+
verdict.rule_ids, verdict.findings,
|
|
419
|
+
verifier={"name": self.verifier.name, **resp})
|
|
420
|
+
if v == _REVIEW_VERDICT and verdict.severity in ("low", "info"):
|
|
421
|
+
return Verdict(True, "medium",
|
|
422
|
+
"意图验证建议复核:%s" % resp.get("reason", ""),
|
|
423
|
+
verdict.rule_ids, verdict.findings,
|
|
424
|
+
verifier={"name": self.verifier.name, **resp})
|
|
425
|
+
return Verdict(verdict.allowed, verdict.severity, verdict.reason,
|
|
426
|
+
verdict.rule_ids, verdict.findings,
|
|
427
|
+
verifier={"name": self.verifier.name, **resp})
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _ids(findings):
|
|
431
|
+
return [f.rule_id for f in findings]
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _first_reason(findings):
|
|
435
|
+
if not findings:
|
|
436
|
+
return "未说明"
|
|
437
|
+
return findings[0].reason
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _tokenize(cmd):
|
|
441
|
+
try:
|
|
442
|
+
return shlex.split(cmd, posix=(os.name != "nt"))
|
|
443
|
+
except ValueError:
|
|
444
|
+
return cmd.split()
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ── argv 级规则(按动词分组)───────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
_RM_VERBS = {"rm", "rmdir", "del", "erase", "unlink", "shred", "remove-item"}
|
|
450
|
+
_FORMAT_VERBS = {"mkfs", "mkfs.ext2", "mkfs.ext3", "mkfs.ext4", "mkfs.xfs",
|
|
451
|
+
"mkfs.btrfs", "mkfs.vfat", "fdisk", "gdisk", "sfdisk",
|
|
452
|
+
"parted", "wipefs", "diskpart", "format"}
|
|
453
|
+
_DD_VERBS = {"dd"}
|
|
454
|
+
_POWER_VERBS = {"shutdown", "reboot", "halt", "poweroff", "init"}
|
|
455
|
+
_CHMOD_VERBS = {"chmod"}
|
|
456
|
+
_CHOWN_VERBS = {"chown"}
|
|
457
|
+
_PRIV_VERBS = {"useradd", "userdel", "usermod", "passwd", "chpasswd", "su",
|
|
458
|
+
"visudo", "groupadd", "net"}
|
|
459
|
+
_FIREWALL_VERBS = {"iptables", "iptables-restore", "ufw", "firewall-cmd",
|
|
460
|
+
"nft", "netsh"}
|
|
461
|
+
_SERVICE_VERBS = {"systemctl", "service", "sc", "chkconfig", "invoke-rc.d"}
|
|
462
|
+
_PERSIST_VERBS = {"crontab", "reg", "schtasks"}
|
|
463
|
+
_REVSHELL_VERBS = {"nc", "ncat", "socat"}
|
|
464
|
+
|
|
465
|
+
_SYSTEM_DIRS = ("/", "/etc", "/usr", "/home", "/boot", "/dev", "/var",
|
|
466
|
+
"/bin", "/sbin", "/lib", "/lib64", "/opt", "/root")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
_SYSTEM_PREFIXES = tuple(s + "/" for s in _SYSTEM_DIRS if s != "/")
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _under_system_dir(p):
|
|
473
|
+
"""判断路径是否位于某个系统关键目录之下(如 /var/log、/usr/local)。"""
|
|
474
|
+
p = p.replace("\\", "/")
|
|
475
|
+
return p.startswith(_SYSTEM_PREFIXES)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _flags_in(argv, flags):
|
|
479
|
+
for a in argv:
|
|
480
|
+
low = a.lower()
|
|
481
|
+
if low in flags:
|
|
482
|
+
return True
|
|
483
|
+
return False
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _rm_flags(args):
|
|
487
|
+
recursive = force = False
|
|
488
|
+
for a in args:
|
|
489
|
+
low = a.lower()
|
|
490
|
+
if low == "--recursive":
|
|
491
|
+
recursive = True
|
|
492
|
+
elif low == "--force":
|
|
493
|
+
force = True
|
|
494
|
+
elif re.fullmatch(r"-[a-zA-Z]{1,2}", low):
|
|
495
|
+
body = low[1:]
|
|
496
|
+
if "r" in body:
|
|
497
|
+
recursive = True
|
|
498
|
+
if "f" in body:
|
|
499
|
+
force = True
|
|
500
|
+
return recursive, force
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _analyze_rm(argv):
|
|
504
|
+
recursive, force = _rm_flags(argv[1:])
|
|
505
|
+
if not (recursive or force):
|
|
506
|
+
return []
|
|
507
|
+
targets = [a for a in argv[1:] if not a.startswith("-")]
|
|
508
|
+
if not targets:
|
|
509
|
+
return []
|
|
510
|
+
findings = []
|
|
511
|
+
for t in targets:
|
|
512
|
+
t_clean = t.rstrip("/\\") or t
|
|
513
|
+
if (t_clean in _SYSTEM_DIRS or t_clean == "C:"
|
|
514
|
+
or t_clean.upper().startswith("C:\\WINDOWS") or _is_win_root(t)):
|
|
515
|
+
findings.append(Finding("ARG-RM-SYSTEM", "critical", "command",
|
|
516
|
+
"递归删除系统关键路径:%s" % t, 95))
|
|
517
|
+
elif _under_system_dir(t_clean):
|
|
518
|
+
findings.append(Finding("ARG-RM-SYS-SUB", "high", "command",
|
|
519
|
+
"递归删除系统目录下路径:%s" % t, 85))
|
|
520
|
+
elif os.path.isabs(t):
|
|
521
|
+
findings.append(Finding("ARG-RM-ABS", "high", "command",
|
|
522
|
+
"递归删除项目外绝对路径:%s" % t, 85))
|
|
523
|
+
else:
|
|
524
|
+
findings.append(Finding("ARG-RM-REL", "medium", "command",
|
|
525
|
+
"递归删除相对路径目标:%s" % t, 60))
|
|
526
|
+
return findings
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _analyze_format(argv):
|
|
530
|
+
targets = [a for a in argv[1:] if not a.startswith("-")]
|
|
531
|
+
if not targets:
|
|
532
|
+
return []
|
|
533
|
+
findings = []
|
|
534
|
+
for t in targets:
|
|
535
|
+
if re.match(r"^/dev/sd", t) or _is_win_root(t):
|
|
536
|
+
findings.append(Finding("ARG-FMT-DEVICE", "critical", "command",
|
|
537
|
+
"格式化块设备/磁盘:%s" % t, 95))
|
|
538
|
+
elif os.path.isabs(t) or len(targets) == 1:
|
|
539
|
+
findings.append(Finding("ARG-FMT-TARGET", "high", "command",
|
|
540
|
+
"格式化目标:%s" % t, 80))
|
|
541
|
+
return findings
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _analyze_dd(argv):
|
|
545
|
+
of = None
|
|
546
|
+
for a in argv[1:]:
|
|
547
|
+
if a.startswith("of="):
|
|
548
|
+
of = a[3:]
|
|
549
|
+
if not of:
|
|
550
|
+
return []
|
|
551
|
+
if re.match(r"^/dev/sd", of) or _is_win_root(of) or re.match(r"^[a-zA-Z]:", of):
|
|
552
|
+
return [Finding("ARG-DD-DEVICE", "critical", "command",
|
|
553
|
+
"dd 直接写入块设备/磁盘:%s" % of, 95)]
|
|
554
|
+
return [Finding("ARG-DD-FILE", "high", "command",
|
|
555
|
+
"dd 写入磁盘镜像目标:%s" % of, 75)]
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _analyze_power(argv):
|
|
559
|
+
return [Finding("ARG-POWER", "high", "command",
|
|
560
|
+
"系统电源/重启操作(需人工确认)", 80)]
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _analyze_chmod(argv):
|
|
564
|
+
mode = None
|
|
565
|
+
targets = []
|
|
566
|
+
for a in argv[1:]:
|
|
567
|
+
if re.fullmatch(r"[0-7]{3,4}", a):
|
|
568
|
+
mode = a
|
|
569
|
+
elif not a.startswith("-"):
|
|
570
|
+
targets.append(a)
|
|
571
|
+
if not mode:
|
|
572
|
+
return []
|
|
573
|
+
if mode in ("777", "0777"):
|
|
574
|
+
sev, reason = "high", "chmod 设置全权限位"
|
|
575
|
+
for t in targets:
|
|
576
|
+
if t in _SYSTEM_DIRS or _is_win_root(t):
|
|
577
|
+
sev, reason = "critical", "chmod 对系统关键路径设置全权限"
|
|
578
|
+
return [Finding("ARG-CHMOD-777", sev, "command", reason, 85)]
|
|
579
|
+
if len(mode) == 4 and mode[0] in "12467":
|
|
580
|
+
return [Finding("ARG-CHMOD-SETID", "high", "command",
|
|
581
|
+
"chmod 设置 setuid / setgid / sticky 权限位", 80)]
|
|
582
|
+
return []
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _analyze_chown(argv):
|
|
586
|
+
recursive = _flags_in(argv[1:], ("-R", "-r", "--recursive"))
|
|
587
|
+
targets = []
|
|
588
|
+
for a in argv[1:]:
|
|
589
|
+
if a.startswith("-") or ":" in a:
|
|
590
|
+
continue
|
|
591
|
+
targets.append(a)
|
|
592
|
+
if not targets:
|
|
593
|
+
return []
|
|
594
|
+
for t in targets:
|
|
595
|
+
if t in _SYSTEM_DIRS or _is_win_root(t):
|
|
596
|
+
return [Finding("ARG-CHOWN-SYSTEM", "critical", "command",
|
|
597
|
+
"chown 修改系统关键路径属主", 90)]
|
|
598
|
+
if recursive:
|
|
599
|
+
return [Finding("ARG-CHOWN-REC", "medium", "command",
|
|
600
|
+
"递归修改文件属主(需确认目标)", 55)]
|
|
601
|
+
return []
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _analyze_priv(argv):
|
|
605
|
+
v = argv[0].lower()
|
|
606
|
+
if v == "net":
|
|
607
|
+
low = " ".join(a.lower() for a in argv[1:])
|
|
608
|
+
if "localgroup" in low and "administrators" in low:
|
|
609
|
+
return [Finding("ARG-PRIV-NET", "high", "command",
|
|
610
|
+
"把用户加入 Windows 管理员组", 88)]
|
|
611
|
+
return []
|
|
612
|
+
if v in ("useradd", "userdel", "groupadd"):
|
|
613
|
+
return [Finding("ARG-PRIV-ACCOUNT", "high", "command",
|
|
614
|
+
"账户管理操作(需人工确认)", 75)]
|
|
615
|
+
if v == "usermod":
|
|
616
|
+
if _flags_in(argv[1:], ("-aG", "-G", "-a")):
|
|
617
|
+
return [Finding("ARG-PRIV-GROUP", "high", "command",
|
|
618
|
+
"把用户加入管理员组", 85)]
|
|
619
|
+
return [Finding("ARG-PRIV-ACCOUNT", "high", "command",
|
|
620
|
+
"账户管理操作(需人工确认)", 70)]
|
|
621
|
+
if v in ("passwd", "chpasswd"):
|
|
622
|
+
return [Finding("ARG-PRIV-PASSWD", "high", "command",
|
|
623
|
+
"修改账户口令", 80)]
|
|
624
|
+
if v == "su":
|
|
625
|
+
return [Finding("ARG-PRIV-SU", "medium", "command",
|
|
626
|
+
"切换用户身份(需确认)", 55)]
|
|
627
|
+
if v == "visudo":
|
|
628
|
+
return [Finding("ARG-PRIV-SUDOERS", "high", "command",
|
|
629
|
+
"编辑 sudoers(提权点)", 85)]
|
|
630
|
+
return []
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _analyze_firewall(argv):
|
|
634
|
+
v = argv[0].lower()
|
|
635
|
+
if v in ("iptables", "iptables-restore"):
|
|
636
|
+
for a in argv[1:]:
|
|
637
|
+
if a in ("-F", "-X", "-Z", "--flush"):
|
|
638
|
+
return [Finding("ARG-FW-FLUSH", "high", "command",
|
|
639
|
+
"清空防火墙规则", 85)]
|
|
640
|
+
return [Finding("ARG-FW-CHANGE", "medium", "command",
|
|
641
|
+
"修改防火墙规则(需确认)", 55)]
|
|
642
|
+
if v == "ufw" and any(a in ("disable", "reset") for a in argv[1:]):
|
|
643
|
+
return [Finding("ARG-FW-UFW", "high", "command",
|
|
644
|
+
"禁用/重置防火墙", 85)]
|
|
645
|
+
if v == "netsh":
|
|
646
|
+
for a in argv[1:]:
|
|
647
|
+
if a.lower().startswith("state=") and a.lower().endswith("off"):
|
|
648
|
+
return [Finding("ARG-FW-NETSH", "high", "command",
|
|
649
|
+
"关闭 Windows 防火墙", 85)]
|
|
650
|
+
if v in ("firewall-cmd", "nft"):
|
|
651
|
+
return [Finding("ARG-FW-CHANGE", "medium", "command",
|
|
652
|
+
"修改防火墙规则(需确认)", 55)]
|
|
653
|
+
return []
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _analyze_service(argv):
|
|
657
|
+
v = argv[0].lower()
|
|
658
|
+
if v == "systemctl":
|
|
659
|
+
for a in argv[1:]:
|
|
660
|
+
if a in ("stop", "disable", "mask", "kill"):
|
|
661
|
+
return [Finding("ARG-SVC-STOP", "medium", "command",
|
|
662
|
+
"停止/禁用系统服务(需确认)", 60)]
|
|
663
|
+
if a in ("start", "enable", "unmask"):
|
|
664
|
+
return [Finding("ARG-SVC-START", "medium", "command",
|
|
665
|
+
"启用系统服务(需确认)", 55)]
|
|
666
|
+
return []
|
|
667
|
+
if v == "sc":
|
|
668
|
+
for a in argv[1:]:
|
|
669
|
+
if a.lower() in ("delete", "stop"):
|
|
670
|
+
return [Finding("ARG-SVC-SC", "high", "command",
|
|
671
|
+
"删除/停止 Windows 服务", 80)]
|
|
672
|
+
return []
|
|
673
|
+
if v in ("service", "chkconfig", "invoke-rc.d"):
|
|
674
|
+
for a in argv[1:]:
|
|
675
|
+
if a.lower() in ("stop", "off", "disable", "del"):
|
|
676
|
+
return [Finding("ARG-SVC-STOP", "medium", "command",
|
|
677
|
+
"停止/禁用系统服务(需确认)", 60)]
|
|
678
|
+
return []
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _analyze_persist(argv):
|
|
682
|
+
v = argv[0].lower()
|
|
683
|
+
if v == "crontab":
|
|
684
|
+
if any(a in ("-e", "-r", "--remove") for a in argv[1:]):
|
|
685
|
+
return [Finding("ARG-CRON", "high", "command",
|
|
686
|
+
"修改/删除 crontab(持久化)", 85)]
|
|
687
|
+
return []
|
|
688
|
+
if v == "reg":
|
|
689
|
+
low = " ".join(a.lower() for a in argv[1:])
|
|
690
|
+
if any(a in ("add", "delete", "copy") for a in argv[1:]):
|
|
691
|
+
if "run" in low or low.startswith("hklm") or low.startswith("hkcu"):
|
|
692
|
+
return [Finding("ARG-REG-RUN", "high", "command",
|
|
693
|
+
"修改注册表启动项", 88)]
|
|
694
|
+
return [Finding("ARG-REG", "medium", "command",
|
|
695
|
+
"修改注册表(需确认)", 60)]
|
|
696
|
+
return []
|
|
697
|
+
if v == "schtasks":
|
|
698
|
+
if any(a.lower() in ("/create", "/delete", "/change") for a in argv[1:]):
|
|
699
|
+
return [Finding("ARG-SCHTASKS", "high", "command",
|
|
700
|
+
"创建/修改计划任务(持久化)", 85)]
|
|
701
|
+
return []
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _analyze_revshell(argv):
|
|
705
|
+
v = argv[0].lower()
|
|
706
|
+
if v in ("nc", "ncat"):
|
|
707
|
+
for a in argv[1:]:
|
|
708
|
+
if a == "-e" or a.startswith("-e") or a.startswith("--exec"):
|
|
709
|
+
return [Finding("ARG-REV-NC", "critical", "command",
|
|
710
|
+
"netcat -e 执行(反向 shell)", 95)]
|
|
711
|
+
return []
|
|
712
|
+
if v == "socat":
|
|
713
|
+
for a in argv[1:]:
|
|
714
|
+
low = a.lower()
|
|
715
|
+
if "exec:" in low or low.startswith("system:"):
|
|
716
|
+
return [Finding("ARG-REV-SOCAT", "critical", "command",
|
|
717
|
+
"socat 执行远程命令(反向 shell)", 90)]
|
|
718
|
+
return []
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
# ── 可插拔意图验证 ─────────────────────────────────────────────────────────
|
|
722
|
+
|
|
723
|
+
class HeuristicVerifier:
|
|
724
|
+
"""内置本地启发式意图验证器(确定性,不调用任何模型)。
|
|
725
|
+
|
|
726
|
+
供 --heuristic 显式启用:对未命中的调用做「高影响动词」复核,
|
|
727
|
+
命中则把结论升级为需人工确认(review)。
|
|
728
|
+
"""
|
|
729
|
+
|
|
730
|
+
name = "heuristic"
|
|
731
|
+
_confirm = re.compile(
|
|
732
|
+
r"(?i)\b(?:install|uninstall|reinstall|delete|remove|overwrite|reset|"
|
|
733
|
+
r"drop|truncate|purge|cleanup|clear|restart|reboot|shutdown|stop|"
|
|
734
|
+
r"disable|enable|start|upgrade|update|format|wipe)\b"
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
def verify(self, req):
|
|
738
|
+
text = " ".join(filter(None, [
|
|
739
|
+
req.get("cmd", ""), req.get("path", ""),
|
|
740
|
+
req.get("content_preview", ""), req.get("target", ""),
|
|
741
|
+
]))[:2000]
|
|
742
|
+
if self._confirm.search(text):
|
|
743
|
+
return {"verdict": _REVIEW_VERDICT, "severity": "medium",
|
|
744
|
+
"reason": "操作含需人工确认的高影响动词"}
|
|
745
|
+
return {"verdict": _ALLOW_VERDICT, "severity": "low",
|
|
746
|
+
"reason": "本地启发式未发现需确认信号"}
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
class ExternalVerifier:
|
|
750
|
+
"""外部意图验证器:把请求 JSON 写入命令 stdin,读 stdout JSON 判定。
|
|
751
|
+
|
|
752
|
+
协议见 references/intent-verifier.md。命令失败/超时/输出非法时返回 None,
|
|
753
|
+
调用方按「复核」处理,不静默放行也不误杀。
|
|
754
|
+
"""
|
|
755
|
+
|
|
756
|
+
name = "external"
|
|
757
|
+
|
|
758
|
+
def __init__(self, command, timeout=30):
|
|
759
|
+
if isinstance(command, str):
|
|
760
|
+
command = shlex.split(command, posix=(os.name != "nt"))
|
|
761
|
+
self.command = list(command)
|
|
762
|
+
self.timeout = timeout
|
|
763
|
+
|
|
764
|
+
def verify(self, req):
|
|
765
|
+
try:
|
|
766
|
+
r = subprocess.run(
|
|
767
|
+
self.command,
|
|
768
|
+
input=json.dumps(req, ensure_ascii=False),
|
|
769
|
+
capture_output=True, text=True, encoding="utf-8",
|
|
770
|
+
errors="replace", timeout=self.timeout, check=False,
|
|
771
|
+
)
|
|
772
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
773
|
+
return None
|
|
774
|
+
try:
|
|
775
|
+
out = json.loads(r.stdout or "{}")
|
|
776
|
+
except ValueError:
|
|
777
|
+
return None
|
|
778
|
+
if not isinstance(out, dict):
|
|
779
|
+
return None
|
|
780
|
+
v = out.get("verdict")
|
|
781
|
+
if v not in (_ALLOW_VERDICT, _DENY_VERDICT, _REVIEW_VERDICT):
|
|
782
|
+
return None
|
|
783
|
+
return out
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
# ── 审计 ───────────────────────────────────────────────────────────────────
|
|
787
|
+
|
|
788
|
+
def _audit_id(call, ts_ns):
|
|
789
|
+
h = hashlib.sha1(("%s|%s|%d" % (call.tool, call.describe(), ts_ns))
|
|
790
|
+
.encode("utf-8")).hexdigest()[:12]
|
|
791
|
+
return h
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _audit_record(call, verdict, policy, ts_ns):
|
|
795
|
+
return {
|
|
796
|
+
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
797
|
+
"id": _audit_id(call, ts_ns),
|
|
798
|
+
"tool": call.tool,
|
|
799
|
+
"action": call.describe(200),
|
|
800
|
+
"verdict": _ALLOW_VERDICT if verdict.allowed else _DENY_VERDICT,
|
|
801
|
+
"allowed": verdict.allowed,
|
|
802
|
+
"severity": verdict.severity,
|
|
803
|
+
"policy": policy,
|
|
804
|
+
"rule_ids": verdict.rule_ids,
|
|
805
|
+
"reason": verdict.reason,
|
|
806
|
+
"verifier": verdict.verifier,
|
|
807
|
+
"cwd": os.getcwd(),
|
|
808
|
+
"exit": verdict.exit_code(),
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def audit_append(log_path, record):
|
|
813
|
+
Path(log_path).parent.mkdir(parents=True, exist_ok=True)
|
|
814
|
+
with open(log_path, "a", encoding="utf-8") as fh:
|
|
815
|
+
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def audit_read(log_path):
|
|
819
|
+
if not Path(log_path).is_file():
|
|
820
|
+
return []
|
|
821
|
+
records = []
|
|
822
|
+
for line in Path(log_path).read_text(encoding="utf-8",
|
|
823
|
+
errors="replace").splitlines():
|
|
824
|
+
line = line.strip()
|
|
825
|
+
if not line:
|
|
826
|
+
continue
|
|
827
|
+
try:
|
|
828
|
+
records.append(json.loads(line))
|
|
829
|
+
except ValueError:
|
|
830
|
+
continue
|
|
831
|
+
return records
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
# ── CLI ────────────────────────────────────────────────────────────────────
|
|
835
|
+
|
|
836
|
+
def _parse_calls(args):
|
|
837
|
+
if args.batch:
|
|
838
|
+
try:
|
|
839
|
+
data = json.loads(Path(args.batch).read_text(encoding="utf-8"))
|
|
840
|
+
except Exception as e:
|
|
841
|
+
print("错误: 批量文件读取失败: %s (%s)" % (args.batch, e),
|
|
842
|
+
file=sys.stderr)
|
|
843
|
+
raise SystemExit(4)
|
|
844
|
+
if isinstance(data, dict):
|
|
845
|
+
data = data.get("calls") or []
|
|
846
|
+
if not isinstance(data, list):
|
|
847
|
+
print("错误: 批量文件需为 JSON 数组或 {\"calls\": [...]}",
|
|
848
|
+
file=sys.stderr)
|
|
849
|
+
raise SystemExit(4)
|
|
850
|
+
calls = []
|
|
851
|
+
for d in data:
|
|
852
|
+
if not isinstance(d, dict):
|
|
853
|
+
print("错误: 批量元素需为对象", file=sys.stderr)
|
|
854
|
+
raise SystemExit(4)
|
|
855
|
+
calls.append(ToolCall(**{
|
|
856
|
+
k: (d.get(k) or "") for k in
|
|
857
|
+
("tool", "cmd", "path", "content", "old", "new", "target")
|
|
858
|
+
}))
|
|
859
|
+
if not calls:
|
|
860
|
+
print("错误: 批量文件为空", file=sys.stderr)
|
|
861
|
+
raise SystemExit(4)
|
|
862
|
+
return calls
|
|
863
|
+
tool = args.tool or "exec"
|
|
864
|
+
tool = tool.lower()
|
|
865
|
+
if tool not in TOOL_TYPES:
|
|
866
|
+
print("错误: 未知工具类型: %s(可选: %s)"
|
|
867
|
+
% (tool, ", ".join(TOOL_TYPES)), file=sys.stderr)
|
|
868
|
+
raise SystemExit(4)
|
|
869
|
+
return [ToolCall(tool=tool, cmd=args.cmd, path=args.path,
|
|
870
|
+
content=args.content, old=args.old, new=args.new,
|
|
871
|
+
target=args.target)]
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _build_verifier(args):
|
|
875
|
+
if args.no_verifier:
|
|
876
|
+
return None
|
|
877
|
+
if args.verifier:
|
|
878
|
+
return ExternalVerifier(args.verifier, args.verifier_timeout)
|
|
879
|
+
if args.heuristic:
|
|
880
|
+
return HeuristicVerifier()
|
|
881
|
+
return None
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _print_check_text(call, verdict, color):
|
|
885
|
+
def c(s, code):
|
|
886
|
+
return "\033[%sm\033[0m" % (code, s) if color else s
|
|
887
|
+
kind = c("DENY", "31;1") if not verdict.allowed else c("ALLOW", "32;1")
|
|
888
|
+
sev = verdict.severity.upper()
|
|
889
|
+
print("[检查] %s" % call.describe(300))
|
|
890
|
+
print("[判定] %s (%s)" % (kind, sev))
|
|
891
|
+
print("[原因] %s" % verdict.reason)
|
|
892
|
+
for f in verdict.findings:
|
|
893
|
+
print("[规则] %s · %s · %s"
|
|
894
|
+
% (f.rule_id, f.severity.upper(), f.reason))
|
|
895
|
+
if verdict.verifier:
|
|
896
|
+
print("[验证] %s" % json.dumps(verdict.verifier,
|
|
897
|
+
ensure_ascii=False))
|
|
898
|
+
print("")
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _cmd_check(args):
|
|
902
|
+
engine = RuleEngine(policy=args.policy or "default",
|
|
903
|
+
allow_patterns=args.allow,
|
|
904
|
+
allow_paths=args.allow_path,
|
|
905
|
+
config_path=args.config,
|
|
906
|
+
cwd=args.cwd,
|
|
907
|
+
verifier=_build_verifier(args))
|
|
908
|
+
calls = _parse_calls(args)
|
|
909
|
+
color = (not args.no_color) and sys.stdout.isatty()
|
|
910
|
+
results = []
|
|
911
|
+
for call in calls:
|
|
912
|
+
verdict = engine.evaluate(call)
|
|
913
|
+
results.append((call, verdict))
|
|
914
|
+
if args.audit_log:
|
|
915
|
+
for call, verdict in results:
|
|
916
|
+
audit_append(args.audit_log,
|
|
917
|
+
_audit_record(call, verdict, engine.policy,
|
|
918
|
+
time.time_ns()))
|
|
919
|
+
exit_code = max((v.exit_code() for _, v in results), default=0)
|
|
920
|
+
if args.json:
|
|
921
|
+
payload = {
|
|
922
|
+
"tool": TOOL_NAME, "version": VERSION, "policy": engine.policy,
|
|
923
|
+
"summary": {
|
|
924
|
+
"calls": len(results),
|
|
925
|
+
"allowed": sum(1 for _, v in results if v.allowed),
|
|
926
|
+
"denied": sum(1 for _, v in results if not v.allowed),
|
|
927
|
+
"exit": exit_code,
|
|
928
|
+
},
|
|
929
|
+
"results": [
|
|
930
|
+
{"call": c.to_dict(), **v.to_dict()} for c, v in results
|
|
931
|
+
],
|
|
932
|
+
}
|
|
933
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
934
|
+
elif args.report:
|
|
935
|
+
_write_report(args.report, results, engine.policy)
|
|
936
|
+
else:
|
|
937
|
+
for call, verdict in results:
|
|
938
|
+
_print_check_text(call, verdict, color)
|
|
939
|
+
raise SystemExit(exit_code)
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _write_report(path, results, policy):
|
|
943
|
+
lines = ["# 元盾安全检查报告(yotta-guardian)", "",
|
|
944
|
+
"- 生成时间:%s" % datetime.now(timezone.utc)
|
|
945
|
+
.isoformat(timespec="seconds"),
|
|
946
|
+
"- 策略:%s" % policy, "",
|
|
947
|
+
"| # | 工具 | 动作 | 判定 | 严重级 | 原因 |",
|
|
948
|
+
"|---|------|------|------|--------|------|"]
|
|
949
|
+
for i, (call, verdict) in enumerate(results, 1):
|
|
950
|
+
lines.append("| %d | %s | %s | %s | %s | %s |"
|
|
951
|
+
% (i, call.tool, call.describe(80),
|
|
952
|
+
"允许" if verdict.allowed else "拒绝",
|
|
953
|
+
verdict.severity.upper(),
|
|
954
|
+
verdict.reason.replace("|", "/")))
|
|
955
|
+
lines += ["", "## 命中规则明细", ""]
|
|
956
|
+
for call, verdict in results:
|
|
957
|
+
if not verdict.findings:
|
|
958
|
+
continue
|
|
959
|
+
lines += ["### %s:%s" % (call.tool, call.describe(80)), ""]
|
|
960
|
+
for f in verdict.findings:
|
|
961
|
+
lines.append("- %s [%s] %s"
|
|
962
|
+
% (f.rule_id, f.severity.upper(), f.reason))
|
|
963
|
+
lines.append("")
|
|
964
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
965
|
+
Path(path).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
966
|
+
print("报告已写入: %s" % path)
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _cmd_audit(args):
|
|
970
|
+
log = args.file or os.environ.get("YOTTA_GUARDIAN_AUDIT")
|
|
971
|
+
if not log:
|
|
972
|
+
print("错误: 请用 --file 指定审计日志(或设置 YOTTA_GUARDIAN_AUDIT)",
|
|
973
|
+
file=sys.stderr)
|
|
974
|
+
raise SystemExit(4)
|
|
975
|
+
records = audit_read(log)
|
|
976
|
+
if args.denied:
|
|
977
|
+
records = [r for r in records if not r.get("allowed", True)]
|
|
978
|
+
if args.tool:
|
|
979
|
+
records = [r for r in records if r.get("tool") == args.tool]
|
|
980
|
+
if args.since:
|
|
981
|
+
records = [r for r in records if r.get("ts", "") >= args.since]
|
|
982
|
+
if args.tail:
|
|
983
|
+
records = records[-args.tail:]
|
|
984
|
+
if args.json:
|
|
985
|
+
print(json.dumps(records, ensure_ascii=False, indent=2))
|
|
986
|
+
else:
|
|
987
|
+
if not records:
|
|
988
|
+
print("审计日志为空: %s" % log)
|
|
989
|
+
for r in records:
|
|
990
|
+
print("%s %s %s %s(%s) %s"
|
|
991
|
+
% (r.get("ts", ""), r.get("id", ""), r.get("verdict", ""),
|
|
992
|
+
r.get("severity", ""), r.get("policy", ""),
|
|
993
|
+
r.get("action", "")))
|
|
994
|
+
raise SystemExit(0)
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _cmd_rules(args):
|
|
998
|
+
if args.config:
|
|
999
|
+
engine = RuleEngine(config_path=args.config, cwd=args.cwd)
|
|
1000
|
+
print("自定义规则文件校验通过: %s" % args.config)
|
|
1001
|
+
print("策略: %s | 放行模式 %d 条 | 放行路径 %d 条 | 自定义拒绝 %d 条"
|
|
1002
|
+
% (engine.policy, len(engine.allow_patterns),
|
|
1003
|
+
len(engine.allow_paths), len(engine.custom_deny)))
|
|
1004
|
+
raise SystemExit(0)
|
|
1005
|
+
counts = {}
|
|
1006
|
+
for r in GR.TEXT_PATTERNS:
|
|
1007
|
+
counts[r.severity] = counts.get(r.severity, 0) + 1
|
|
1008
|
+
path_counts = {}
|
|
1009
|
+
for r in GR.PATH_PATTERNS:
|
|
1010
|
+
path_counts[r.severity] = path_counts.get(r.severity, 0) + 1
|
|
1011
|
+
if args.json:
|
|
1012
|
+
print(json.dumps({
|
|
1013
|
+
"tool": TOOL_NAME, "version": VERSION,
|
|
1014
|
+
"text_patterns": len(GR.TEXT_PATTERNS),
|
|
1015
|
+
"path_patterns": len(GR.PATH_PATTERNS),
|
|
1016
|
+
"allow_patterns": len(GR.ALLOW_PATTERNS),
|
|
1017
|
+
"text_by_severity": counts,
|
|
1018
|
+
"path_by_severity": path_counts,
|
|
1019
|
+
}, ensure_ascii=False, indent=2))
|
|
1020
|
+
else:
|
|
1021
|
+
print("%s %s 规则摘要" % (TOOL_CN, TOOL_NAME))
|
|
1022
|
+
print("文本规则: %d 条 %s" % (len(GR.TEXT_PATTERNS), counts))
|
|
1023
|
+
print("路径规则: %d 条 %s" % (len(GR.PATH_PATTERNS), path_counts))
|
|
1024
|
+
print("默认放行模式: %d 条" % len(GR.ALLOW_PATTERNS))
|
|
1025
|
+
raise SystemExit(0)
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _cmd_version(_args):
|
|
1029
|
+
print("%s %s(%s)" % (TOOL_NAME, VERSION, TOOL_CN))
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _build_parser():
|
|
1033
|
+
ap = argparse.ArgumentParser(
|
|
1034
|
+
prog=TOOL_NAME,
|
|
1035
|
+
description="元盾 yotta-guardian:确定性规则引擎 + 可插拔意图验证的"
|
|
1036
|
+
"工具调用拦截护栏")
|
|
1037
|
+
sub = ap.add_subparsers(dest="command")
|
|
1038
|
+
|
|
1039
|
+
p_check = sub.add_parser("check", help="评估工具调用安全性")
|
|
1040
|
+
p_check.add_argument("tool", nargs="?", default=None,
|
|
1041
|
+
help="工具类型: exec/run/shell/write/edit/read")
|
|
1042
|
+
p_check.add_argument("--cmd", default="", help="命令文本(exec/run/shell)")
|
|
1043
|
+
p_check.add_argument("--path", default="", help="目标路径(write/edit/read)")
|
|
1044
|
+
p_check.add_argument("--content", default="", help="写入内容(write/edit)")
|
|
1045
|
+
p_check.add_argument("--old", default="", help="编辑前内容(edit)")
|
|
1046
|
+
p_check.add_argument("--new", default="", help="编辑后内容(edit)")
|
|
1047
|
+
p_check.add_argument("--target", default="", help="通用目标描述")
|
|
1048
|
+
p_check.add_argument("--batch", default="", help="批量 JSON 文件")
|
|
1049
|
+
p_check.add_argument("--policy", default="default",
|
|
1050
|
+
choices=("default", "strict", "loose"))
|
|
1051
|
+
p_check.add_argument("--allow", action="append", default=None,
|
|
1052
|
+
help="追加放行模式(可重复)")
|
|
1053
|
+
p_check.add_argument("--allow-path", dest="allow_path", action="append",
|
|
1054
|
+
default=None, help="追加放行路径前缀(可重复)")
|
|
1055
|
+
p_check.add_argument("--config", default="", help="自定义规则 JSON 文件")
|
|
1056
|
+
p_check.add_argument("--heuristic", action="store_true",
|
|
1057
|
+
help="启用内置启发式意图验证")
|
|
1058
|
+
p_check.add_argument("--verifier", default="",
|
|
1059
|
+
help="外部意图验证命令(如 python3 verify.py)")
|
|
1060
|
+
p_check.add_argument("--verifier-timeout", dest="verifier_timeout",
|
|
1061
|
+
type=int, default=30)
|
|
1062
|
+
p_check.add_argument("--no-verifier", action="store_true",
|
|
1063
|
+
help="禁用意图验证")
|
|
1064
|
+
p_check.add_argument("--audit-log", dest="audit_log", default="",
|
|
1065
|
+
help="审计日志 JSONL 路径")
|
|
1066
|
+
p_check.add_argument("--json", action="store_true",
|
|
1067
|
+
help="输出 JSON(stdout 纯净)")
|
|
1068
|
+
p_check.add_argument("--report", default="", help="输出 Markdown 报告")
|
|
1069
|
+
p_check.add_argument("--cwd", default="", help="工作目录(路径上下文)")
|
|
1070
|
+
p_check.add_argument("--no-color", action="store_true",
|
|
1071
|
+
help="禁用颜色输出")
|
|
1072
|
+
|
|
1073
|
+
p_audit = sub.add_parser("audit", help="查询审计日志")
|
|
1074
|
+
p_audit.add_argument("--file", default="", help="审计日志 JSONL 路径")
|
|
1075
|
+
p_audit.add_argument("--tail", type=int, default=0)
|
|
1076
|
+
p_audit.add_argument("--denied", action="store_true")
|
|
1077
|
+
p_audit.add_argument("--since", default="")
|
|
1078
|
+
p_audit.add_argument("--tool", default="")
|
|
1079
|
+
p_audit.add_argument("--json", action="store_true")
|
|
1080
|
+
|
|
1081
|
+
p_rules = sub.add_parser("rules", help="规则摘要 / 校验自定义规则")
|
|
1082
|
+
p_rules.add_argument("--config", default="")
|
|
1083
|
+
p_rules.add_argument("--cwd", default="")
|
|
1084
|
+
p_rules.add_argument("--json", action="store_true")
|
|
1085
|
+
|
|
1086
|
+
sub.add_parser("version", help="打印版本")
|
|
1087
|
+
return ap
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def main(argv=None):
|
|
1091
|
+
ap = _build_parser()
|
|
1092
|
+
try:
|
|
1093
|
+
args = ap.parse_args(argv)
|
|
1094
|
+
except SystemExit as e:
|
|
1095
|
+
if e.code == 2:
|
|
1096
|
+
raise SystemExit(4)
|
|
1097
|
+
raise
|
|
1098
|
+
if not args.command:
|
|
1099
|
+
ap.print_help()
|
|
1100
|
+
raise SystemExit(4)
|
|
1101
|
+
if args.command == "check":
|
|
1102
|
+
_cmd_check(args)
|
|
1103
|
+
elif args.command == "audit":
|
|
1104
|
+
_cmd_audit(args)
|
|
1105
|
+
elif args.command == "rules":
|
|
1106
|
+
_cmd_rules(args)
|
|
1107
|
+
elif args.command == "version":
|
|
1108
|
+
_cmd_version(args)
|
|
1109
|
+
else:
|
|
1110
|
+
ap.print_help()
|
|
1111
|
+
raise SystemExit(4)
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
if __name__ == "__main__":
|
|
1115
|
+
main()
|