ai-secret-scout 2.4.7 → 2.4.8

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/README.fr.md CHANGED
@@ -9,7 +9,7 @@
9
9
  ◈ A I S E C R E T S C O U T ◈
10
10
  </pre>
11
11
 
12
- <h2>AI Secret Scout (<code>aiscout</code>) — v2.4.7</h2>
12
+ <h2>AI Secret Scout (<code>aiscout</code>) — v2.4.8</h2>
13
13
 
14
14
  <p align="center">
15
15
  <!-- Ligne 1 : badges techno & plateforme -->
@@ -192,7 +192,7 @@ Le moteur TUI autonome d'`aiscout` (pure bibliothèque standard Python) s'adapte
192
192
  ### 1. Tableau de bord & Menu d'accueil interactif (Hub central)
193
193
 
194
194
  ```text
195
- ◈ AISCOUT v2.4.7 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:30
195
+ ◈ AISCOUT v2.4.8 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:30
196
196
 
197
197
  █████╗ ██╗ ███████╗ ██████╗ ██████╗ ██╗ ██╗████████╗
198
198
  ██╔══██╗██║ ██╔════╝██╔════╝██╔═══██╗██║ ██║╚══██╔══╝
@@ -228,7 +228,7 @@ Le moteur TUI autonome d'`aiscout` (pure bibliothèque standard Python) s'adapte
228
228
  La vue tableau permet de parcourir l'intégralité des fuites identifiées avec pagination fluide, tri multi-critères et recherche plein texte instantanée :
229
229
 
230
230
  ```text
231
- ◈ AISCOUT v2.4.7 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:30
231
+ ◈ AISCOUT v2.4.8 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:30
232
232
 
233
233
  📋 EXPLORATEUR DE SECRETS (1/21) — Mode : MASQUÉ 🛡️ │ Tri : Sévérité (🔴 > 🟡 > 🔵)
234
234
 
@@ -249,7 +249,7 @@ La vue tableau permet de parcourir l'intégralité des fuites identifiées avec
249
249
  En appuyant sur `/`, une boîte de saisie inline s'ouvre sur la barre inférieure. Le tableau se met à jour en temps réel (filtre instantané sur la catégorie, l'outil, le projet, le fichier ou la valeur du secret) :
250
250
 
251
251
  ```text
252
- ◈ AISCOUT v2.4.7 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:30
252
+ ◈ AISCOUT v2.4.8 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:30
253
253
 
254
254
  📋 EXPLORATEUR DE SECRETS (1/3) — Mode : MASQUÉ 🛡️ │ Tri : Sévérité (🔴 > 🟡 > 🔵)
255
255
  🔍 Recherche active : "github" (3 secrets trouvés) │ [Backspace/Esc] Effacer
@@ -269,7 +269,7 @@ En appuyant sur `/`, une boîte de saisie inline s'ouvre sur la barre inférieur
269
269
  En pressant `Entrée` sur une ligne du tableau, une fiche d'audit chirurgicale s'affiche avec le contexte exact d'usage et l'extrait de log :
270
270
 
271
271
  ```text
272
- ◈ AISCOUT v2.4.7 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:30
272
+ ◈ AISCOUT v2.4.8 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:30
273
273
 
274
274
  ╭──────────────────────────────────────────────────────────────────────────────────╮
275
275
  │ FICHE D'AUDIT DU SECRET DÉTECTÉ │
@@ -298,7 +298,7 @@ En pressant `Entrée` sur une ligne du tableau, une fiche d'audit chirurgicale s
298
298
  Lors de l'audit initial, du caviardage ou de l'exportation des dossiers d'audit, une animation à 100ms affiche l'étape en cours et la progression :
299
299
 
300
300
  ```text
301
- ◈ AISCOUT v2.4.7 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:30
301
+ ◈ AISCOUT v2.4.8 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:30
302
302
 
303
303
  █████╗ ██╗ ███████╗ ██████╗ ██████╗ ██╗ ██╗████████╗
304
304
  ██╔══██╗██║ ██╔════╝██╔════╝██╔═══██╗██║ ██║╚══██╔══╝
@@ -334,7 +334,7 @@ Lors de l'audit initial, du caviardage ou de l'exportation des dossiers d'audit,
334
334
  Le mode **Watchdog** transforme `aiscout` en un gardien silencieux en arrière-plan :
335
335
 
336
336
  ```text
337
- ◈ AISCOUT v2.4.7 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:21
337
+ ◈ AISCOUT v2.4.8 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:21
338
338
 
339
339
  📡 SURVEILLANCE EN TEMPS RÉEL (WATCHDOG) │ 🟢 [VEILLE ACTIVE]
340
340
  Détecte les sessions IA en écriture et notifie instantanément sur le bureau (Linux / Windows)
@@ -365,7 +365,7 @@ Le mode **Watchdog** transforme `aiscout` en un gardien silencieux en arrière-p
365
365
  Le menu `[7]` offre un contrôle absolu sur les fichiers de sauvegarde générés lors des opérations de caviardage :
366
366
 
367
367
  ```text
368
- ◈ AISCOUT v2.4.7 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:22
368
+ ◈ AISCOUT v2.4.8 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:22
369
369
 
370
370
  ↩️ GESTIONNAIRE DES SAUVEGARDES & RESTAURATION (.BAK)
371
371
  Restaurer les fichiers originaux avant caviardage ou purger les sauvegardes.
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  ◈ A I S E C R E T S C O U T ◈
10
10
  </pre>
11
11
 
12
- <h2>AI Secret Scout (<code>aiscout</code>) — v2.4.7</h2>
12
+ <h2>AI Secret Scout (<code>aiscout</code>) — v2.4.8</h2>
13
13
 
14
14
  <p align="center">
15
15
  <!-- Row 1: tech & platform badges -->
@@ -192,7 +192,7 @@ The application automatically switches your terminal to an alternate screen buff
192
192
  ### 1. Central Hub & Dashboard
193
193
 
194
194
  ```text
195
- ◈ AISCOUT v2.4.7 │ User : dev_redious │ Host : workstation 12/09/2026 22:30
195
+ ◈ AISCOUT v2.4.8 │ User : dev_redious │ Host : workstation 12/09/2026 22:30
196
196
 
197
197
  █████╗ ██╗ ███████╗ ██████╗ ██████╗ ██╗ ██╗████████╗
198
198
  ██╔══██╗██║ ██╔════╝██╔════╝██╔═══██╗██║ ██║╚══██╔══╝
@@ -228,7 +228,7 @@ The application automatically switches your terminal to an alternate screen buff
228
228
  The interactive table allows fluid inspection of all detected secrets with multi-criteria sorting and instantaneous full-text filtering:
229
229
 
