ai-secret-scout 2.4.6 → 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.6</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.6 │ 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.6 │ 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.6 │ 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.6 │ 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.6 │ 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.6 │ 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.6 │ 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.6</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.6 │ 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.6 │ 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.6 │ 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.6 │ 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.6 │ 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.6 │ 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.6 │ 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.6"
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",
@@ -989,6 +996,7 @@ class ScoutEngine:
989
996
  self.home / ".gemini" / "antigravity-cli" / "history.jsonl",
990
997
  self.home / ".gemini" / "antigravity-ide" / "conversations",
991
998
  self.home / ".codex" / "sessions",
999
+ self.home / ".codex" / "history.jsonl",
992
1000
  self.home / ".copilot" / "session-state",
993
1001
  self.home / ".cursor" / "projects",
994
1002
  self.home / ".cursor" / "plans",
@@ -1012,7 +1020,7 @@ class ScoutEngine:
1012
1020
  ])
1013
1021
  return targets
1014
1022
 
1015
- def get_target_directories(self) -> List[Path]:
1023
+ def get_target_directories(self) -> list[Path]:
1016
1024
  return [p for p in self.get_candidate_targets() if p.exists()]
1017
1025
 
1018
1026
  def identify_tool(self, file_path: str) -> str:
@@ -1039,20 +1047,18 @@ class ScoutEngine:
1039
1047
 
1040
1048
  p = Path(file_path)
1041
1049
  if ".claude" in str(file_path) and p.suffix == ".jsonl":
1042
- try:
1043
- with open(p, "r", encoding="utf-8", errors="ignore") as f:
1044
- for _ in range(10):
1045
- line = f.readline()
1046
- if not line:
1047
- break
1048
- if '"cwd"' in line:
1049
- data = json.loads(line)
1050
- if "cwd" in data and data["cwd"]:
1051
- res = data["cwd"].replace(str(self.home), "~")
1052
- self._cwd_cache[file_path] = res
1053
- return res
1054
- except Exception:
1055
- 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
1056
1062
 
1057
1063
  parent_name = p.parent.name
1058
1064
  if parent_name.startswith("-"):
@@ -1079,12 +1085,12 @@ class ScoutEngine:
1079
1085
 
1080
1086
  return str(p.parent).replace(str(self.home), "~")
1081
1087
 
1082
- 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]:
1083
1089
  line_clean = raw_line.strip()
1084
1090
  usage = "Texte consigné en session"
1085
1091
  try:
1086
1092
  data = json.loads(line_clean)
1087
- except Exception:
1093
+ except (ValueError, RecursionError):
1088
1094
  data = None
1089
1095
  if isinstance(data, dict):
1090
1096
  # Antigravity porte aussi un champ "type" : tester "step_index" en premier.
@@ -1124,9 +1130,9 @@ class ScoutEngine:
1124
1130
  if len(val) < 6:
1125
1131
  return False
1126
1132
 
1127
- if val.startswith("$") or val.startswith("<") or val.startswith("{{") or val.startswith("[") or val.startswith("("):
1133
+ if val.startswith(("$", "<", "{{", "[", "(")):
1128
1134
  return False
1129
- if val.endswith(">") or val.endswith("]") or val.endswith(")") or val.endswith("}"):
1135
+ if val.endswith((">", "]", ")", "}")):
1130
1136
  return False
1131
1137
 
1132
1138
  is_free_text = any(kw in category for kw in FREE_TEXT_RULE_MARKERS)
@@ -1147,9 +1153,8 @@ class ScoutEngine:
1147
1153
  if any(marker in full_line for marker in ["AI SECRET SCOUT", "[REDACTED_BY_AISCOUT]", "FICHE DÉTAILLÉE DU SECRET", "rapport_audit"]):
1148
1154
  return False
1149
1155
 
1150
- if "Clé Privée" in category:
1151
- if "END " not in full_line and "PRIVATE KEY" not in full_line.split(text)[-1]:
1152
- 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
1153
1158
 
