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.
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"]