230
230
  ```text
231
- ◈ AISCOUT v2.4.7 │ User : dev_redious │ Host : workstation 12/09/2026 22:30
231
+ ◈ AISCOUT v2.4.8 │ User : dev_redious │ Host : workstation 12/09/2026 22:30
232
232
 
233
233
  📋 SECRET EXPLORER (1/21) — Mode : MASKED 🛡️ │ Sort : Severity (🔴 > 🟡 > 🔵)
234
234
 
@@ -249,7 +249,7 @@ The interactive table allows fluid inspection of all detected secrets with multi
249
249
  Pressing `/` opens an inline prompt directly in the status bar. The table updates dynamically with every keystroke:
250
250
 
251
251
  ```text
252
- ◈ AISCOUT v2.4.7 │ User : dev_redious │ Host : workstation 12/09/2026 22:30
252
+ ◈ AISCOUT v2.4.8 │ User : dev_redious │ Host : workstation 12/09/2026 22:30
253
253
 
254
254
  📋 SECRET EXPLORER (1/3) — Mode : MASKED 🛡️ │ Sort : Severity (🔴 > 🟡 > 🔵)
255
255
  🔍 Active filter: "github" (3 matching secrets) │ [Backspace/Esc] Clear
@@ -269,7 +269,7 @@ Pressing `/` opens an inline prompt directly in the status bar. The table update
269
269
  Pressing `Enter` on any row displays a full diagnostic modal card highlighting the source file, context, and remediation recommendations:
270
270
 
271
271
  ```text
272
- ◈ AISCOUT v2.4.7 │ User : dev_redious │ Host : workstation 12/09/2026 22:30
272
+ ◈ AISCOUT v2.4.8 │ User : dev_redious │ Host : workstation 12/09/2026 22:30
273
273
 
274
274
  ╭──────────────────────────────────────────────────────────────────────────────────╮
275
275
  │ AUDIT CARD FOR DETECTED SECRET │
@@ -298,7 +298,7 @@ Pressing `Enter` on any row displays a full diagnostic modal card highlighting t
298
298
  During scans, redactions, or reports generation, an animated 100ms spinner displays current scan phase and progress percentage:
299
299
 
300
300
  ```text
301
- ◈ AISCOUT v2.4.7 │ User : dev_redious │ Host : workstation 12/09/2026 22:30
301
+ ◈ AISCOUT v2.4.8 │ User : dev_redious │ Host : workstation 12/09/2026 22:30
302
302
 
303
303
  █████╗ ██╗ ███████╗ ██████╗ ██████╗ ██╗ ██╗████████╗
304
304
  ██╔══██╗██║ ██╔════╝██╔════╝██╔═══██╗██║ ██║╚══██╔══╝
@@ -334,7 +334,7 @@ During scans, redactions, or reports generation, an animated 100ms spinner displ
334
334
  The **Watchdog** mode turns `aiscout` into a background sentinel:
335
335
 
336
336
  ```text
337
- ◈ AISCOUT v2.4.7 │ User : dev_redious │ Host : workstation 12/09/2026 22:21
337
+ ◈ AISCOUT v2.4.8 │ User : dev_redious │ Host : workstation 12/09/2026 22:21
338
338
 
339
339
  📡 REAL-TIME MONITORING (WATCHDOG) │ 🟢 [ACTIVE SENTINEL]
340
340
  Detects active AI sessions writing to disk and sends instant desktop alerts
@@ -365,7 +365,7 @@ The **Watchdog** mode turns `aiscout` into a background sentinel:
365
365
  TUI Menu `[7]` provides complete control over backup files created prior to sanitization:
366
366
 
367
367
  ```text
368
- ◈ AISCOUT v2.4.7 │ User : dev_redious │ Host : workstation 12/09/2026 22:22
368
+ ◈ AISCOUT v2.4.8 │ User : dev_redious │ Host : workstation 12/09/2026 22:22
369
369
 
370
370
  ↩️ SAFE RESTORE & BACKUP MANAGER (.BAK)
371
371
  Restore original files before redaction or purge stale backup copies.
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
  """
4
3
  ===============================================================================
5
4
  AI SECRET SCOUT (aiscout) — Full TUI Application
@@ -9,21 +8,22 @@ Compatible : Claude Code, Antigravity/Gemini CLI, Codex, Copilot CLI, Cursor, Ai
9
8
  ===============================================================================
10
9
  """
11
10
 
12
- import sys
13
- import os
14
- import re
15
- import time
11
+ import argparse
12
+ import contextlib
16
13
  import json
17
14
  import math
18
- import shutil
15
+ import os
19
16
  import platform
20
- import argparse
17
+ import re
18
+ import shutil
21
19
  import subprocess
20
+ import sys
21
+ import time
22
22
  import unicodedata
23
- from pathlib import Path
24
- from datetime import datetime
25
23
  from collections import Counter
26
- from typing import List, Dict, Any, Optional, Tuple
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from typing import Any
27
27
 
28
28
  IS_WINDOWS = sys.platform == "win32"
29
29
 
@@ -31,17 +31,15 @@ if IS_WINDOWS:
31
31
  import msvcrt
32
32
  # Active les séquences d'échappement VT100 / ANSI sous console Windows
33
33
  os.system("")
34
- try:
34
+ with contextlib.suppress(AttributeError, OSError, ValueError):
35
35
  sys.stdout.reconfigure(encoding="utf-8")
36
36
  sys.stdin.reconfigure(encoding="utf-8")
37
- except Exception:
38
- pass
39
37
  else:
40
38
  import select
41
39
  import termios
42
40
  import tty
43
41
 
44
- VERSION = "2.4.7"
42
+ VERSION = "2.4.8"
45
43
 
46
44
  # --- INTERNATIONALISATION (I18N) ---
47
45
  def detect_default_lang() -> str:
@@ -51,7 +49,7 @@ def detect_default_lang() -> str:
51
49
 
52
50
  CURRENT_LANG = detect_default_lang()
53
51
 
54
- def set_lang(lang: Optional[str]):
52
+ def set_lang(lang: str | None):
55
53
  global CURRENT_LANG
56
54
  if not lang:
57
55
  CURRENT_LANG = detect_default_lang()
@@ -71,7 +69,7 @@ SEV_TRANSLATIONS = {
71
69
  "fr": {"CRITICAL": "CRITIQUE", "HIGH": "ÉLEVÉ", "MEDIUM": "MOYEN", "CRITIQUE": "CRITIQUE", "ÉLEVÉ": "ÉLEVÉ", "MOYEN": "MOYEN"}
72
70
  }
73
71
 
74
- def fmt_severity(sev: str, lang: Optional[str] = None) -> str:
72
+ def fmt_severity(sev: str, lang: str | None = None) -> str:
75
73
  l = lang or CURRENT_LANG