1154
1159
  if any(kw in category for kw in ["Prompt", "CLI", "commande", "sensible"]):
1155
1160
  if lower_t in COMMON_WORDS_FR_EN:
@@ -1206,15 +1211,15 @@ class ScoutEngine:
1206
1211
  i += 1
1207
1212
  return "".join(out)
1208
1213
 
1209
- def _file_meta(self, fpath: str) -> Tuple[str, str, str]:
1214
+ def _file_meta(self, fpath: str) -> tuple[str, str, str]:
1210
1215
  try:
1211
- 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")
1212
1217
  except OSError:
1213
1218
  mtime = "Inconnu"
1214
1219
  return self.identify_tool(fpath), self.resolve_project(fpath), mtime
1215
1220
 
1216
- def _match_line(self, cat_name: str, conf: Dict[str, str], line: str, fpath: str, lno: int,
1217
- 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]:
1218
1223
  """Point unique de correspondance : ripgrep, repli Python et watchdog passent tous ici."""
1219
1224
  if "ai_secret_scout" in fpath or "scan_secrets" in fpath:
1220
1225
  return []
@@ -1239,8 +1244,8 @@ class ScoutEngine:
1239
1244
  ))
1240
1245
  return found
1241
1246
 
1242
- def _scan_file(self, fpath: str, patterns: Dict[str, Dict[str, str]],
1243
- 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]:
1244
1249
  found = []
1245
1250
  try:
1246
1251
  with open(fpath, "r", encoding="utf-8", errors="ignore") as f:
@@ -1252,7 +1257,7 @@ class ScoutEngine:
1252
1257
  return found
1253
1258
 
1254
1259
  @staticmethod
1255
- def _list_scan_files(targets: List[str]) -> List[str]:
1260
+ def _list_scan_files(targets: list[str]) -> list[str]:
1256
1261
  files = []
1257
1262
  for target in targets:
1258
1263
  tp = Path(target)
@@ -1265,12 +1270,12 @@ class ScoutEngine:
1265
1270
  files.append(os.path.join(root, name))
1266
1271
  return files
1267
1272
 
1268
- 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:
1269
1274
  """Lignes trouvées par ripgrep, ou None si ripgrep refuse la regex."""
1270
1275
  cmd = [self.rg_path, "--null", "-H", "-n", "--no-heading", "--no-messages",
1271
1276
  "--glob", "!*.bak", "--glob", "!*.backup",
1272
1277
  "-e", self.rg_compatible_regex(regex)] + targets
1273
- 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)
1274
1279
  if proc.returncode == 2 and not proc.stdout and "regex" in proc.stderr.lower():
1275
1280
  return None
1276
1281
  results = []
@@ -1289,15 +1294,15 @@ class ScoutEngine:
1289
1294
  results.append((fpath, lno, content.rstrip("\r")))
1290
1295
  return results
1291
1296
 
1292
- def scan(self, progress_callback=None) -> List[Finding]:
1297
+ def scan(self, progress_callback=None) -> list[Finding]:
1293
1298
  self.findings = []
1294
1299
  targets = [str(p) for p in self.get_target_directories()]
1295
1300
  if not targets:
1296
1301
  return []
1297
1302
 
1298
- meta_cache: Dict[str, Tuple[str, str, str]] = {}
1303
+ meta_cache: dict[str, tuple[str, str, str]] = {}
1299
1304
 
1300
- def meta(fpath: str) -> Tuple[str, str, str]:
1305
+ def meta(fpath: str) -> tuple[str, str, str]:
1301
1306
  if fpath not in meta_cache:
1302
1307
  meta_cache[fpath] = self._file_meta(fpath)
1303
1308
  return meta_cache[fpath]
@@ -1323,15 +1328,15 @@ class ScoutEngine:
1323
1328
  progress_callback(idx, len(all_files), f"Lecture : {Path(fpath).name}")
