ezshell 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.
- ezshell-0.2.0.dist-info/METADATA +174 -0
- ezshell-0.2.0.dist-info/RECORD +37 -0
- ezshell-0.2.0.dist-info/WHEEL +5 -0
- ezshell-0.2.0.dist-info/entry_points.txt +3 -0
- ezshell-0.2.0.dist-info/licenses/LICENSE +21 -0
- ezshell-0.2.0.dist-info/top_level.txt +1 -0
- src/__init__.py +3 -0
- src/app.py +286 -0
- src/core/__init__.py +0 -0
- src/core/assistant.py +525 -0
- src/core/capture.py +213 -0
- src/core/chat_bridge.py +107 -0
- src/core/config.py +238 -0
- src/core/context.py +229 -0
- src/core/groq_client.py +376 -0
- src/core/llm_client.py +111 -0
- src/core/prompts.py +262 -0
- src/core/provider.py +233 -0
- src/core/proxy_client.py +166 -0
- src/core/pty_shell.py +273 -0
- src/core/redact.py +95 -0
- src/core/risk.py +455 -0
- src/core/safety.py +268 -0
- src/core/sanitize.py +39 -0
- src/core/schema.py +65 -0
- src/core/shell_hook.py +388 -0
- src/core/suggest.py +94 -0
- src/core/updater.py +428 -0
- src/i18n.py +422 -0
- src/main.py +346 -0
- src/ui/chat_tui.py +634 -0
- src/ui/gui_window.py +1018 -0
- src/ui/inline.py +330 -0
- src/ui/logo.py +194 -0
- src/ui/panel.py +163 -0
- src/ui/prompt.py +184 -0
- src/ui/render.py +269 -0
src/core/risk.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""Moteur de risque (DEVELOPMENT.md §7.4).
|
|
2
|
+
|
|
3
|
+
risque_final = max(niveau_règles, niveau_modèle)
|
|
4
|
+
|
|
5
|
+
Le modèle ne peut jamais abaisser un risque détecté par une règle.
|
|
6
|
+
Chaque règle a un identifiant, un niveau et une raison, et possède au moins
|
|
7
|
+
un cas positif et un cas négatif dans tests/test_risk.py.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import shlex
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Callable, List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
LOW, MEDIUM, HIGH = 0, 1, 2
|
|
18
|
+
_NAMES = {LOW: "low", MEDIUM: "medium", HIGH: "high"}
|
|
19
|
+
_LEVELS = {v: k for k, v in _NAMES.items()}
|
|
20
|
+
|
|
21
|
+
_SEPARATORS = {";", "&&", "||", "|", "&", "|&", "\n"}
|
|
22
|
+
_WRAPPERS = {"sudo", "doas", "env", "command", "nohup", "time", "nice", "exec", "builtin"}
|
|
23
|
+
_ROOTISH = {"/", "/*", "~", "~/", "~/*", "$HOME", "$HOME/", "$HOME/*", "${HOME}", "*", ".", ".."}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Rule:
|
|
28
|
+
id: str
|
|
29
|
+
level: int
|
|
30
|
+
reason: str
|
|
31
|
+
check: Callable[[List[str], str], bool] # (tokens du segment, commande brute)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _flags(tokens: List[str]) -> set:
|
|
35
|
+
out = set()
|
|
36
|
+
for t in tokens[1:]:
|
|
37
|
+
if t.startswith("--"):
|
|
38
|
+
out.add(t)
|
|
39
|
+
elif t.startswith("-") and len(t) > 1:
|
|
40
|
+
out.update(t[1:])
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _targets(tokens: List[str]) -> List[str]:
|
|
45
|
+
return [t for t in tokens[1:] if not t.startswith("-")]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is(tokens: List[str], name: str) -> bool:
|
|
49
|
+
return bool(tokens) and tokens[0].rsplit("/", 1)[-1] == name
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _rx(pattern: str, flags: int = re.IGNORECASE):
|
|
53
|
+
compiled = re.compile(pattern, flags)
|
|
54
|
+
return lambda tokens, raw: bool(compiled.search(raw))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _rm_root(t, raw):
|
|
58
|
+
if not _is(t, "rm"):
|
|
59
|
+
return False
|
|
60
|
+
f = _flags(t)
|
|
61
|
+
recursive = "r" in f or "R" in f or "--recursive" in f
|
|
62
|
+
return recursive and any(x in _ROOTISH for x in _targets(t))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _rm_recursive(t, raw):
|
|
66
|
+
if not _is(t, "rm"):
|
|
67
|
+
return False
|
|
68
|
+
f = _flags(t)
|
|
69
|
+
return "r" in f or "R" in f or "--recursive" in f
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _find_delete(t, raw):
|
|
73
|
+
return _is(t, "find") and (
|
|
74
|
+
"-delete" in t or ("-exec" in t and any(x in ("rm", "shred") for x in t))
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _git_force_push(t, raw):
|
|
79
|
+
if not (_is(t, "git") and "push" in t):
|
|
80
|
+
return False
|
|
81
|
+
return any(x in ("--force", "-f") for x in t)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _recursive_perm_root(t, raw):
|
|
85
|
+
if not (_is(t, "chmod") or _is(t, "chown") or _is(t, "chgrp")):
|
|
86
|
+
return False
|
|
87
|
+
f = _flags(t)
|
|
88
|
+
return ("R" in f or "--recursive" in f) and any(x in _ROOTISH for x in _targets(t))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _recursive_perm(t, raw):
|
|
92
|
+
if not (_is(t, "chmod") or _is(t, "chown") or _is(t, "chgrp")):
|
|
93
|
+
return False
|
|
94
|
+
f = _flags(t)
|
|
95
|
+
return "R" in f or "--recursive" in f
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _overwrite_redirect(t, raw):
|
|
99
|
+
# '>' sans '>>' et hors /dev/null, /dev/stderr, /dev/stdout, &1/&2
|
|
100
|
+
for m in re.finditer(r"(?<![>&0-9])>(?!>)\s*([^\s|;&]+)", raw):
|
|
101
|
+
target = m.group(1)
|
|
102
|
+
if target not in ("/dev/null", "/dev/stderr", "/dev/stdout") and not target.startswith("&"):
|
|
103
|
+
return True
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---- PowerShell / Windows -------------------------------------------------
|
|
108
|
+
# PowerShell ignore la casse, accepte les alias (rm, del, ri…) et les paramètres abrégés
|
|
109
|
+
# (-r, -rec, -Recurse) : ces règles travaillent donc sur le texte brut, segment par segment.
|
|
110
|
+
_PS_REMOVE = r"(remove-item|ri|rm|del|erase|rd|rmdir)"
|
|
111
|
+
_PS_ROOT = re.compile(
|
|
112
|
+
r"""^['"]?(
|
|
113
|
+
[a-z]:[\\/]?\*? # C: C:\ C:\*
|
|
114
|
+
| [\\/]\*? # \ /
|
|
115
|
+
| ~[\\/]?\*? # ~ ~\*
|
|
116
|
+
| \$home[\\/]?\*?
|
|
117
|
+
| \$env:(userprofile|systemdrive|systemroot|windir|homedrive|programfiles)[\\/]?\*?
|
|
118
|
+
| \*
|
|
119
|
+
)['"]?$""",
|
|
120
|
+
re.IGNORECASE | re.VERBOSE,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _ps_segments(raw: str) -> List[str]:
|
|
125
|
+
return [seg.strip() for seg in re.split(r"&&|\|\||[;|\n]", raw) if seg.strip()]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _ps_args(segment: str, command: str) -> Optional[List[str]]:
|
|
129
|
+
"""Arguments du segment s'il commence par `command` (regex, insensible à la casse)."""
|
|
130
|
+
words = segment.split()
|
|
131
|
+
if words and re.fullmatch(command, words[0].lstrip("&").strip("'\""), re.IGNORECASE):
|
|
132
|
+
return words[1:]
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _ps_recursive_remove(raw: str) -> Tuple[bool, bool]:
|
|
137
|
+
"""(suppression récursive, cible racine/dossier personnel) pour Remove-Item, del /s, rd /s."""
|
|
138
|
+
recursive = root = False
|
|
139
|
+
for seg in _ps_segments(raw):
|
|
140
|
+
args = _ps_args(seg, _PS_REMOVE)
|
|
141
|
+
if args is None:
|
|
142
|
+
continue
|
|
143
|
+
flags = [a.lower() for a in args]
|
|
144
|
+
if any(re.fullmatch(r"-r(e(c(u(r(se?)?)?)?)?)?|/s", f) for f in flags):
|
|
145
|
+
recursive = True
|
|
146
|
+
if any(_PS_ROOT.match(a) for a in args if not a.startswith(("-", "/")) or len(a) <= 2):
|
|
147
|
+
root = True
|
|
148
|
+
return recursive, root
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _ps_force_kill(t, raw):
|
|
152
|
+
for seg in _ps_segments(raw):
|
|
153
|
+
args = _ps_args(seg, r"(stop-process|spps|kill)")
|
|
154
|
+
if args is not None and any(re.fullmatch(r"-f(o(r(ce?)?)?)?", a, re.I) for a in args):
|
|
155
|
+
return True
|
|
156
|
+
args = _ps_args(seg, r"taskkill(\.exe)?")
|
|
157
|
+
if args is not None and any(a.lower() == "/f" for a in args):
|
|
158
|
+
return True
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
RULES: Tuple[Rule, ...] = (
|
|
163
|
+
# ---- high -----------------------------------------------------------
|
|
164
|
+
Rule(
|
|
165
|
+
"rm-rf-root",
|
|
166
|
+
HIGH,
|
|
167
|
+
"Suppression récursive de la racine, du dossier personnel ou de tout le dossier courant.",
|
|
168
|
+
_rm_root,
|
|
169
|
+
),
|
|
170
|
+
Rule(
|
|
171
|
+
"mkfs",
|
|
172
|
+
HIGH,
|
|
173
|
+
"Formate un système de fichiers : toutes les données du périphérique sont perdues.",
|
|
174
|
+
lambda t, r: bool(t) and t[0].startswith("mkfs"),
|
|
175
|
+
),
|
|
176
|
+
Rule(
|
|
177
|
+
"dd-device",
|
|
178
|
+
HIGH,
|
|
179
|
+
"dd écrit directement sur un périphérique bloc.",
|
|
180
|
+
lambda t, r: _is(t, "dd") and any(re.match(r"of=/dev/", x) for x in t),
|
|
181
|
+
),
|
|
182
|
+
Rule(
|
|
183
|
+
"redirect-device",
|
|
184
|
+
HIGH,
|
|
185
|
+
"Redirection qui écrase un périphérique brut.",
|
|
186
|
+
_rx(r">\s*/dev/(sd[a-z]|nvme\d|vd[a-z]|hd[a-z]|mem|kmem)"),
|
|
187
|
+
),
|
|
188
|
+
Rule(
|
|
189
|
+
"fork-bomb",
|
|
190
|
+
HIGH,
|
|
191
|
+
"Bombe fork : sature la table des processus.",
|
|
192
|
+
_rx(r":\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:", 0),
|
|
193
|
+
),
|
|
194
|
+
Rule(
|
|
195
|
+
"perm-root",
|
|
196
|
+
HIGH,
|
|
197
|
+
"Changement récursif de permissions/propriétaire sur la racine ou le dossier personnel.",
|
|
198
|
+
_recursive_perm_root,
|
|
199
|
+
),
|
|
200
|
+
Rule(
|
|
201
|
+
"curl-pipe-shell",
|
|
202
|
+
HIGH,
|
|
203
|
+
"Exécute directement un script téléchargé (curl/wget | sh).",
|
|
204
|
+
_rx(r"\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z|da|k)?sh\b"),
|
|
205
|
+
),
|
|
206
|
+
Rule(
|
|
207
|
+
"git-force-push", HIGH, "Push forcé : peut écraser l'historique distant.", _git_force_push
|
|
208
|
+
),
|
|
209
|
+
Rule(
|
|
210
|
+
"sql-destructive",
|
|
211
|
+
HIGH,
|
|
212
|
+
"Instruction SQL destructrice (DROP / TRUNCATE).",
|
|
213
|
+
_rx(r"\b(drop\s+(table|database|schema)|truncate\s+table)\b"),
|
|
214
|
+
),
|
|
215
|
+
Rule("find-delete", HIGH, "find supprime les fichiers trouvés, sans corbeille.", _find_delete),
|
|
216
|
+
Rule(
|
|
217
|
+
"ps-remove-root",
|
|
218
|
+
HIGH,
|
|
219
|
+
"Suppression récursive d'un lecteur, de la racine ou du dossier personnel.",
|
|
220
|
+
lambda t, r: _ps_recursive_remove(r)[1],
|
|
221
|
+
),
|
|
222
|
+
Rule(
|
|
223
|
+
"ps-format-disk",
|
|
224
|
+
HIGH,
|
|
225
|
+
"Formate ou efface un disque : toutes les données sont perdues.",
|
|
226
|
+
_rx(r"\b(format-volume|clear-disk|initialize-disk|format(\.com)?\s+[a-z]:)"),
|
|
227
|
+
),
|
|
228
|
+
Rule(
|
|
229
|
+
"ps-download-exec",
|
|
230
|
+
HIGH,
|
|
231
|
+
"Exécute directement un script téléchargé (iwr | iex).",
|
|
232
|
+
_rx(
|
|
233
|
+
r"\b(iwr|irm|invoke-webrequest|invoke-restmethod|curl|wget)\b[^|]*\|\s*(iex|invoke-expression)\b"
|
|
234
|
+
r"|\b(iex|invoke-expression)\b\W*(\(\s*)*(new-object\s+(system\.)?net\.webclient|iwr|irm"
|
|
235
|
+
r"|invoke-webrequest|invoke-restmethod)\b"
|
|
236
|
+
),
|
|
237
|
+
),
|
|
238
|
+
Rule(
|
|
239
|
+
"ps-registry-delete",
|
|
240
|
+
HIGH,
|
|
241
|
+
"Supprime des clés du registre Windows.",
|
|
242
|
+
_rx(r"\breg(\.exe)?\s+delete\b|\bremove-item\b[^;|]*\b(hklm|hkcu|hkcr|registry)::?"),
|
|
243
|
+
),
|
|
244
|
+
Rule(
|
|
245
|
+
"ps-execution-policy",
|
|
246
|
+
HIGH,
|
|
247
|
+
"Désactive la protection contre les scripts non signés.",
|
|
248
|
+
_rx(r"\bset-executionpolicy\b[^;|]*\b(unrestricted|bypass)\b"),
|
|
249
|
+
),
|
|
250
|
+
Rule(
|
|
251
|
+
"ps-disable-defender",
|
|
252
|
+
HIGH,
|
|
253
|
+
"Désactive l'antivirus Windows Defender.",
|
|
254
|
+
_rx(r"\bset-mppreference\b[^;|]*-disable\w*\s+(\$true|1)\b"),
|
|
255
|
+
),
|
|
256
|
+
# ---- medium ---------------------------------------------------------
|
|
257
|
+
Rule("rm-recursive", MEDIUM, "Suppression récursive, irréversible.", _rm_recursive),
|
|
258
|
+
Rule(
|
|
259
|
+
"git-reset-hard",
|
|
260
|
+
MEDIUM,
|
|
261
|
+
"Perd les modifications non commitées.",
|
|
262
|
+
_rx(r"\bgit\s+reset\s+--hard\b"),
|
|
263
|
+
),
|
|
264
|
+
Rule(
|
|
265
|
+
"git-clean-force",
|
|
266
|
+
MEDIUM,
|
|
267
|
+
"Supprime définitivement les fichiers non suivis.",
|
|
268
|
+
_rx(r"\bgit\s+clean\s+-[a-z]*f"),
|
|
269
|
+
),
|
|
270
|
+
Rule(
|
|
271
|
+
"sudo",
|
|
272
|
+
MEDIUM,
|
|
273
|
+
"Exécution avec privilèges administrateur.",
|
|
274
|
+
lambda t, r: (
|
|
275
|
+
bool(t) and t[0] in ("sudo", "doas") or bool(re.search(r"(^|[;&|]\s*)sudo\s", r))
|
|
276
|
+
),
|
|
277
|
+
),
|
|
278
|
+
Rule(
|
|
279
|
+
"perm-recursive",
|
|
280
|
+
MEDIUM,
|
|
281
|
+
"Changement récursif de permissions ou de propriétaire.",
|
|
282
|
+
_recursive_perm,
|
|
283
|
+
),
|
|
284
|
+
Rule(
|
|
285
|
+
"overwrite-redirect",
|
|
286
|
+
MEDIUM,
|
|
287
|
+
"Redirection '>' : écrase le fichier cible.",
|
|
288
|
+
lambda t, r: _overwrite_redirect(t, r),
|
|
289
|
+
),
|
|
290
|
+
Rule(
|
|
291
|
+
"kill-force",
|
|
292
|
+
MEDIUM,
|
|
293
|
+
"Arrêt forcé de processus (SIGKILL).",
|
|
294
|
+
lambda t, r: (
|
|
295
|
+
(_is(t, "killall") or _is(t, "pkill") or _is(t, "kill"))
|
|
296
|
+
and ("9" in _flags(t) or "-KILL" in t or "-SIGKILL" in t)
|
|
297
|
+
),
|
|
298
|
+
),
|
|
299
|
+
Rule(
|
|
300
|
+
"power",
|
|
301
|
+
MEDIUM,
|
|
302
|
+
"Arrête ou redémarre la machine.",
|
|
303
|
+
lambda t, r: bool(t) and t[0] in ("shutdown", "reboot", "poweroff", "halt"),
|
|
304
|
+
),
|
|
305
|
+
Rule(
|
|
306
|
+
"eval-exec",
|
|
307
|
+
MEDIUM,
|
|
308
|
+
"Exécute du texte construit dynamiquement.",
|
|
309
|
+
lambda t, r: bool(t) and t[0] == "eval",
|
|
310
|
+
),
|
|
311
|
+
Rule("pip-system", MEDIUM, "Modifie le Python du système.", _rx(r"--break-system-packages")),
|
|
312
|
+
Rule(
|
|
313
|
+
"ps-remove-recursive",
|
|
314
|
+
MEDIUM,
|
|
315
|
+
"Suppression récursive, irréversible (pas de corbeille).",
|
|
316
|
+
lambda t, r: _ps_recursive_remove(r)[0],
|
|
317
|
+
),
|
|
318
|
+
Rule("ps-kill-force", MEDIUM, "Arrêt forcé de processus.", _ps_force_kill),
|
|
319
|
+
Rule(
|
|
320
|
+
"ps-power",
|
|
321
|
+
MEDIUM,
|
|
322
|
+
"Arrête ou redémarre la machine.",
|
|
323
|
+
_rx(r"\b(stop-computer|restart-computer)\b|\bshutdown(\.exe)?\s+/[srpf]\b"),
|
|
324
|
+
),
|
|
325
|
+
Rule(
|
|
326
|
+
"ps-run-as-admin",
|
|
327
|
+
MEDIUM,
|
|
328
|
+
"Exécution avec privilèges administrateur.",
|
|
329
|
+
_rx(r"\bstart-process\b[^;|]*-verb\s+['\"]?runas\b"),
|
|
330
|
+
),
|
|
331
|
+
Rule(
|
|
332
|
+
"ps-invoke-expression",
|
|
333
|
+
MEDIUM,
|
|
334
|
+
"Exécute du texte construit dynamiquement.",
|
|
335
|
+
_rx(r"(^|[;|(]\s*)(iex|invoke-expression)\b"),
|
|
336
|
+
),
|
|
337
|
+
Rule(
|
|
338
|
+
"ps-overwrite",
|
|
339
|
+
MEDIUM,
|
|
340
|
+
"Écrase le contenu du fichier cible.",
|
|
341
|
+
lambda t, r: any(
|
|
342
|
+
_ps_args(seg, r"(set-content|sc|out-file)") is not None
|
|
343
|
+
and not re.search(r"-append\b", seg, re.I)
|
|
344
|
+
for seg in _ps_segments(r)
|
|
345
|
+
),
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
_RULES_BY_ID = {r.id: r for r in RULES}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@dataclass
|
|
353
|
+
class RiskAssessment:
|
|
354
|
+
level: int = LOW
|
|
355
|
+
hits: List[Tuple[str, str, int]] = field(default_factory=list) # (rule_id, reason, level)
|
|
356
|
+
parse_ok: bool = True
|
|
357
|
+
|
|
358
|
+
@property
|
|
359
|
+
def name(self) -> str:
|
|
360
|
+
return _NAMES[self.level]
|
|
361
|
+
|
|
362
|
+
@property
|
|
363
|
+
def reason(self) -> str:
|
|
364
|
+
if not self.hits:
|
|
365
|
+
return "" if self.parse_ok else "Commande non analysable : prudence."
|
|
366
|
+
worst = max(self.hits, key=lambda h: h[2])
|
|
367
|
+
return worst[1]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _split_segments(command: str) -> Tuple[List[List[str]], bool]:
|
|
371
|
+
try:
|
|
372
|
+
lex = shlex.shlex(command, posix=True, punctuation_chars=True)
|
|
373
|
+
lex.whitespace_split = True
|
|
374
|
+
tokens = list(lex)
|
|
375
|
+
except ValueError:
|
|
376
|
+
return [], False
|
|
377
|
+
segments, current = [], []
|
|
378
|
+
for tok in tokens:
|
|
379
|
+
if tok in _SEPARATORS:
|
|
380
|
+
if current:
|
|
381
|
+
segments.append(current)
|
|
382
|
+
current = []
|
|
383
|
+
else:
|
|
384
|
+
current.append(tok)
|
|
385
|
+
if current:
|
|
386
|
+
segments.append(current)
|
|
387
|
+
return segments, True
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _strip_wrappers(tokens: List[str]) -> List[str]:
|
|
391
|
+
i = 0
|
|
392
|
+
while i < len(tokens) and (tokens[i] in _WRAPPERS or re.match(r"^\w+=", tokens[i])):
|
|
393
|
+
i += 1
|
|
394
|
+
return tokens[i:] if i < len(tokens) else tokens
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _inner_commands(command: str) -> List[str]:
|
|
398
|
+
inner = re.findall(r"\$\(([^()]*)\)", command)
|
|
399
|
+
inner += re.findall(r"`([^`]*)`", command)
|
|
400
|
+
return inner
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def assess(command: str, _depth: int = 0) -> RiskAssessment:
|
|
404
|
+
"""Analyse une commande : pipes, &&, ;, sous-shells, sudo. Échec d'analyse => au moins moyen."""
|
|
405
|
+
result = RiskAssessment()
|
|
406
|
+
command = command or ""
|
|
407
|
+
segments, ok = _split_segments(command.replace("\n", " ; "))
|
|
408
|
+
if not ok:
|
|
409
|
+
result.parse_ok = False
|
|
410
|
+
result.level = MEDIUM
|
|
411
|
+
|
|
412
|
+
candidates: List[Tuple[List[str], str]] = []
|
|
413
|
+
for seg in segments:
|
|
414
|
+
candidates.append((seg, command)) # avec sudo/env
|
|
415
|
+
stripped = _strip_wrappers(seg)
|
|
416
|
+
if stripped is not seg and stripped != seg:
|
|
417
|
+
candidates.append((stripped, command))
|
|
418
|
+
if not segments:
|
|
419
|
+
candidates.append(([], command))
|
|
420
|
+
|
|
421
|
+
seen = set()
|
|
422
|
+
for tokens, raw in candidates:
|
|
423
|
+
for rule in RULES:
|
|
424
|
+
try:
|
|
425
|
+
matched = rule.check(tokens, raw)
|
|
426
|
+
except Exception:
|
|
427
|
+
matched = False
|
|
428
|
+
if matched and rule.id not in seen:
|
|
429
|
+
seen.add(rule.id)
|
|
430
|
+
result.hits.append((rule.id, rule.reason, rule.level))
|
|
431
|
+
result.level = max(result.level, rule.level)
|
|
432
|
+
|
|
433
|
+
if _depth < 3:
|
|
434
|
+
for inner in _inner_commands(command):
|
|
435
|
+
sub = assess(inner, _depth + 1)
|
|
436
|
+
for h in sub.hits:
|
|
437
|
+
if h[0] not in seen:
|
|
438
|
+
seen.add(h[0])
|
|
439
|
+
result.hits.append(h)
|
|
440
|
+
result.level = max(result.level, sub.level)
|
|
441
|
+
return result
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def merge_risk(
|
|
445
|
+
assessment: RiskAssessment, model_risk: Optional[str], model_reason: str = ""
|
|
446
|
+
) -> Tuple[str, str]:
|
|
447
|
+
"""risque_final = max(règles, modèle). Raison de la règle si elle est au moins aussi sévère."""
|
|
448
|
+
m = _LEVELS.get((model_risk or "medium").lower(), MEDIUM)
|
|
449
|
+
final = max(assessment.level, m)
|
|
450
|
+
if assessment.hits and assessment.level >= m:
|
|
451
|
+
return _NAMES[final], assessment.reason
|
|
452
|
+
return _NAMES[final], model_reason or assessment.reason
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
__all__ = ["RULES", "Rule", "RiskAssessment", "assess", "merge_risk", "LOW", "MEDIUM", "HIGH"]
|