76
74
  norm_sev = SEV_TRANSLATIONS["fr"].get(sev, sev)
77
75
  if l == "en":
@@ -155,7 +153,7 @@ RULE_I18N = {
155
153
  }
156
154
  }
157
155
 
158
- def get_rule_display(name: str, default_desc: str = "", lang: Optional[str] = None) -> Tuple[str, str]:
156
+ def get_rule_display(name: str, default_desc: str = "", lang: str | None = None) -> tuple[str, str]:
159
157
  l = lang or CURRENT_LANG
160
158
  if l == "en" and name in RULE_I18N["en"]:
161
159
  return RULE_I18N["en"][name]["name"], RULE_I18N["en"][name]["desc"]
@@ -177,7 +175,7 @@ USAGE_CONTEXT_I18N = {
177
175
  }
178
176
  }
179
177
 
180
- def fmt_usage_context(ctx: str, lang: Optional[str] = None) -> str:
178
+ def fmt_usage_context(ctx: str, lang: str | None = None) -> str:
181
179
  l = lang or CURRENT_LANG
182
180
  if l == "en":
183
181
  return USAGE_CONTEXT_I18N["en"].get(ctx, ctx)
@@ -519,7 +517,7 @@ def t(key: str, **kwargs) -> Any:
519
517
  if isinstance(val, str) and kwargs:
520
518
  try:
521
519
  return val.format(**kwargs)
522
- except Exception:
520
+ except (KeyError, IndexError, ValueError):
523
521
  return val
524
522
  return val
525
523
 
@@ -663,10 +661,10 @@ else:
663
661
  CONFIG_DIR = Path.home() / ".config" / "aiscout"
664
662
  CUSTOM_RULES_FILE = CONFIG_DIR / "rules.json"
665
663
 
666
- def load_custom_rules() -> Dict[str, Dict[str, str]]:
664
+ def load_custom_rules() -> dict[str, dict[str, str]]:
667
665
  """Charge les règles personnalisées de l'utilisateur depuis rules.json."""
668
666
  if not CUSTOM_RULES_FILE.exists():
669
- try:
667
+ with contextlib.suppress(OSError):
670
668
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
671
669
  sample = {
672
670
  "_comment": "Ajoutez vos règles personnalisées ici. Format: Nom: {regex, severity, description}",
@@ -678,8 +676,6 @@ def load_custom_rules() -> Dict[str, Dict[str, str]]:
678
676
  }
679
677
  with open(CUSTOM_RULES_FILE, "w", encoding="utf-8") as f:
680
678
  json.dump(sample, f, indent=2, ensure_ascii=False)
681
- except Exception:
682
- pass
683
679
  return {}
684
680
 
685
681
  try:
@@ -700,43 +696,52 @@ def load_custom_rules() -> Dict[str, Dict[str, str]]:
700
696
  "description": v.get("description", "Règle personnalisée utilisateur.")
701
697
  }
702
698
  return rules
703
- except Exception:
699
+ except (OSError, ValueError, AttributeError):
700
+ # Fichier illisible, JSON invalide ou racine qui n'est pas un objet.
704
701
  return {}
705
702
 
706
703
 
704
+ # Script fixe : le titre et le texte (noms de projet, chemins de session) arrivent par l'environnement.
705
+ # Insérés dans la chaîne entre guillemets doubles, un « $(...) » y serait exécuté par PowerShell.
706
+ WINDOWS_NOTIFICATION_SCRIPT = (
707
+ '[void] [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms"); '
708
+ '$objNotifyIcon = New-Object System.Windows.Forms.NotifyIcon; '
709
+ '$objNotifyIcon.Icon = [System.Drawing.SystemIcons]::Shield; '
710
+ '$objNotifyIcon.BalloonTipIcon = "Warning"; '
711
+ '$objNotifyIcon.BalloonTipTitle = $env:AISCOUT_BALLOON_TITLE; '
712
+ '$objNotifyIcon.BalloonTipText = $env:AISCOUT_BALLOON_TEXT; '
713
+ '$objNotifyIcon.Visible = $True; '
714
+ '$objNotifyIcon.ShowBalloonTip(5000); '
715
+ 'Start-Sleep -Seconds 1; '
716
+ '$objNotifyIcon.Dispose()'
717
+ )
718
+
719
+
720
+ def build_windows_notification(title: str, message: str) -> tuple[list[str], dict[str, str]]:
721
+ """Commande PowerShell et environnement d'une notification Windows."""
722
+ env = {**os.environ, "AISCOUT_BALLOON_TITLE": title, "AISCOUT_BALLOON_TEXT": message.replace("\n", " ")}
723
+ return ["powershell", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_NOTIFICATION_SCRIPT], env
724
+
725
+
707
726
  def send_desktop_notification(title: str, message: str, severity: str = "normal"):
708
727
  """Envoie une notification de bureau native sous Linux / KDE / GNOME ou Windows 10/11."""
709
728
  if os.getenv("AISCOUT_NO_NOTIFY"):
710
729
  return
711
730
  if IS_WINDOWS:
712
- clean_title = title.replace('"', '`"').replace("'", "''")
713
- clean_msg = message.replace('"', '`"').replace("'", "''").replace("\n", " ")
714
- ps_script = (
715
- f'[void] [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms"); '
716
- f'$objNotifyIcon = New-Object System.Windows.Forms.NotifyIcon; '
717
- f'$objNotifyIcon.Icon = [System.Drawing.SystemIcons]::Shield; '
718
- f'$objNotifyIcon.BalloonTipIcon = "Warning"; '
719
- f'$objNotifyIcon.BalloonTipTitle = "{clean_title}"; '
720
- f'$objNotifyIcon.BalloonTipText = "{clean_msg}"; '
721
- f'$objNotifyIcon.Visible = $True; '
722
- f'$objNotifyIcon.ShowBalloonTip(5000); '
723
- f'Start-Sleep -Seconds 1; '
724
- f'$objNotifyIcon.Dispose()'
725
- )
726
- try:
731
+ args, env = build_windows_notification(title, message)
732
+ with contextlib.suppress(OSError):
727
733
  subprocess.Popen(
728
- ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
734
+ args,
735
+ env=env,
729
736
  creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0,
730
737
  stdout=subprocess.DEVNULL,
731
738
  stderr=subprocess.DEVNULL
732
739
  )
733
- except Exception:
734
- pass
735
740
  return
736
741
 
737
742
  if shutil.which("notify-send"):
738
743
  urgency = "critical" if severity in ("CRITIQUE", "ÉLEVÉ") else "normal"