1324
1329
  self.findings.extend(self._scan_file(fpath, python_patterns, *meta(fpath)))
1325
1330
 
1326
- dedup_map: Dict[Tuple[str, str, str, int], Finding] = {}
1331
+ dedup_map: dict[tuple[str, str, str, int], Finding] = {}
1327
1332
  for f in self.findings:
1328
1333
  key = (f.category, f.secret_raw, f.file_path, f.line_no)
1329
1334
  if key not in dedup_map:
1330
1335
  dedup_map[key] = f
1331
- 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))
1332
1337
  return self.findings
1333
1338
 
1334
- def scan_single_file(self, fpath: Path) -> List[Finding]:
1339
+ def scan_single_file(self, fpath: Path) -> list[Finding]:
1335
1340
  """Analyse unitairement un fichier pour y détecter des secrets."""
1336
1341
  if not fpath.is_file() or str(fpath).endswith((".bak", ".backup")):
1337
1342
  return []
@@ -1356,7 +1361,7 @@ class ScoutEngine:
1356
1361
  except OSError:
1357
1362
  return False
1358
1363
 
1359
- def list_backups(self) -> List[Path]:
1364
+ def list_backups(self) -> list[Path]:
1360
1365
  """Recherche et liste tous les fichiers de sauvegarde .bak dans les répertoires d'IA."""
1361
1366
  backups = []
1362
1367
  for target in self.get_candidate_targets():
@@ -1382,7 +1387,7 @@ class ScoutEngine:
1382
1387
  shutil.copy2(backup_path, orig_path)
1383
1388
  backup_path.unlink()
1384
1389
  return True
1385
- except Exception:
1390
+ except OSError:
1386
1391
  return False
1387
1392
 
1388
1393
  def restore_all_backups(self) -> int:
@@ -1397,11 +1402,9 @@ class ScoutEngine:
1397
1402
  """Supprime définitivement tous les fichiers de sauvegarde .bak."""
1398
1403
  count = 0
1399
1404
  for b in self.list_backups():
1400
- try:
1405
+ with contextlib.suppress(OSError):
1401
1406
  b.unlink()
1402
1407
  count += 1
1403
- except Exception:
1404
- pass
1405
1408
  return count
1406
1409
 
1407
1410
 
@@ -1409,9 +1412,9 @@ class ScoutEngine:
1409
1412
  class ScoutTUI:
1410
1413
  def __init__(self, engine: ScoutEngine):
1411
1414
  self.engine = engine
1412
- self.findings: List[Finding] = []
1413
- self.active_filter_tool: Optional[str] = None
1414
- 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
1415
1418
  self.search_query: str = ""
1416
1419
  self.sort_mode: str = "severity" # "severity", "date", "tool"
1417
1420
  self.reveal_mode: bool = False
@@ -1442,7 +1445,7 @@ class ScoutTUI:
1442
1445
  def build_top_bar(self, width: int) -> str:
1443
1446
  user = os.getenv("USER") or os.getenv("USERNAME") or "user"
1444
1447
  host = platform.node() or "localhost"
1445
- date_str = datetime.now().strftime("%d/%m/%Y %H:%M")
1448
+ date_str = local_time().strftime("%d/%m/%Y %H:%M")
1446
1449
  u_lbl = t("user_label")
1447
1450
  h_lbl = t("host_label")
1448
1451
  left = f" ◈ AISCOUT v{VERSION} │ {u_lbl} : {user} │ {h_lbl} : {host}"
@@ -1455,7 +1458,7 @@ class ScoutTUI:
1455
1458
  space = max(0, width - display_len(content))
1456
1459
  return f"{C.BG_DARK}{C.WHITE}{content}{' ' * space}{C.RESET}"
1457
1460
 
1458
- def prompt_input(self, prompt_label: str, initial: str = "") -> Optional[str]:
1461
+ def prompt_input(self, prompt_label: str, initial: str = "") -> str | None:
1459
1462
  """Saisie interactive inline sur la barre inférieure du TUI."""
1460
1463
  width = self.get_width()
1461
1464
  height = self.get_height()
@@ -1520,7 +1523,7 @@ class ScoutTUI:
1520
1523
  msg = "Language switched to English (EN)" if new_lang == "en" else "Langue basculée en Français (FR)"
1521
1524
  self.show_flash_message(f"✔ {msg}", C.CYAN)
1522
1525
 
1523
- def get_filtered_findings(self) -> List[Finding]:
1526
+ def get_filtered_findings(self) -> list[Finding]:
1524
1527
  res = list(self.findings)
1525
1528
  if self.active_filter_tool:
1526
1529
  res = [f for f in res if f.tool == self.active_filter_tool]
@@ -1949,7 +1952,7 @@ class ScoutTUI:
1949
1952
  lines.append(" " * pad_left + f"{C.CYAN}│{C.BOLD}{C.WHITE}{card_hdr}{' ' * sp_hdr}{C.CYAN}│{C.RESET}")
1950
1953
  lines.append(" " * pad_left + f"{C.CYAN}├" + "─" * (card_w - 2) + "┤" + C.RESET)
1951
1954
 
1952
- 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):
1953
1956
  txt = f" {C.BOLD}{label:<16}:{C.RESET} {color}{val}{C.RESET}"
1954
1957
  vis = display_len(txt)
1955
1958
  sp = max(0, card_w - 2 - vis)
@@ -2016,28 +2019,27 @@ class ScoutTUI:
2016
2019
  sys.stdout.flush()
2017
2020
 
2018
2021
  key = read_key()
2019
- if key in ("o", "O", "y", "Y"):
2020
- if self.engine.redact_secret(item):
2021
- item.secret_raw = "[REDACTED_BY_AISCOUT]"
2022
- item.secret_masked = "[REDACTED]"
2023
- lines = []
2024
- 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:
2025
2032
  lines.append("")
2026
- lines.append(self.center_line(f"{C.GREEN}{C.BOLD}{t('redact_success')}{C.RESET}", width))
2027
- lines.append(self.center_line(f"{C.WHITE}{t('redact_file_cleaned', file=Path(item.file_path).name)}{C.RESET}", width))
2028
- lines.append(self.center_line(f"{C.CYAN}{t('redact_bak_created', bak=Path(item.file_path).name + '.bak')}{C.RESET}", width))
2029
- while len(lines) < height - 1:
2030
- lines.append("")
2031
- lines = lines[:height - 1]
2032
- lines.append(self.build_bottom_bar(width, t("continue_hint")))
2033
- sys.stdout.write("\033[H" + "\n".join(lines))
2034
- sys.stdout.flush()
2035
- while read_key() not in ("ENTER", "ESC", "SPACE"):
2036
- 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
2037
2039
 
2038
2040
  # --- DIALOGUE DE FILTRAGE ---
2039
2041
  def run_filter_dialog(self):
2040
- tools = sorted(list(set(f.tool for f in self.findings)))
2042
+ tools = sorted({f.tool for f in self.findings})
2041
2043
  width = self.get_width()
2042
2044
  height = self.get_height()
2043
2045
 
@@ -2076,7 +2078,7 @@ class ScoutTUI:
2076
2078
  height = self.get_height()
2077
2079
  items = self.get_filtered_findings()
2078
2080
 
2079
- tag = datetime.now().strftime("%Y%m%d_%H%M%S")
2081
+ tag = local_time().strftime("%Y%m%d_%H%M%S")
2080
2082
  md_dest = self.engine.home / f"audit_secrets_ia_{tag}.md"
2081
2083
  json_dest = self.engine.home / f"audit_secrets_ia_{tag}.json"
2082
2084
 