739
- try:
744
+ with contextlib.suppress(OSError, subprocess.SubprocessError):
740
745
  subprocess.run([
741
746
  "notify-send",
742
747
  "-u", urgency,
@@ -744,9 +749,7 @@ def send_desktop_notification(title: str, message: str, severity: str = "normal"
744
749
  "-i", "security-high",
745
750
  title,
746
751
  message
747
- ], capture_output=True, timeout=2)
748
- except Exception:
749
- pass
752
+ ], capture_output=True, timeout=2, check=False)
750
753
 
751
754
 
752
755
  PLACEHOLDER_KEYWORDS = [
@@ -780,6 +783,13 @@ COMMON_WORDS_FR_EN = {
780
783
  }
781
784
 
782
785
 
786
+ def local_time(timestamp: float | None = None) -> datetime:
787
+ """Heure locale avec fuseau : maintenant, ou à partir d'un horodatage (mtime)."""
788
+ if timestamp is None:
789
+ return datetime.now(timezone.utc).astimezone()
790
+ return datetime.fromtimestamp(timestamp, tz=timezone.utc).astimezone()
791
+
792
+
783
793
  def calculate_entropy(text: str) -> float:
784
794
  if not text:
785
795
  return 0.0
@@ -896,10 +906,7 @@ def read_key() -> str:
896
906
  if data == b"\x03":
897
907
  return "CTRL_C"
898
908
 
899
- try:
900
- return data.decode("utf-8", errors="ignore")
901
- except Exception:
902
- return ""
909
+ return data.decode("utf-8", errors="ignore")
903
910
  finally:
904
911
  termios.tcsetattr(fd, termios.TCSADRAIN, old)
905
912
 
@@ -908,8 +915,8 @@ def read_key() -> str:
908
915
  class Finding:
909
916
  def __init__(self, category: str, secret_raw: str, file_path: str, line_no: int,
910
917
  tool: str, project: str, usage_context: str, snippet: str,
911
- timestamp: Optional[str] = None, severity: Optional[str] = None,
912
- description: Optional[str] = None):
918
+ timestamp: str | None = None, severity: str | None = None,
919
+ description: str | None = None):
913
920
  self.category = category
914
921
  self.secret_raw = secret_raw
915
922
  self.secret_masked = self.mask(secret_raw)
@@ -923,14 +930,14 @@ class Finding:
923
930
  self.severity = severity or PATTERNS.get(category, {}).get("severity", "MOYEN")
924
931
  self.description = description or PATTERNS.get(category, {}).get("description", "")
925
932
 
926
- def display_category(self, lang: Optional[str] = None) -> str:
933
+ def display_category(self, lang: str | None = None) -> str:
927
934
  name, _ = get_rule_display(self.category, default_desc=self.description, lang=lang)
928
935
  return name
929
936
 
930
- def display_severity(self, lang: Optional[str] = None) -> str:
937
+ def display_severity(self, lang: str | None = None) -> str:
931
938
  return fmt_severity(self.severity, lang=lang)
932
939
 
933
- def display_usage_context(self, lang: Optional[str] = None) -> str:
940
+ def display_usage_context(self, lang: str | None = None) -> str:
934
941
  return fmt_usage_context(self.usage_context, lang=lang)
935
942
 
936
943
  @staticmethod
@@ -940,7 +947,7 @@ class Finding:
940
947
  return s[:2] + "****"
941
948
  return s[:4] + "*" * (min(len(s) - 8, 20)) + s[-4:]
942
949
 
943
- def to_dict(self) -> Dict[str, Any]:
950
+ def to_dict(self) -> dict[str, Any]:
944
951
  return {
945
952
  "category": self.display_category(),
946
953
  "category_raw": self.category,
@@ -960,15 +967,15 @@ class Finding:
960
967
 
961
968
 
962
969
  class ScoutEngine:
963
- def __init__(self, user_home: Optional[Path] = None):
970
+ def __init__(self, user_home: Path | None = None):
964
971
  self.home = user_home or Path.home()
965
972
  self.rg_path = self._locate_ripgrep()
966
973
  self.patterns = dict(PATTERNS)
967
974
  self.patterns.update(load_custom_rules())
968
- self.findings: List[Finding] = []
969
- self._cwd_cache: Dict[str, str] = {}
975
+ self.findings: list[Finding] = []
976
+ self._cwd_cache: dict[str, str] = {}
970
977
 
971
- def _locate_ripgrep(self) -> Optional[str]:
978
+ def _locate_ripgrep(self) -> str | None:
972
979
  if os.getenv("AISCOUT_DISABLE_RIPGREP"):
973
980
  return None
974
981
  system_rg = shutil.which("rg")
@@ -979,7 +986,7 @@ class ScoutEngine:
979
986
  return str(cargo_rg)
980
987
  return None
981
988
 
982
- def get_candidate_targets(self) -> List[Path]:
989
+ def get_candidate_targets(self) -> list[Path]:
983
990
  """Tous les emplacements connus, qu'ils existent ou non."""
984
991
  targets = [
985
992
  self.home / ".claude" / "projects",
@@ -1013,7 +1020,7 @@ class ScoutEngine:
1013
1020
  ])
1014
1021
  return targets
1015
1022
 
1016
- def get_target_directories(self) -> List[Path]:
1023
+ def get_target_directories(self) -> list[Path]:
1017
1024
  return [p for p in self.get_candidate_targets() if p.exists()]
1018
1025
 
1019
1026
  def identify_tool(self, file_path: str) -> str:
@@ -1040,20 +1047,18 @@ class ScoutEngine:
1040
1047
 
1041
1048
  p = Path(file_path)
1042
1049
  if ".claude" in str(file_path) and p.suffix == ".jsonl":
1043
- try:
1044
- with open(p, "r", encoding="utf-8", errors="ignore") as f:
1045
- for _ in range(10):
1046
- line = f.readline()
1047
- if not line:
1048
- break
1049
- if '"cwd"' in line:
1050
- data = json.loads(line)
1051
- if "cwd" in data and data["cwd"]:
1052
- res = data["cwd"].replace(str(self.home), "~")
1053
- self._cwd_cache[file_path] = res
1054
- return res
1055
- except Exception:
1056
- pass
1050
+ # Fichier illisible, JSON invalide, "cwd" qui n'est pas une chaîne : repli sur le nom du dossier.
1051
+ with contextlib.suppress(OSError, ValueError, AttributeError, RecursionError), open(p, encoding="utf-8", errors="ignore") as f:
1052
+ for _ in range(10):
1053
+ line = f.readline()
1054
+ if not line:
1055
+ break
1056
+ if '"cwd"' in line:
1057
+ data = json.loads(line)
1058
+ if data.get("cwd"):
1059
+ res = data["cwd"].replace(str(self.home), "~")
1060
+ self._cwd_cache[file_path] = res
1061
+ return res
1057
1062
 