@@ -2134,13 +2136,13 @@ class ScoutTUI:
2134
2136
  while read_key() not in ("ENTER", "SPACE", "ESC"):
2135
2137
  pass
2136
2138
 
2137
- def export_markdown(self, path: Path, items: List[Finding]):
2139
+ def export_markdown(self, path: Path, items: list[Finding]):
2138
2140
  is_fr = (get_lang() == "fr")
2139
2141
  user_name = os.getenv("USER") or os.getenv("USERNAME") or "user"
2140
2142
  with open(path, "w", encoding="utf-8") as f:
2141
2143
  if is_fr:
2142
- f.write(f"# Rapport d'Audit de Sécurité — Transcriptions d'IA\n\n")
2143
- 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")
2144
2146
  f.write(f"- **Utilisateur** : `{user_name}`\n")
2145
2147
  f.write(f"- **Total secrets identifiés** : {len(items)}\n\n")
2146
2148
  f.write("## Synthèse par Niveau de Sévérité\n\n")
@@ -2150,11 +2152,10 @@ class ScoutTUI:
2150
2152
  f.write("## Détail des Secrets Détectés\n\n")
2151
2153
  f.write("| # | Sévérité | Catégorie | Outil IA | Projet d'Origine | Valeur (Masquée) | Fichier |\n")
2152
2154
  f.write("|---|---|---|---|---|---|---|\n")
2153
- for idx, item in enumerate(items, 1):
2154
- 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))
2155
2156
  else:
2156
- f.write(f"# Security Audit Report — AI Assistant Histories\n\n")
2157
- 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")
2158
2159
  f.write(f"- **User** : `{user_name}`\n")
2159
2160
  f.write(f"- **Total exposed secrets** : {len(items)}\n\n")
2160
2161
  f.write("## Summary by Severity Level\n\n")
@@ -2164,10 +2165,9 @@ class ScoutTUI:
2164
2165
  f.write("## Detected Secrets Breakdown\n\n")
2165
2166
  f.write("| # | Severity | Category | AI Tool | Origin Project | Value (Masked) | File |\n")
2166
2167
  f.write("|---|---|---|---|---|---|---|\n")
2167
- for idx, item in enumerate(items, 1):
2168
- 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))
2169
2169
 
2170
- def export_json(self, path: Path, items: List[Finding]):
2170
+ def export_json(self, path: Path, items: list[Finding]):
2171
2171
  with open(path, "w", encoding="utf-8") as f:
2172
2172
  json.dump([item.to_dict() for item in items], f, indent=2, ensure_ascii=False)
2173
2173
 
@@ -2307,8 +2307,8 @@ class ScoutTUI:
2307
2307
  try:
2308
2308
  stat = b_path.stat()
2309
2309
  size_kb = f"{stat.st_size / 1024:.1f} KB"
2310
- date_str = datetime.fromtimestamp(stat.st_mtime).strftime("%d/%m/%Y %H:%M:%S")
2311
- except Exception:
2310
+ date_str = local_time(stat.st_mtime).strftime("%d/%m/%Y %H:%M:%S")
2311
+ except OSError:
2312
2312
  size_kb = "Unknown"
2313
2313
  date_str = "Unknown"
2314
2314
 
@@ -2358,7 +2358,7 @@ class ScoutTUI:
2358
2358
 
2359
2359
  # --- SURVEILLANCE EN TEMPS RÉEL (WATCHDOG DÉDIÉ) ---
2360
2360
  @staticmethod
2361
- def _list_watched_files(targets: List[Path]) -> List[Path]:
2361
+ def _list_watched_files(targets: list[Path]) -> list[Path]:
2362
2362
  files = []
2363
2363
  for target in targets:
2364
2364
  if target.is_file():
@@ -2372,7 +2372,7 @@ class ScoutTUI:
2372
2372
 
2373
2373
  def run_watchdog_screen(self):
2374
2374
  targets = self.engine.get_target_directories()