1058
1063
  parent_name = p.parent.name
1059
1064
  if parent_name.startswith("-"):
@@ -1080,12 +1085,12 @@ class ScoutEngine:
1080
1085
 
1081
1086
  return str(p.parent).replace(str(self.home), "~")
1082
1087
 
1083
- def determine_usage_context(self, raw_line: str, tool: str) -> Tuple[str, str]:
1088
+ def determine_usage_context(self, raw_line: str, tool: str) -> tuple[str, str]:
1084
1089
  line_clean = raw_line.strip()
1085
1090
  usage = "Texte consigné en session"
1086
1091
  try:
1087
1092
  data = json.loads(line_clean)
1088
- except Exception:
1093
+ except (ValueError, RecursionError):
1089
1094
  data = None
1090
1095
  if isinstance(data, dict):
1091
1096
  # Antigravity porte aussi un champ "type" : tester "step_index" en premier.
@@ -1125,9 +1130,9 @@ class ScoutEngine:
1125
1130
  if len(val) < 6:
1126
1131
  return False
1127
1132
 
1128
- if val.startswith("$") or val.startswith("<") or val.startswith("{{") or val.startswith("[") or val.startswith("("):
1133
+ if val.startswith(("$", "<", "{{", "[", "(")):
1129
1134
  return False
1130
- if val.endswith(">") or val.endswith("]") or val.endswith(")") or val.endswith("}"):
1135
+ if val.endswith((">", "]", ")", "}")):
1131
1136
  return False
1132
1137
 
1133
1138
  is_free_text = any(kw in category for kw in FREE_TEXT_RULE_MARKERS)
@@ -1148,9 +1153,8 @@ class ScoutEngine:
1148
1153
  if any(marker in full_line for marker in ["AI SECRET SCOUT", "[REDACTED_BY_AISCOUT]", "FICHE DÉTAILLÉE DU SECRET", "rapport_audit"]):
1149
1154
  return False
1150
1155
 
1151
- if "Clé Privée" in category:
1152
- if "END " not in full_line and "PRIVATE KEY" not in full_line.split(text)[-1]:
1153
- return False
1156
+ if "Clé Privée" in category and "END " not in full_line and "PRIVATE KEY" not in full_line.split(text)[-1]:
1157
+ return False
1154
1158
 
1155
1159
  if any(kw in category for kw in ["Prompt", "CLI", "commande", "sensible"]):
1156
1160
  if lower_t in COMMON_WORDS_FR_EN:
@@ -1207,15 +1211,15 @@ class ScoutEngine:
1207
1211
  i += 1
1208
1212
  return "".join(out)
1209
1213
 
1210
- def _file_meta(self, fpath: str) -> Tuple[str, str, str]:
1214
+ def _file_meta(self, fpath: str) -> tuple[str, str, str]:
1211
1215
  try:
1212
- mtime = datetime.fromtimestamp(os.path.getmtime(fpath)).strftime("%Y-%m-%d %H:%M")
1216
+ mtime = local_time(os.path.getmtime(fpath)).strftime("%Y-%m-%d %H:%M")
1213
1217
  except OSError:
1214
1218
  mtime = "Inconnu"
1215
1219
  return self.identify_tool(fpath), self.resolve_project(fpath), mtime
1216
1220
 
1217
- def _match_line(self, cat_name: str, conf: Dict[str, str], line: str, fpath: str, lno: int,
1218
- tool: str, project: str, mtime: str) -> List[Finding]:
1221
+ def _match_line(self, cat_name: str, conf: dict[str, str], line: str, fpath: str, lno: int,
1222
+ tool: str, project: str, mtime: str) -> list[Finding]:
1219
1223
  """Point unique de correspondance : ripgrep, repli Python et watchdog passent tous ici."""
1220
1224
  if "ai_secret_scout" in fpath or "scan_secrets" in fpath:
1221
1225
  return []
@@ -1240,8 +1244,8 @@ class ScoutEngine:
1240
1244
  ))
1241
1245
  return found
1242
1246
 
1243
- def _scan_file(self, fpath: str, patterns: Dict[str, Dict[str, str]],
1244
- tool: str, project: str, mtime: str) -> List[Finding]:
1247
+ def _scan_file(self, fpath: str, patterns: dict[str, dict[str, str]],
1248
+ tool: str, project: str, mtime: str) -> list[Finding]:
1245
1249
  found = []
1246
1250
  try:
1247
1251
  with open(fpath, "r", encoding="utf-8", errors="ignore") as f:
@@ -1253,7 +1257,7 @@ class ScoutEngine:
1253
1257
  return found
1254
1258
 
1255
1259
  @staticmethod
1256
- def _list_scan_files(targets: List[str]) -> List[str]:
1260
+ def _list_scan_files(targets: list[str]) -> list[str]:
1257
1261
  files = []
1258
1262
  for target in targets:
1259
1263
  tp = Path(target)
@@ -1266,12 +1270,12 @@ class ScoutEngine:
1266
1270
  files.append(os.path.join(root, name))
1267
1271
  return files
1268
1272
 
1269
- def _rg_search(self, regex: str, targets: List[str]) -> Optional[List[Tuple[str, int, str]]]:
1273
+ def _rg_search(self, regex: str, targets: list[str]) -> list[tuple[str, int, str]] | None:
1270
1274
  """Lignes trouvées par ripgrep, ou None si ripgrep refuse la regex."""
1271
1275
  cmd = [self.rg_path, "--null", "-H", "-n", "--no-heading", "--no-messages",
1272
1276
  "--glob", "!*.bak", "--glob", "!*.backup",
1273
1277
  "-e", self.rg_compatible_regex(regex)] + targets
1274
- proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
1278
+ proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", check=False)
1275
1279
  if proc.returncode == 2 and not proc.stdout and "regex" in proc.stderr.lower():
1276
1280
  return None
1277
1281
  results = []
@@ -1290,15 +1294,15 @@ class ScoutEngine:
1290
1294
  results.append((fpath, lno, content.rstrip("\r")))
1291
1295
  return results
1292
1296
 
1293
- def scan(self, progress_callback=None) -> List[Finding]:
1297
+ def scan(self, progress_callback=None) -> list[Finding]:
1294
1298
  self.findings = []
1295
1299
  targets = [str(p) for p in self.get_target_directories()]
1296
1300
  if not targets:
1297
1301
  return []
1298
1302
 
1299
- meta_cache: Dict[str, Tuple[str, str, str]] = {}
1303
+ meta_cache: dict[str, tuple[str, str, str]] = {}
1300
1304
 
1301
- def meta(fpath: str) -> Tuple[str, str, str]:
1305
+ def meta(fpath: str) -> tuple[str, str, str]:
1302
1306
  if fpath not in meta_cache:
1303
1307
  meta_cache[fpath] = self._file_meta(fpath)
1304
1308
  return meta_cache[fpath]
@@ -1324,15 +1328,15 @@ class ScoutEngine:
1324
1328
  progress_callback(idx, len(all_files), f"Lecture : {Path(fpath).name}")
1325
1329
  self.findings.extend(self._scan_file(fpath, python_patterns, *meta(fpath)))
1326
1330
 
1327
- dedup_map: Dict[Tuple[str, str, str, int], Finding] = {}
1331
+ dedup_map: dict[tuple[str, str, str, int], Finding] = {}
1328
1332
  for f in self.findings:
1329
1333
  key = (f.category, f.secret_raw, f.file_path, f.line_no)
1330
1334
  if key not in dedup_map:
1331
1335
  dedup_map[key] = f
1332
- self.findings = sorted(list(dedup_map.values()), key=lambda x: (x.severity != "CRITIQUE", x.severity != "ÉLEVÉ", x.category))
1336
+ self.findings = sorted(dedup_map.values(), key=lambda x: (x.severity != "CRITIQUE", x.severity != "ÉLEVÉ", x.category))
1333
1337
  return self.findings
1334
1338
 
1335
- def scan_single_file(self, fpath: Path) -> List[Finding]:
1339
+ def scan_single_file(self, fpath: Path) -> list[Finding]:
1336
1340
  """Analyse unitairement un fichier pour y détecter des secrets."""
1337
1341
  if not fpath.is_file() or str(fpath).endswith((".bak", ".backup")):
1338
1342
  return []
@@ -1357,7 +1361,7 @@ class ScoutEngine:
1357
1361
  except OSError:
1358
1362
  return False
1359
1363
 
1360
- def list_backups(self) -> List[Path]:
1364
+ def list_backups(self) -> list[Path]:
1361
1365
  """Recherche et liste tous les fichiers de sauvegarde .bak dans les répertoires d'IA."""
1362
1366
  backups = []
1363
1367
  for target in self.get_candidate_targets():
@@ -1383,7 +1387,7 @@ class ScoutEngine:
1383
1387
  shutil.copy2(backup_path, orig_path)
1384
1388
  backup_path.unlink()
1385
1389
  return True
1386
- except Exception:
1390
+ except OSError:
1387
1391
  return False
1388
1392
 
1389
1393
  def restore_all_backups(self) -> int:
@@ -1398,11 +1402,9 @@ class ScoutEngine:
1398
1402
  """Supprime définitivement tous les fichiers de sauvegarde .bak."""
1399
1403
  count = 0
1400
1404
  for b in self.list_backups():
1401
- try:
1405
+ with contextlib.suppress(OSError):
1402
1406
  b.unlink()
1403
1407
  count += 1
1404
- except Exception:
1405
- pass
1406
1408
  return count
1407
1409
 
1408
1410
 
@@ -1410,9 +1412,9 @@ class ScoutEngine:
1410
1412
  class ScoutTUI:
1411
1413
  def __init__(self, engine: ScoutEngine):
1412
1414
  self.engine = engine
1413
- self.findings: List[Finding] = []
1414
- self.active_filter_tool: Optional[str] = None
1415
- self.active_filter_project: Optional[str] = None
1415
+ self.findings: list[Finding] = []
1416
+ self.active_filter_tool: str | None = None
1417
+ self.active_filter_project: str | None = None
1416
1418
  self.search_query: str = ""
1417
1419
  self.sort_mode: str = "severity" # "severity", "date", "tool"
1418
1420
  self.reveal_mode: bool = False
@@ -1443,7 +1445,7 @@ class ScoutTUI:
1443
1445
  def build_top_bar(self, width: int) -> str:
1444
1446
  user = os.getenv("USER") or os.getenv("USERNAME") or "user"
1445
1447
  host = platform.node() or "localhost"
1446
- date_str = datetime.now().strftime("%d/%m/%Y %H:%M")
1448
+ date_str = local_time().strftime("%d/%m/%Y %H:%M")
1447
1449
  u_lbl = t("user_label")
1448
1450
  h_lbl = t("host_label")
1449
1451
  left = f" ◈ AISCOUT v{VERSION} │ {u_lbl} : {user} │ {h_lbl} : {host}"
@@ -1456,7 +1458,7 @@ class ScoutTUI:
1456
1458
  space = max(0, width - display_len(content))
1457
1459
  return f"{C.BG_DARK}{C.WHITE}{content}{' ' * space}{C.RESET}"
1458
1460
 
1459
- def prompt_input(self, prompt_label: str, initial: str = "") -> Optional[str]:
1461
+ def prompt_input(self, prompt_label: str, initial: str = "") -> str | None:
1460
1462
  """Saisie interactive inline sur la barre inférieure du TUI."""
1461
1463
  width = self.get_width()
1462
1464
  height = self.get_height()
@@ -1521,7 +1523,7 @@ class ScoutTUI:
1521
1523
  msg = "Language switched to English (EN)" if new_lang == "en" else "Langue basculée en Français (FR)"
1522
1524
  self.show_flash_message(f"✔ {msg}", C.CYAN)
1523
1525
 
1524
- def get_filtered_findings(self) -> List[Finding]:
1526
+ def get_filtered_findings(self) -> list[Finding]:
1525
1527
  res = list(self.findings)
1526
1528
  if self.active_filter_tool:
1527
1529
  res = [f for f in res if f.tool == self.active_filter_tool]
@@ -1950,7 +1952,7 @@ class ScoutTUI:
1950
1952
  lines.append(" " * pad_left + f"{C.CYAN}│{C.BOLD}{C.WHITE}{card_hdr}{' ' * sp_hdr}{C.CYAN}│{C.RESET}")
1951
1953
  lines.append(" " * pad_left + f"{C.CYAN}├" + "─" * (card_w - 2) + "┤" + C.RESET)
1952
1954
 
1953
- def add_card_line(label, val, color=""):
1955
+ def add_card_line(label, val, color="", lines=lines, pad_left=pad_left, card_w=card_w):
1954
1956
  txt = f" {C.BOLD}{label:<16}:{C.RESET} {color}{val}{C.RESET}"
1955
1957
  vis = display_len(txt)
1956
1958
  sp = max(0, card_w - 2 - vis)
@@ -2017,28 +2019,27 @@ class ScoutTUI:
2017
2019
  sys.stdout.flush()
2018
2020
 
2019
2021
  key = read_key()