2375
- known_mtimes: Dict[str, float] = {}
2375
+ known_mtimes: dict[str, float] = {}
2376
2376
 
2377
2377
  # Scan initial des mtimes des fichiers surveillés
2378
2378
  for fp in self._list_watched_files(targets):
@@ -2385,8 +2385,8 @@ class ScoutTUI:
2385
2385
  # et chaque modification rescanne le fichier entier.
2386
2386
  alerted = {(f.category, f.secret_raw, f.file_path) for f in self.findings}
2387
2387
 
2388
- events_log: List[str] = [
2389
- 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}"
2390
2390
  ]
2391
2391
  alerts_count = 0
2392
2392
  scans_count = 0
@@ -2478,7 +2478,7 @@ class ScoutTUI:
2478
2478
  break
2479
2479
 
2480
2480
  # Analyse des répertoires pour détecter fichiers nouveaux ou modifiés
2481
- now_ts = datetime.now().strftime("%H:%M:%S")
2481
+ now_ts = local_time().strftime("%H:%M:%S")
2482
2482
  for cp in self._list_watched_files(targets):
2483
2483
  sp = str(cp)
2484
2484
  try:
@@ -2530,7 +2530,7 @@ class ScoutTUI:
2530
2530
  NPM_PACKAGE = "ai-secret-scout"
2531
2531
 
2532
2532
 
2533
- def parse_version(text: str) -> Tuple[int, ...]:
2533
+ def parse_version(text: str) -> tuple[int, ...]:
2534
2534
  return tuple(int(n) for n in re.findall(r"\d+", text.split("-")[0])[:3])
2535
2535
 
2536
2536
 
@@ -2542,7 +2542,7 @@ def run_update() -> int:
2542
2542
  return 1
2543
2543
 
2544
2544
  def npm_out(*args: str) -> str:
2545
- 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)
2546
2546
  if proc.returncode != 0:
2547
2547
  detail = (proc.stderr or proc.stdout).strip().splitlines()
2548
2548
  raise RuntimeError(detail[-1] if detail else f"code {proc.returncode}")
@@ -2576,7 +2576,7 @@ def run_update() -> int:
2576
2576
  return 0
2577
2577
 
2578
2578
  print(f"{C.GRAY}{t('update_running', cmd=' '.join(install_cmd))}{C.RESET}")
2579
- code = subprocess.run([npm, *install_cmd[1:]]).returncode
2579
+ code = subprocess.run([npm, *install_cmd[1:]], check=False).returncode
2580
2580
  if code != 0:
2581
2581
  print(f"{C.RED}{t('update_failed', code=code)}{C.RESET}", file=sys.stderr)
2582
2582
  return code
@@ -2623,7 +2623,7 @@ def main():
2623
2623
  if args.list_rules:
2624
2624
  print(f"\n{C.CYAN}{C.BOLD}◈ AI SECRET SCOUT — {t('cli_rules_title')} ({len(engine.patterns)}) ◈{C.RESET}\n")
2625
2625
  print(f" {'#':<3} │ {t('col_severity'):<10} │ {t('cli_rule_name'):<35} │ {t('cli_rule_desc'):<45}")
2626
- print(f"─" * 98)
2626
+ print("─" * 98)
2627
2627
  for i, (name, conf) in enumerate(engine.patterns.items(), 1):
2628
2628
  rule_disp, rule_desc = get_rule_display(name, conf.get("description", ""))
2629
2629
  sev_str = fmt_severity(conf.get("severity", "MOYEN"))
@@ -2694,8 +2694,6 @@ if __name__ == "__main__":
2694
2694
  try:
2695
2695
  main()
2696
2696
  except BrokenPipeError:
2697
- try:
2697
+ with contextlib.suppress(OSError):
2698
2698
  sys.stderr.close()
2699
- except Exception:
2700
- pass
2701
2699
  sys.exit(0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-secret-scout",
3
- "version": "2.4.6",
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 ."