2020
- if key in ("o", "O", "y", "Y"):
2021
- if self.engine.redact_secret(item):
2022
- item.secret_raw = "[REDACTED_BY_AISCOUT]"
2023
- item.secret_masked = "[REDACTED]"
2024
- lines = []
2025
- lines.append(self.build_top_bar(width))
2022
+ if key in ("o", "O", "y", "Y") and self.engine.redact_secret(item):
2023
+ item.secret_raw = "[REDACTED_BY_AISCOUT]"
2024
+ item.secret_masked = "[REDACTED]"
2025
+ lines = []
2026
+ lines.append(self.build_top_bar(width))
2027
+ lines.append("")
2028
+ lines.append(self.center_line(f"{C.GREEN}{C.BOLD}{t('redact_success')}{C.RESET}", width))
2029
+ lines.append(self.center_line(f"{C.WHITE}{t('redact_file_cleaned', file=Path(item.file_path).name)}{C.RESET}", width))
2030
+ lines.append(self.center_line(f"{C.CYAN}{t('redact_bak_created', bak=Path(item.file_path).name + '.bak')}{C.RESET}", width))
2031
+ while len(lines) < height - 1:
2026
2032
  lines.append("")
2027
- lines.append(self.center_line(f"{C.GREEN}{C.BOLD}{t('redact_success')}{C.RESET}", width))
2028
- lines.append(self.center_line(f"{C.WHITE}{t('redact_file_cleaned', file=Path(item.file_path).name)}{C.RESET}", width))
2029
- lines.append(self.center_line(f"{C.CYAN}{t('redact_bak_created', bak=Path(item.file_path).name + '.bak')}{C.RESET}", width))
2030
- while len(lines) < height - 1:
2031
- lines.append("")
2032
- lines = lines[:height - 1]
2033
- lines.append(self.build_bottom_bar(width, t("continue_hint")))
2034
- sys.stdout.write("\033[H" + "\n".join(lines))
2035
- sys.stdout.flush()
2036
- while read_key() not in ("ENTER", "ESC", "SPACE"):
2037
- pass
2033
+ lines = lines[:height - 1]
2034
+ lines.append(self.build_bottom_bar(width, t("continue_hint")))
2035
+ sys.stdout.write("\033[H" + "\n".join(lines))
2036
+ sys.stdout.flush()
2037
+ while read_key() not in ("ENTER", "ESC", "SPACE"):
2038
+ pass
2038
2039
 
2039
2040
  # --- DIALOGUE DE FILTRAGE ---
2040
2041
  def run_filter_dialog(self):
2041
- tools = sorted(list(set(f.tool for f in self.findings)))
2042
+ tools = sorted({f.tool for f in self.findings})
2042
2043
  width = self.get_width()
2043
2044
  height = self.get_height()
2044
2045
 
@@ -2077,7 +2078,7 @@ class ScoutTUI:
2077
2078
  height = self.get_height()
2078
2079
  items = self.get_filtered_findings()
2079
2080
 
2080
- tag = datetime.now().strftime("%Y%m%d_%H%M%S")
2081
+ tag = local_time().strftime("%Y%m%d_%H%M%S")
2081
2082
  md_dest = self.engine.home / f"audit_secrets_ia_{tag}.md"
2082
2083
  json_dest = self.engine.home / f"audit_secrets_ia_{tag}.json"
2083
2084
 
@@ -2135,13 +2136,13 @@ class ScoutTUI:
2135
2136
  while read_key() not in ("ENTER", "SPACE", "ESC"):
2136
2137
  pass
2137
2138
 
2138
- def export_markdown(self, path: Path, items: List[Finding]):
2139
+ def export_markdown(self, path: Path, items: list[Finding]):
2139
2140
  is_fr = (get_lang() == "fr")
2140
2141
  user_name = os.getenv("USER") or os.getenv("USERNAME") or "user"
2141
2142
  with open(path, "w", encoding="utf-8") as f:
2142
2143
  if is_fr:
2143
- f.write(f"# Rapport d'Audit de Sécurité — Transcriptions d'IA\n\n")
2144
- f.write(f"- **Généré le** : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
2144
+ f.write("# Rapport d'Audit de Sécurité — Transcriptions d'IA\n\n")
2145
+ f.write(f"- **Généré le** : {local_time().strftime('%Y-%m-%d %H:%M:%S')}\n")
2145
2146
  f.write(f"- **Utilisateur** : `{user_name}`\n")
2146
2147
  f.write(f"- **Total secrets identifiés** : {len(items)}\n\n")
2147
2148
  f.write("## Synthèse par Niveau de Sévérité\n\n")
@@ -2151,11 +2152,10 @@ class ScoutTUI:
2151
2152
  f.write("## Détail des Secrets Détectés\n\n")
2152
2153
  f.write("| # | Sévérité | Catégorie | Outil IA | Projet d'Origine | Valeur (Masquée) | Fichier |\n")
2153
2154
  f.write("|---|---|---|---|---|---|---|\n")
2154
- for idx, item in enumerate(items, 1):
2155
- f.write(f"| {idx} | {item.display_severity()} | {item.display_category()} | {item.tool} | {item.project} | `{item.secret_masked}` | `{Path(item.file_path).name}:{item.line_no}` |\n")
2155
+ f.writelines(f"| {idx} | {item.display_severity()} | {item.display_category()} | {item.tool} | {item.project} | `{item.secret_masked}` | `{Path(item.file_path).name}:{item.line_no}` |\n" for idx, item in enumerate(items, 1))
2156
2156
  else:
2157
- f.write(f"# Security Audit Report — AI Assistant Histories\n\n")
2158
- f.write(f"- **Generated on** : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
2157
+ f.write("# Security Audit Report — AI Assistant Histories\n\n")
2158
+ f.write(f"- **Generated on** : {local_time().strftime('%Y-%m-%d %H:%M:%S')}\n")
2159
2159
  f.write(f"- **User** : `{user_name}`\n")
2160
2160
  f.write(f"- **Total exposed secrets** : {len(items)}\n\n")
2161
2161
  f.write("## Summary by Severity Level\n\n")
@@ -2165,10 +2165,9 @@ class ScoutTUI:
2165
2165
  f.write("## Detected Secrets Breakdown\n\n")
2166
2166
  f.write("| # | Severity | Category | AI Tool | Origin Project | Value (Masked) | File |\n")
2167
2167
  f.write("|---|---|---|---|---|---|---|\n")
2168
- for idx, item in enumerate(items, 1):
2169
- f.write(f"| {idx} | {item.display_severity()} | {item.display_category()} | {item.tool} | {item.project} | `{item.secret_masked}` | `{Path(item.file_path).name}:{item.line_no}` |\n")
2168
+ f.writelines(f"| {idx} | {item.display_severity()} | {item.display_category()} | {item.tool} | {item.project} | `{item.secret_masked}` | `{Path(item.file_path).name}:{item.line_no}` |\n" for idx, item in enumerate(items, 1))
2170
2169
 
2171
- def export_json(self, path: Path, items: List[Finding]):
2170
+ def export_json(self, path: Path, items: list[Finding]):
2172
2171
  with open(path, "w", encoding="utf-8") as f:
2173
2172
  json.dump([item.to_dict() for item in items], f, indent=2, ensure_ascii=False)
2174
2173
 
@@ -2308,8 +2307,8 @@ class ScoutTUI:
2308
2307
  try:
2309
2308
  stat = b_path.stat()
2310
2309
  size_kb = f"{stat.st_size / 1024:.1f} KB"
2311
- date_str = datetime.fromtimestamp(stat.st_mtime).strftime("%d/%m/%Y %H:%M:%S")
2312
- except Exception:
2310
+ date_str = local_time(stat.st_mtime).strftime("%d/%m/%Y %H:%M:%S")
2311
+ except OSError:
2313
2312
  size_kb = "Unknown"
2314
2313
  date_str = "Unknown"
2315
2314
 
@@ -2359,7 +2358,7 @@ class ScoutTUI:
2359
2358
 
2360
2359
  # --- SURVEILLANCE EN TEMPS RÉEL (WATCHDOG DÉDIÉ) ---
2361
2360
  @staticmethod
2362
- def _list_watched_files(targets: List[Path]) -> List[Path]:
2361
+ def _list_watched_files(targets: list[Path]) -> list[Path]:
2363
2362
  files = []
2364
2363
  for target in targets:
2365
2364
  if target.is_file():
@@ -2373,7 +2372,7 @@ class ScoutTUI:
2373
2372
 
2374
2373
  def run_watchdog_screen(self):
2375
2374
  targets = self.engine.get_target_directories()
2376
- known_mtimes: Dict[str, float] = {}
2375
+ known_mtimes: dict[str, float] = {}
2377
2376
 
2378
2377
  # Scan initial des mtimes des fichiers surveillés
2379
2378
  for fp in self._list_watched_files(targets):
@@ -2386,8 +2385,8 @@ class ScoutTUI:
2386
2385
  # et chaque modification rescanne le fichier entier.
2387
2386
  alerted = {(f.category, f.secret_raw, f.file_path) for f in self.findings}
2388
2387
 
2389
- events_log: List[str] = [
2390
- f"{C.GRAY}[{datetime.now().strftime('%H:%M:%S')}] {t('watchdog_started', files=len(known_mtimes))}{C.RESET}"
2388
+ events_log: list[str] = [
2389
+ f"{C.GRAY}[{local_time().strftime('%H:%M:%S')}] {t('watchdog_started', files=len(known_mtimes))}{C.RESET}"
2391
2390
  ]
2392
2391
  alerts_count = 0
2393
2392
  scans_count = 0
@@ -2479,7 +2478,7 @@ class ScoutTUI:
2479
2478
  break
2480
2479
 
2481
2480
  # Analyse des répertoires pour détecter fichiers nouveaux ou modifiés
2482
- now_ts = datetime.now().strftime("%H:%M:%S")
2481
+ now_ts = local_time().strftime("%H:%M:%S")
2483
2482
  for cp in self._list_watched_files(targets):
2484
2483
  sp = str(cp)
2485
2484
  try:
@@ -2531,7 +2530,7 @@ class ScoutTUI:
2531
2530
  NPM_PACKAGE = "ai-secret-scout"
2532
2531
 
2533
2532
 
2534
- def parse_version(text: str) -> Tuple[int, ...]:
2533
+ def parse_version(text: str) -> tuple[int, ...]:
2535
2534
  return tuple(int(n) for n in re.findall(r"\d+", text.split("-")[0])[:3])
2536
2535
 
2537
2536
 
@@ -2543,7 +2542,7 @@ def run_update() -> int:
2543
2542
  return 1
2544
2543
 
2545
2544
  def npm_out(*args: str) -> str:
2546
- proc = subprocess.run([npm, *args], capture_output=True, text=True, timeout=60)
2545
+ proc = subprocess.run([npm, *args], capture_output=True, text=True, timeout=60, check=False)
2547
2546
  if proc.returncode != 0:
2548
2547
  detail = (proc.stderr or proc.stdout).strip().splitlines()
2549
2548
  raise RuntimeError(detail[-1] if detail else f"code {proc.returncode}")
@@ -2577,7 +2576,7 @@ def run_update() -> int:
2577
2576
  return 0
2578
2577
 
2579
2578
  print(f"{C.GRAY}{t('update_running', cmd=' '.join(install_cmd))}{C.RESET}")
2580
- code = subprocess.run([npm, *install_cmd[1:]]).returncode
2579
+ code = subprocess.run([npm, *install_cmd[1:]], check=False).returncode
2581
2580
  if code != 0:
2582
2581
  print(f"{C.RED}{t('update_failed', code=code)}{C.RESET}", file=sys.stderr)
2583
2582
  return code
@@ -2624,7 +2623,7 @@ def main():
2624
2623
  if args.list_rules:
2625
2624
  print(f"\n{C.CYAN}{C.BOLD}◈ AI SECRET SCOUT — {t('cli_rules_title')} ({len(engine.patterns)}) ◈{C.RESET}\n")
2626
2625
  print(f" {'#':<3} │ {t('col_severity'):<10} │ {t('cli_rule_name'):<35} │ {t('cli_rule_desc'):<45}")
2627
- print(f"─" * 98)
2626
+ print("─" * 98)
2628
2627
  for i, (name, conf) in enumerate(engine.patterns.items(), 1):
2629
2628
  rule_disp, rule_desc = get_rule_display(name, conf.get("description", ""))
2630
2629
  sev_str = fmt_severity(conf.get("severity", "MOYEN"))
@@ -2695,8 +2694,6 @@ if __name__ == "__main__":
2695
2694
  try:
2696
2695
  main()
2697
2696
  except BrokenPipeError:
2698
- try:
2697
+ with contextlib.suppress(OSError):
2699
2698
  sys.stderr.close()
2700
- except Exception:
2701
- pass
2702
2699
  sys.exit(0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-secret-scout",
3
- "version": "2.4.7",
3
+ "version": "2.4.8",
4
4
  "description": "Zero-dependency heuristic secret scanner & redaction tool for AI coding assistants (Claude Code, Antigravity, Codex, Copilot, Cursor)",
5
5
  "homepage": "https://github.com/DevRedious/ai-secret-scout#readme",
6
6
  "repository": {
@@ -26,6 +26,7 @@
26
26
  "test": "node --test \"test/*.test.js\"",
27
27
  "format": "biome format --write .",
28
28
  "lint": "biome lint .",
29
+ "lint:py": "ruff check .",
29
30
  "check": "biome check --write .",
30
31
  "ci": "biome ci .",
31
32
  "ci:fix": "biome check --write --unsafe ."