ai-secret-scout 2.4.4 → 2.4.5

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.4</h2>
12
+ <h2>AI Secret Scout (<code>aiscout</code>) — v2.4.5</h2>
13
13
 
14
14
  <p align="center">
15
15
  <!-- Ligne 1 : badges techno & plateforme -->
@@ -162,6 +162,12 @@ aiscout
162
162
  ### Options en ligne de commande usuelles
163
163
 
164
164
  ```bash
165
+ # Version installée
166
+ aiscout --version
167
+
168
+ # Mise à jour vers la dernière version npm (seule commande qui accède au réseau)
169
+ aiscout update
170
+
165
171
  # Surveillance temps réel (Watchdog) avec alertes bureau
166
172
  aiscout --watch
167
173
 
@@ -186,7 +192,7 @@ Le moteur TUI autonome d'`aiscout` (pure bibliothèque standard Python) s'adapte
186
192
  ### 1. Tableau de bord & Menu d'accueil interactif (Hub central)
187
193
 
188
194
  ```text
189
- ◈ AISCOUT v2.4.4 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:30
195
+ ◈ AISCOUT v2.4.5 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:30
190
196
 
191
197
  █████╗ ██╗ ███████╗ ██████╗ ██████╗ ██╗ ██╗████████╗
192
198
  ██╔══██╗██║ ██╔════╝██╔════╝██╔═══██╗██║ ██║╚══██╔══╝
@@ -222,7 +228,7 @@ Le moteur TUI autonome d'`aiscout` (pure bibliothèque standard Python) s'adapte
222
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 :
223
229
 
224
230
  ```text
225
- ◈ AISCOUT v2.4.4 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:30
231
+ ◈ AISCOUT v2.4.5 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:30
226
232
 
227
233
  📋 EXPLORATEUR DE SECRETS (1/21) — Mode : MASQUÉ 🛡️ │ Tri : Sévérité (🔴 > 🟡 > 🔵)
228
234
 
@@ -243,7 +249,7 @@ La vue tableau permet de parcourir l'intégralité des fuites identifiées avec
243
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) :
244
250
 
245
251
  ```text
246
- ◈ AISCOUT v2.4.4 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:30
252
+ ◈ AISCOUT v2.4.5 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:30
247
253
 
248
254
  📋 EXPLORATEUR DE SECRETS (1/3) — Mode : MASQUÉ 🛡️ │ Tri : Sévérité (🔴 > 🟡 > 🔵)
249
255
  🔍 Recherche active : "github" (3 secrets trouvés) │ [Backspace/Esc] Effacer
@@ -263,7 +269,7 @@ En appuyant sur `/`, une boîte de saisie inline s'ouvre sur la barre inférieur
263
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 :
264
270
 
265
271
  ```text
266
- ◈ AISCOUT v2.4.4 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:30
272
+ ◈ AISCOUT v2.4.5 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:30
267
273
 
268
274
  ╭──────────────────────────────────────────────────────────────────────────────────╮
269
275
  │ FICHE D'AUDIT DU SECRET DÉTECTÉ │
@@ -292,7 +298,7 @@ En pressant `Entrée` sur une ligne du tableau, une fiche d'audit chirurgicale s
292
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 :
293
299
 
294
300
  ```text
295
- ◈ AISCOUT v2.4.4 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:30
301
+ ◈ AISCOUT v2.4.5 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:30
296
302
 
297
303
  █████╗ ██╗ ███████╗ ██████╗ ██████╗ ██╗ ██╗████████╗
298
304
  ██╔══██╗██║ ██╔════╝██╔════╝██╔═══██╗██║ ██║╚══██╔══╝
@@ -328,7 +334,7 @@ Lors de l'audit initial, du caviardage ou de l'exportation des dossiers d'audit,
328
334
  Le mode **Watchdog** transforme `aiscout` en un gardien silencieux en arrière-plan :
329
335
 
330
336
  ```text
331
- ◈ AISCOUT v2.4.4 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:21
337
+ ◈ AISCOUT v2.4.5 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:21
332
338
 
333
339
  📡 SURVEILLANCE EN TEMPS RÉEL (WATCHDOG) │ 🟢 [VEILLE ACTIVE]
334
340
  Détecte les sessions IA en écriture et notifie instantanément sur le bureau (Linux / Windows)
@@ -359,7 +365,7 @@ Le mode **Watchdog** transforme `aiscout` en un gardien silencieux en arrière-p
359
365
  Le menu `[7]` offre un contrôle absolu sur les fichiers de sauvegarde générés lors des opérations de caviardage :
360
366
 
361
367
  ```text
362
- ◈ AISCOUT v2.4.4 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:22
368
+ ◈ AISCOUT v2.4.5 │ Utilisateur : dev_redious │ Machine : poste-travail 12/09/2026 22:22
363
369
 
364
370
  ↩️ GESTIONNAIRE DES SAUVEGARDES & RESTAURATION (.BAK)
365
371
  Restaurer les fichiers originaux avant caviardage ou purger les sauvegardes.
@@ -400,7 +406,7 @@ Le menu `[7]` offre un contrôle absolu sur les fichiers de sauvegarde généré
400
406
  | **GitHub Token (PAT / Fine-Grained)** | 🔴 **CRITIQUE** | `ghp_[A-Za-z0-9]{36}`, `github_pat_[A-Za-z0-9_]{82}` |
401
407
  | **GitLab Personal Access Token** *(Nouveau)* | 🔴 **CRITIQUE** | `glpat-[0-9a-zA-Z_\-]{20,}` |
402
408
  | **HuggingFace Token** *(Nouveau)* | 🔴 **CRITIQUE** | `hf_[a-zA-Z0-9]{34,}` |
403
- | **Clé Privée (SSH / RSA / ECC / PEM)** | 🔴 **CRITIQUE** | `-----BEGIN (?:OPENSSH\|RSA\|EC) PRIVATE KEY-----` |
409
+ | **Clé Privée (SSH / RSA / ECC / PEM)** | 🔴 **CRITIQUE** | `-----BEGIN (?:OPENSSH\|RSA\|DSA\|EC\|ENCRYPTED\|PGP)? PRIVATE KEY-----` |
404
410
  | **Stripe Secret Key** | 🔴 **CRITIQUE** | `sk_live_[0-9a-zA-Z]{24,}`, `rk_live_[0-9a-zA-Z]{24,}` |
405
411
  | **Base de données (URI avec MDP)** | 🔴 **CRITIQUE** | `postgres://user:password@host`, `mysql://...`, `mongodb://...` |
406
412
  | **Infisical / Coolify Token** | 🔴 **CRITIQUE** | `st.[a-f0-9]{24}.[a-f0-9]{64}`, `inf_sec_...`, `inf_tok_...` |
@@ -547,4 +553,4 @@ aiscout --home-dir /home/autre_utilisateur
547
553
  * **Systèmes Supportés** : **Linux** (natif Wayland / X11), **Windows 10/11** (natif PowerShell, CMD, Windows Terminal) et **WSL / WSL2**.
548
554
  * **Système de Notification** : Détection automatique de `notify-send` sous Linux et toasts natifs PowerShell sous Windows.
549
555
  * **Configuration** : `~/.config/aiscout/rules.json` (ou `%APPDATA%\aiscout\rules.json` sous Windows).
550
- * **Confidentialité absolue** : 100% local, aucun flux réseau sortant, zéro télémétrie.
556
+ * **Confidentialité absolue** : 100% local, aucun flux réseau sortant, zéro télémétrie. Seule exception : `aiscout update`, lancé à la demande, où npm interroge son registre.
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.4</h2>
12
+ <h2>AI Secret Scout (<code>aiscout</code>) — v2.4.5</h2>
13
13
 
14
14
  <p align="center">
15
15
  <!-- Row 1: tech & platform badges -->
@@ -159,6 +159,12 @@ aiscout
159
159
 
160
160
  ### 🛠️ Common CLI Commands
161
161
  ```bash
162
+ # Print the installed version
163
+ aiscout --version
164
+
165
+ # Update to the latest npm release (the only command that uses the network)
166
+ aiscout update
167
+
162
168
  # Launch real-time background watchdog with desktop notifications
163
169
  aiscout --watch
164
170
 
@@ -186,7 +192,7 @@ The application automatically switches your terminal to an alternate screen buff
186
192
  ### 1. Central Hub & Dashboard
187
193
 
188
194
  ```text
189
- ◈ AISCOUT v2.4.4 │ User : dev_redious │ Host : workstation 12/09/2026 22:30
195
+ ◈ AISCOUT v2.4.5 │ User : dev_redious │ Host : workstation 12/09/2026 22:30
190
196
 
191
197
  █████╗ ██╗ ███████╗ ██████╗ ██████╗ ██╗ ██╗████████╗
192
198
  ██╔══██╗██║ ██╔════╝██╔════╝██╔═══██╗██║ ██║╚══██╔══╝
@@ -222,7 +228,7 @@ The application automatically switches your terminal to an alternate screen buff
222
228
  The interactive table allows fluid inspection of all detected secrets with multi-criteria sorting and instantaneous full-text filtering:
223
229
 
224
230
  ```text
225
- ◈ AISCOUT v2.4.4 │ User : dev_redious │ Host : workstation 12/09/2026 22:30
231
+ ◈ AISCOUT v2.4.5 │ User : dev_redious │ Host : workstation 12/09/2026 22:30
226
232
 
227
233
  📋 SECRET EXPLORER (1/21) — Mode : MASKED 🛡️ │ Sort : Severity (🔴 > 🟡 > 🔵)
228
234
 
@@ -243,7 +249,7 @@ The interactive table allows fluid inspection of all detected secrets with multi
243
249
  Pressing `/` opens an inline prompt directly in the status bar. The table updates dynamically with every keystroke:
244
250
 
245
251
  ```text
246
- ◈ AISCOUT v2.4.4 │ User : dev_redious │ Host : workstation 12/09/2026 22:30
252
+ ◈ AISCOUT v2.4.5 │ User : dev_redious │ Host : workstation 12/09/2026 22:30
247
253
 
248
254
  📋 SECRET EXPLORER (1/3) — Mode : MASKED 🛡️ │ Sort : Severity (🔴 > 🟡 > 🔵)
249
255
  🔍 Active filter: "github" (3 matching secrets) │ [Backspace/Esc] Clear
@@ -263,7 +269,7 @@ Pressing `/` opens an inline prompt directly in the status bar. The table update
263
269
  Pressing `Enter` on any row displays a full diagnostic modal card highlighting the source file, context, and remediation recommendations:
264
270
 
265
271
  ```text
266
- ◈ AISCOUT v2.4.4 │ User : dev_redious │ Host : workstation 12/09/2026 22:30
272
+ ◈ AISCOUT v2.4.5 │ User : dev_redious │ Host : workstation 12/09/2026 22:30
267
273
 
268
274
  ╭──────────────────────────────────────────────────────────────────────────────────╮
269
275
  │ AUDIT CARD FOR DETECTED SECRET │
@@ -292,7 +298,7 @@ Pressing `Enter` on any row displays a full diagnostic modal card highlighting t
292
298
  During scans, redactions, or reports generation, an animated 100ms spinner displays current scan phase and progress percentage:
293
299
 
294
300
  ```text
295
- ◈ AISCOUT v2.4.4 │ User : dev_redious │ Host : workstation 12/09/2026 22:30
301
+ ◈ AISCOUT v2.4.5 │ User : dev_redious │ Host : workstation 12/09/2026 22:30
296
302
 
297
303
  █████╗ ██╗ ███████╗ ██████╗ ██████╗ ██╗ ██╗████████╗
298
304
  ██╔══██╗██║ ██╔════╝██╔════╝██╔═══██╗██║ ██║╚══██╔══╝
@@ -328,7 +334,7 @@ During scans, redactions, or reports generation, an animated 100ms spinner displ
328
334
  The **Watchdog** mode turns `aiscout` into a background sentinel:
329
335
 
330
336
  ```text
331
- ◈ AISCOUT v2.4.4 │ User : dev_redious │ Host : workstation 12/09/2026 22:21
337
+ ◈ AISCOUT v2.4.5 │ User : dev_redious │ Host : workstation 12/09/2026 22:21
332
338
 
333
339
  📡 REAL-TIME MONITORING (WATCHDOG) │ 🟢 [ACTIVE SENTINEL]
334
340
  Detects active AI sessions writing to disk and sends instant desktop alerts
@@ -359,7 +365,7 @@ The **Watchdog** mode turns `aiscout` into a background sentinel:
359
365
  TUI Menu `[7]` provides complete control over backup files created prior to sanitization:
360
366
 
361
367
  ```text
362
- ◈ AISCOUT v2.4.4 │ User : dev_redious │ Host : workstation 12/09/2026 22:22
368
+ ◈ AISCOUT v2.4.5 │ User : dev_redious │ Host : workstation 12/09/2026 22:22
363
369
 
364
370
  ↩️ SAFE RESTORE & BACKUP MANAGER (.BAK)
365
371
  Restore original files before redaction or purge stale backup copies.
@@ -400,7 +406,7 @@ TUI Menu `[7]` provides complete control over backup files created prior to sani
400
406
  | **GitHub Token (PAT / Fine-Grained)** | 🔴 **CRITICAL** | `ghp_[A-Za-z0-9]{36}`, `github_pat_[A-Za-z0-9_]{82}` |
401
407
  | **GitLab Personal Access Token** *(New)* | 🔴 **CRITICAL** | `glpat-[0-9a-zA-Z_\-]{20,}` |
402
408
  | **HuggingFace Token** *(New)* | 🔴 **CRITICAL** | `hf_[a-zA-Z0-9]{34,}` |
403
- | **Private Key (SSH / RSA / ECC / PEM)** | 🔴 **CRITICAL** | `-----BEGIN (?:OPENSSH|RSA|EC) PRIVATE KEY-----` |
409
+ | **Private Key (SSH / RSA / ECC / PEM)** | 🔴 **CRITICAL** | `-----BEGIN (?:OPENSSH\|RSA\|DSA\|EC\|ENCRYPTED\|PGP)? PRIVATE KEY-----` |
404
410
  | **Stripe Secret Key** | 🔴 **CRITICAL** | `sk_live_[0-9a-zA-Z]{24,}`, `rk_live_[0-9a-zA-Z]{24,}` |
405
411
  | **Database URI (with Credentials)** | 🔴 **CRITICAL** | `postgres://user:password@host`, `mysql://...`, `mongodb://...` |
406
412
  | **Infisical / Coolify Token** | 🔴 **CRITICAL** | `st.[a-f0-9]{24}.[a-f0-9]{64}`, `inf_sec_...`, `inf_tok_...` |
@@ -547,7 +553,7 @@ aiscout --home-dir /home/other_user
547
553
  * **Supported Platforms**: **Linux** (native Wayland / X11), **Windows 10/11** (native PowerShell, CMD, Windows Terminal), and **WSL / WSL2**.
548
554
  * **Notification System**: Auto-detects `notify-send` on Linux and native PowerShell toast notifications on Windows.
549
555
  * **Configuration Path**: `~/.config/aiscout/rules.json` (or `%APPDATA%\aiscout\rules.json` on Windows).
550
- * **Zero Telemetry**: 100% local execution, zero outbound network traffic, complete privacy guarantee.
556
+ * **Zero Telemetry**: 100% local execution, zero outbound network traffic, complete privacy guarantee. The only exception is `aiscout update`, run on demand, where npm queries its registry.
551
557
 
552
558
  ---
553
559
 
@@ -2,7 +2,7 @@
2
2
  # -*- coding: utf-8 -*-
3
3
  """
4
4
  ===============================================================================
5
- AI SECRET SCOUT (aiscout) — v2.1.0 Full TUI Application
5
+ AI SECRET SCOUT (aiscout) — Full TUI Application
6
6
  Audit & Détection de secrets en clair dans les transcriptions & mémoires d'IA
7
7
  Interface TUI plein écran avec logo centré, menus larges, flèches et animations
8
8
  Compatible : Claude Code, Antigravity/Gemini CLI, Codex, Copilot CLI, Cursor, Aider
@@ -41,7 +41,7 @@ else:
41
41
  import termios
42
42
  import tty
43
43
 
44
- VERSION = "2.4.4"
44
+ VERSION = "2.4.5"
45
45
 
46
46
  # --- INTERNATIONALISATION (I18N) ---
47
47
  def detect_default_lang() -> str:
@@ -293,6 +293,50 @@ I18N_STRINGS = {
293
293
  "watchdog_act_modification": "Modification",
294
294
  "notif_title": "Secret Détecté",
295
295
  "notif_sec": "Secret",
296
+ "cli_help_version": "Affiche la version installée et quitte",
297
+ "cli_help_update": "« update » : met à jour AI Secret Scout via npm (seul accès réseau, sur demande)",
298
+ "update_checking": "Vérification de la dernière version sur le registre npm...",
299
+ "update_uptodate": "✔ AI Secret Scout est à jour (v{version}).",
300
+ "update_available": "Nouvelle version disponible : v{current} → v{latest}",
301
+ "update_running": "Mise à jour : {cmd}",
302
+ "update_done": "✔ AI Secret Scout mis à jour en v{latest}.",
303
+ "update_failed": "❌ La mise à jour a échoué (code {code}). Droits insuffisants ? Relancez avec les droits d'administration ou configurez un préfixe npm utilisateur.",
304
+ "update_npx": "Lancé via npx : « {cmd} » exécute directement la dernière version.",
305
+ "update_manual": "AI Secret Scout n'est pas installé globalement par npm : lancez « {cmd} » pour installer la dernière version.",
306
+ "update_no_npm": "❌ npm est introuvable : impossible de vérifier ou d'installer une mise à jour.",
307
+ "update_check_failed": "❌ Impossible d'interroger le registre npm : {error}",
308
+ "confirm_title": "⚠️ CONFIRMATION",
309
+ "confirm_bottom": "[O] Confirmer │ [Annuler] Toute autre touche",
310
+ "continue_hint": "[Entrée] / [Espace] pour continuer",
311
+ "filter_tool_lbl": "Outil",
312
+ "filter_project_lbl": "Projet",
313
+ "table_empty_search_hint": "[Backspace/Esc] Effacer la recherche │ [Q] Retour",
314
+ "table_empty_hint": "[Esc] / [Q] Retour au tableau de bord",
315
+ "redact_file_lbl": "Fichier",
316
+ "redact_line_lbl": "Ligne",
317
+ "filter_title": "🏷️ GESTION DES FILTRES PAR OUTIL IA",
318
+ "filter_reset": "[R] Réinitialiser tous les filtres",
319
+ "filter_back": "[Q] Retour au menu principal",
320
+ "filter_active_tag": "(ACTIF)",
321
+ "filter_bottom": "Sélectionnez un numéro [1-N], [R] pour réinitialiser ou [Q] pour retour",
322
+ "export_title": "💾 EXPORTER LE DOSSIER D'AUDIT COMPLET",
323
+ "export_md": "[1] Exporter en Markdown : {name}",
324
+ "export_json": "[2] Exporter en JSON : {name}",
325
+ "export_cancel": "[Q] Annuler et revenir au menu",
326
+ "export_bottom": "Appuyez sur [1] ou [2] pour exporter, ou [Q] pour annuler",
327
+ "export_progress": "Génération du dossier d'audit en cours...",
328
+ "export_writing": "Écriture des données sécurisées...",
329
+ "export_done": "✔ RAPPORT D'AUDIT EXPORTÉ AVEC SUCCÈS !",
330
+ "export_file": "Fichier : {path}",
331
+ "export_total": "Total de secrets consignés : {count}",
332
+ "export_return": "[Entrée] ou [Espace] pour revenir au menu",
333
+ "bak_confirm_one": "Restaurer {name} depuis sa sauvegarde ?",
334
+ "bak_restored_one": "✔ {name} restauré avec succès !",
335
+ "bak_restore_failed": "❌ Échec de la restauration.",
336
+ "bak_confirm_all": "Restaurer les {count} sauvegardes ?",
337
+ "bak_restored_all": "✔ {count} fichier(s) restauré(s) avec succès !",
338
+ "bak_confirm_purge": "⚠️ SUPPRIMER DÉFINITIVEMENT les {count} sauvegardes (.bak) ?",
339
+ "bak_purged": "✔ {count} fichier(s) .bak supprimé(s) définitivement.",
296
340
  "menu_1": ("AUDIT COMPLET DES SESSIONS IA", "Scanne toutes les transcriptions et mémoires d'IA (Claude, Gemini, etc.)"),
297
341
  "menu_2": ("EXPLORATEUR DE SECRETS (TABLEAU TUI)", "Naviguer au clavier dans la liste des secrets détectés et inspecter"),
298
342
  "menu_3": ("MODE RÉVÉLATION (VALEURS EN CLAIR)", "Basculer l'affichage des secrets en clair ou masqué (ghp_**** vs brut)"),
@@ -412,6 +456,50 @@ I18N_STRINGS = {
412
456
  "watchdog_act_modification": "Modification",
413
457
  "notif_title": "Secret Detected",
414
458
  "notif_sec": "Secret",
459
+ "cli_help_version": "Print the installed version and exit",
460
+ "cli_help_update": "\"update\": update AI Secret Scout through npm (only network access, on demand)",
461
+ "update_checking": "Checking the latest version on the npm registry...",
462
+ "update_uptodate": "✔ AI Secret Scout is up to date (v{version}).",
463
+ "update_available": "New version available: v{current} → v{latest}",
464
+ "update_running": "Updating: {cmd}",
465
+ "update_done": "✔ AI Secret Scout updated to v{latest}.",
466
+ "update_failed": "❌ Update failed (code {code}). Missing permissions? Retry with administrator rights or configure a user-level npm prefix.",
467
+ "update_npx": "Launched through npx: \"{cmd}\" runs the latest version directly.",
468
+ "update_manual": "AI Secret Scout is not installed globally with npm: run \"{cmd}\" to install the latest version.",
469
+ "update_no_npm": "❌ npm not found: cannot check for or install an update.",
470
+ "update_check_failed": "❌ Could not query the npm registry: {error}",
471
+ "confirm_title": "⚠️ CONFIRMATION",
472
+ "confirm_bottom": "[Y] Confirm │ [Cancel] Any other key",
473
+ "continue_hint": "[Enter] / [Space] to continue",
474
+ "filter_tool_lbl": "Tool",
475
+ "filter_project_lbl": "Project",
476
+ "table_empty_search_hint": "[Backspace/Esc] Clear search │ [Q] Back",
477
+ "table_empty_hint": "[Esc] / [Q] Return to dashboard",
478
+ "redact_file_lbl": "File",
479
+ "redact_line_lbl": "Line",
480
+ "filter_title": "🏷️ AI TOOL FILTERS",
481
+ "filter_reset": "[R] Reset all filters",
482
+ "filter_back": "[Q] Back to main menu",
483
+ "filter_active_tag": "(ACTIVE)",
484
+ "filter_bottom": "Pick a number [1-N], [R] to reset or [Q] to go back",
485
+ "export_title": "💾 EXPORT FULL AUDIT REPORT",
486
+ "export_md": "[1] Export as Markdown : {name}",
487
+ "export_json": "[2] Export as JSON : {name}",
488
+ "export_cancel": "[Q] Cancel and return to menu",
489
+ "export_bottom": "Press [1] or [2] to export, or [Q] to cancel",
490
+ "export_progress": "Generating audit report...",
491
+ "export_writing": "Writing report data...",
492
+ "export_done": "✔ AUDIT REPORT EXPORTED SUCCESSFULLY!",
493
+ "export_file": "File : {path}",
494
+ "export_total": "Secrets recorded : {count}",
495
+ "export_return": "[Enter] or [Space] to return to menu",
496
+ "bak_confirm_one": "Restore {name} from its backup copy?",
497
+ "bak_restored_one": "✔ {name} restored successfully!",
498
+ "bak_restore_failed": "❌ Restoration failed.",
499
+ "bak_confirm_all": "Restore all {count} backup files?",
500
+ "bak_restored_all": "✔ {count} file(s) restored successfully!",
501
+ "bak_confirm_purge": "⚠️ PERMANENTLY PURGE all {count} backup (.bak) files?",
502
+ "bak_purged": "✔ {count} .bak file(s) permanently deleted.",
415
503
  "menu_1": ("RUN FULL AUDIT ACROSS ALL AI SESSIONS", "Scan all AI transcripts and memories (Claude, Gemini, Codex, etc.)"),
416
504
  "menu_2": ("SECRET EXPLORER (INTERACTIVE TUI TABLE)", "Browse and inspect detected credentials with keyboard controls"),
417
505
  "menu_3": ("REVEAL MODE (TOGGLE PLAIN-TEXT VALUES)", "Toggle plain-text vs masked display (ghp_**** vs raw)"),
@@ -482,7 +570,7 @@ PATTERNS = {
482
570
  "description": "Jeton d'accès GitHub personnel ou OAuth (droits en lecture/écriture sur les dépôts)."
483
571
  },
484
572
  "Clé Privée (SSH / RSA / ECC)": {
485
- "regex": r"-----BEGIN (?:RSA|DSA|EC|OPENSSH|PGP)? PRIVATE KEY-----",
573
+ "regex": r"-----BEGIN (?:(?:RSA|DSA|EC|OPENSSH|ENCRYPTED|PGP) )?PRIVATE KEY(?: BLOCK)?-----",
486
574
  "severity": "CRITIQUE",
487
575
  "description": "En-tête de clé cryptographique privée permettant l'authentification serveur ou la signature."
488
576
  },
@@ -497,7 +585,7 @@ PATTERNS = {
497
585
  "description": "Clé d'API Claude (Anthropic) permettant de consommer des tokens d'inférence."
498
586
  },
499
587
  "OpenAI API Key": {
500
- "regex": r"\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b",
588
+ "regex": r"\bsk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{32,}\b",
501
589
  "severity": "ÉLEVÉ",
502
590
  "description": "Clé d'API OpenAI pour GPT / embeddings."
503
591
  },
@@ -527,22 +615,22 @@ PATTERNS = {
527
615
  "description": "Jeton de coffre-fort de secrets Infisical ou jeton API Coolify."
528
616
  },
529
617
  "Base de données (URI avec mot de passe)": {
530
- "regex": r"(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?):\/\/[a-zA-Z0-9_\-\.]+:(?!(?:password|root|postgres|admin|\$\{[^\}]+\}|<[^>]+>|\*+|\[|\%|\bYOUR_))[^\s@\"'`:]+@[a-zA-Z0-9_\-\.]+",
618
+ "regex": r"(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?):\/\/[a-zA-Z0-9_\-\.]+:(?!(?:password|root|postgres|admin)@|\$\{|<|\*+@|\[|%|YOUR_)([^\s@\"'`:\\]+)@[a-zA-Z0-9_\-\.]+",
531
619
  "severity": "CRITIQUE",
532
620
  "description": "Chaîne de connexion de base de données contenant identifiant et mot de passe en clair."
533
621
  },
534
622
  "Variable d'environnement sensible": {
535
- "regex": r"(?i)\b(?:PGPASSWORD|MYSQL_PWD|DB_PASSWORD|AUTH_TOKEN|API_KEY|SECRET_KEY|COOLIFY_API_TOKEN)=([\"']?[A-Za-z0-9_\-@#$%^&+=!]{8,}[\"']?)",
623
+ "regex": r"(?i)\b(?:PGPASSWORD|MYSQL_PWD|DB_PASSWORD|AUTH_TOKEN|API_KEY|SECRET_KEY|COOLIFY_API_TOKEN)=(?:\\?[\"'])?([A-Za-z0-9_\-@#$%^&+=!]{8,})",
536
624
  "severity": "ÉLEVÉ",
537
625
  "description": "Affectation de mot de passe ou secret via variable d'environnement dans un terminal."
538
626
  },
539
627
  "Mot de passe passé en commande CLI": {
540
- "regex": r"(?i)\b(?:--password[\s=]+|sshpass\s+-p\s*|mysql\s+(?:-[a-zA-Z0-9_]+\s+)*-p)([\"']?[A-Za-z0-9_\-@#$%^&+=!]{6,}[\"']?)",
628
+ "regex": r"(?i)\b(?:--password[\s=]+|sshpass\s+-p\s*|mysql\s+(?:-[a-zA-Z0-9_]+\s+)*-p)(?:\\?[\"'])?([A-Za-z0-9_\-@#$%^&+=!]{6,})",
541
629
  "severity": "MOYEN",
542
630
  "description": "Mot de passe transmis directement dans une commande dédiée (ex. sshpass, mysql, --password)."
543
631
  },
544
632
  "Mot de passe en clair dans le Prompt": {
545
- "regex": r"(?i)(?:mon mot de passe est|le mot de passe (?:est|c'est)|mdp\s*[:=]|mot de passe\s*[:=]|my password is|the password is|password\s*[:=])\s*([\"']?[A-Za-z0-9_\-@#$%^&+=!]{6,}[\"']?)",
633
+ "regex": r"(?i)(?:mon mot de passe est|le mot de passe (?:est|c'est)|mdp\s*[:=]|mot de passe\s*[:=]|my password is|the password is|password\s*[:=])\s*(?:\\?[\"'])?([A-Za-z0-9_\-@#$%^&+=!]{6,})",
546
634
  "severity": "ÉLEVÉ",
547
635
  "description": "Mot de passe rédigé directement par l'utilisateur dans l'invite de dialogue avec l'IA."
548
636
  },
@@ -602,6 +690,10 @@ def load_custom_rules() -> Dict[str, Dict[str, str]]:
602
690
  if k.startswith("_") or not isinstance(v, dict):
603
691
  continue
604
692
  if "regex" in v and "severity" in v:
693
+ try:
694
+ re.compile(v["regex"])
695
+ except (re.error, TypeError):
696
+ continue # une regex invalide ne doit pas interrompre tout le scan
605
697
  rules[f"{k} [CUSTOM]"] = {
606
698
  "regex": v["regex"],
607
699
  "severity": v.get("severity", "MOYEN"),
@@ -614,6 +706,8 @@ def load_custom_rules() -> Dict[str, Dict[str, str]]:
614
706
 
615
707
  def send_desktop_notification(title: str, message: str, severity: str = "normal"):
616
708
  """Envoie une notification de bureau native sous Linux / KDE / GNOME ou Windows 10/11."""
709
+ if os.getenv("AISCOUT_NO_NOTIFY"):
710
+ return
617
711
  if IS_WINDOWS:
618
712
  clean_title = title.replace('"', '`"').replace("'", "''")
619
713
  clean_msg = message.replace('"', '`"').replace("'", "''").replace("\n", " ")
@@ -657,13 +751,22 @@ def send_desktop_notification(title: str, message: str, severity: str = "normal"
657
751
 
658
752
  PLACEHOLDER_KEYWORDS = [
659
753
  "your_key", "your_token", "your_password", "your_api_key", "your_secret",
660
- "example", "placeholder", "dummy", "fake", "xxxx", "00000", "12345", "test",
661
- "abcdef", "sk-ant-xxx", "ghp_xxx", "password", "secret", "undefined", "null",
662
- "none", "true", "false", "localhost", "admin", "root", "dev", "prod", "change_me",
663
- "changeme", "foobar", "redacted", "token_here", "key_here", "sample", "postgres_db",
664
- "ostgres", "postgres"
754
+ "example", "placeholder", "dummy", "fake", "xxxx", "00000", "12345",
755
+ "abcdef", "sk-ant-xxx", "ghp_xxx", "undefined", "change_me",
756
+ "changeme", "foobar", "redacted", "token_here", "key_here", "sample", "postgres_db"
665
757
  ]
666
758
 
759
+ # Mots courts : pertinents pour un mot de passe tapé à la main, mais qu'un jeton aléatoire
760
+ # contient par hasard (« dev » apparaît dans ~0,1 % des PAT GitHub). Ils ne s'appliquent
761
+ # donc pas aux règles intégrées à préfixe fixe.
762
+ WEAK_PLACEHOLDER_KEYWORDS = [
763
+ "test", "password", "secret", "null", "none", "true", "false", "localhost",
764
+ "admin", "root", "dev", "prod", "ostgres", "postgres"
765
+ ]
766
+
767
+ # Sous-chaînes des noms de règles dont la valeur est du texte libre (mot de passe, variable).
768
+ FREE_TEXT_RULE_MARKERS = ["Prompt", "CLI", "commande", "sensible", "Base de données"]
769
+
667
770
  COMMON_WORDS_FR_EN = {
668
771
  "compromis", "obligatoire", "requis", "introuvable", "invalide", "correct",
669
772
  "incorrect", "temporaire", "modifie", "modifié", "supprime", "supprimé",
@@ -866,6 +969,8 @@ class ScoutEngine:
866
969
  self._cwd_cache: Dict[str, str] = {}
867
970
 
868
971
  def _locate_ripgrep(self) -> Optional[str]:
972
+ if os.getenv("AISCOUT_DISABLE_RIPGREP"):
973
+ return None
869
974
  system_rg = shutil.which("rg")
870
975
  if system_rg:
871
976
  return system_rg
@@ -874,7 +979,8 @@ class ScoutEngine:
874
979
  return str(cargo_rg)
875
980
  return None
876
981
 
877
- def get_target_directories(self) -> List[Path]:
982
+ def get_candidate_targets(self) -> List[Path]:
983
+ """Tous les emplacements connus, qu'ils existent ou non."""
878
984
  targets = [
879
985
  self.home / ".claude" / "projects",
880
986
  self.home / ".claude" / "history.jsonl",
@@ -904,7 +1010,10 @@ class ScoutEngine:
904
1010
  targets.extend([
905
1011
  lad / "Programs" / "cursor" / "resources"
906
1012
  ])
907
- return [p for p in targets if p.exists()]
1013
+ return targets
1014
+
1015
+ def get_target_directories(self) -> List[Path]:
1016
+ return [p for p in self.get_candidate_targets() if p.exists()]
908
1017
 
909
1018
  def identify_tool(self, file_path: str) -> str:
910
1019
  fp = str(file_path).lower().replace("\\", "/")
@@ -975,28 +1084,28 @@ class ScoutEngine:
975
1084
  usage = "Texte consigné en session"
976
1085
  try:
977
1086
  data = json.loads(line_clean)
978
- if "type" in data:
979
- t = data.get("type")
980
- if t == "user":
981
- msg = data.get("message", {})
982
- content = msg.get("content", "")
983
- if isinstance(content, list):
984
- for item in content:
985
- if item.get("type") == "tool_result":
986
- usage = "Sortie d'outil exécuté (commande bash, .env ou infisical)"
987
- snip = str(item.get("content", ""))[:200]
988
- return usage, snip
989
- usage = "Message / Prompt utilisateur direct"
990
- elif t == "assistant":
991
- usage = "Réponse / Génération de l'assistant IA"
992
- elif "step_index" in data:
1087
+ except Exception:
1088
+ data = None
1089
+ if isinstance(data, dict):
1090
+ # Antigravity porte aussi un champ "type" : tester "step_index" en premier.
1091
+ if "step_index" in data:
993
1092
  stype = data.get("type")
994
1093
  if stype == "USER_INPUT":
995
1094
  usage = "Prompt utilisateur Antigravity"
996
1095
  elif stype == "PLANNER_RESPONSE":
997
1096
  usage = "Raisonnement / Réponse du modèle"
998
- except Exception:
999
- pass
1097
+ elif data.get("type") == "user":
1098
+ msg = data.get("message")
1099
+ content = msg.get("content", "") if isinstance(msg, dict) else ""
1100
+ if isinstance(content, list):
1101
+ for item in content:
1102
+ if isinstance(item, dict) and item.get("type") == "tool_result":
1103
+ usage = "Sortie d'outil exécuté (commande bash, .env ou infisical)"
1104
+ snip = str(item.get("content", ""))[:200]
1105
+ return usage, snip
1106
+ usage = "Message / Prompt utilisateur direct"
1107
+ elif data.get("type") == "assistant":
1108
+ usage = "Réponse / Génération de l'assistant IA"
1000
1109
 
1001
1110
  if "infisical" in line_clean.lower():
1002
1111
  usage = "Sortie de commande Infisical ou dump de secrets"
@@ -1009,26 +1118,30 @@ class ScoutEngine:
1009
1118
  return usage, snippet
1010
1119
 
1011
1120
  def is_valid_secret(self, text: str, category: str = "", full_line: str = "") -> bool:
1012
- t = text.strip().strip("'\"`")
1013
- lower_t = t.lower()
1121
+ val = text.strip().strip("'\"`")
1122
+ lower_t = val.lower()
1014
1123
 
1015
- if len(t) < 6:
1124
+ if len(val) < 6:
1016
1125
  return False
1017
1126
 
1018
- if t.startswith("$") or t.startswith("<") or t.startswith("{{") or t.startswith("[") or t.startswith("("):
1127
+ if val.startswith("$") or val.startswith("<") or val.startswith("{{") or val.startswith("[") or val.startswith("("):
1019
1128
  return False
1020
- if t.endswith(">") or t.endswith("]") or t.endswith(")") or t.endswith("}"):
1129
+ if val.endswith(">") or val.endswith("]") or val.endswith(")") or val.endswith("}"):
1021
1130
  return False
1022
1131
 
1023
- for ph in PLACEHOLDER_KEYWORDS:
1132
+ is_free_text = any(kw in category for kw in FREE_TEXT_RULE_MARKERS)
1133
+ keywords = PLACEHOLDER_KEYWORDS
1134
+ if is_free_text or category not in PATTERNS:
1135
+ keywords = PLACEHOLDER_KEYWORDS + WEAK_PLACEHOLDER_KEYWORDS
1136
+ for ph in keywords:
1024
1137
  if ph in lower_t:
1025
1138
  return False
1026
- if re.search(r"^(?:YOUR_|VOTRE_|SAMPLE_|MY_|TEST_|EXAMPLE_)[A-Z0-9_]+$", t, re.IGNORECASE):
1139
+ if re.search(r"^(?:YOUR_|VOTRE_|SAMPLE_|MY_|TEST_|EXAMPLE_)[A-Z0-9_]+$", val, re.IGNORECASE):
1027
1140
  return False
1028
- if re.search(r"_[A-Z]+_(?:KEY|TOKEN|SECRET|PASSWORD|HERE)$", t, re.IGNORECASE):
1141
+ if re.search(r"_[A-Z]+_(?:KEY|TOKEN|SECRET|PASSWORD|HERE)$", val, re.IGNORECASE):
1029
1142
  return False
1030
1143
 
1031
- if len(set(lower_t)) <= 2 and len(t) >= 6:
1144
+ if len(set(lower_t)) <= 2 and len(val) >= 6:
1032
1145
  return False
1033
1146
 
1034
1147
  if any(marker in full_line for marker in ["AI SECRET SCOUT", "[REDACTED_BY_AISCOUT]", "FICHE DÉTAILLÉE DU SECRET", "rapport_audit"]):
@@ -1042,112 +1155,173 @@ class ScoutEngine:
1042
1155
  if lower_t in COMMON_WORDS_FR_EN:
1043
1156
  return False
1044
1157
 
1045
- classes = count_character_classes(t)
1046
- entropy = calculate_entropy(t)
1158
+ classes = count_character_classes(val)
1159
+ entropy = calculate_entropy(val)
1047
1160
 
1048
1161
  if classes == 1:
1049
- if t.isalpha():
1162
+ if val.isalpha():
1050
1163
  return False
1051
1164
  if entropy < 3.0:
1052
1165
  return False
1053
1166
 
1054
- if classes == 2 and t.istitle() and t.isalpha():
1167
+ if classes == 2 and val.istitle() and val.isalpha():
1055
1168
  return False
1056
1169
 
1057
1170
  return True
1058
1171
 
1172
+ @staticmethod
1173
+ def rg_compatible_regex(regex: str) -> str:
1174
+ """Retire les assertions (?=…) (?!…) (?<=…) (?<!…), que ripgrep refuse.
1175
+
1176
+ Le motif obtenu est plus large que l'original : ripgrep ne fait que présélectionner
1177
+ les lignes, et la regex complète est réappliquée en Python par _match_line()."""
1178
+ out = []
1179
+ i = 0
1180
+ while i < len(regex):
1181
+ if regex[i] == "\\":
1182
+ out.append(regex[i:i + 2])
1183
+ i += 2
1184
+ continue
1185
+ if regex.startswith(("(?=", "(?!", "(?<=", "(?<!"), i):
1186
+ depth, j, in_class = 0, i, False
1187
+ while j < len(regex):
1188
+ c = regex[j]
1189
+ if c == "\\":
1190
+ j += 2
1191
+ continue
1192
+ if in_class:
1193
+ in_class = c != "]"
1194
+ elif c == "[":
1195
+ in_class = True
1196
+ elif c == "(":
1197
+ depth += 1
1198
+ elif c == ")":
1199
+ depth -= 1
1200
+ if depth == 0:
1201
+ break
1202
+ j += 1
1203
+ i = j + 1
1204
+ continue
1205
+ out.append(regex[i])
1206
+ i += 1
1207
+ return "".join(out)
1208
+
1209
+ def _file_meta(self, fpath: str) -> Tuple[str, str, str]:
1210
+ try:
1211
+ mtime = datetime.fromtimestamp(os.path.getmtime(fpath)).strftime("%Y-%m-%d %H:%M")
1212
+ except OSError:
1213
+ mtime = "Inconnu"
1214
+ return self.identify_tool(fpath), self.resolve_project(fpath), mtime
1215
+
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]:
1218
+ """Point unique de correspondance : ripgrep, repli Python et watchdog passent tous ici."""
1219
+ if "ai_secret_scout" in fpath or "scan_secrets" in fpath:
1220
+ return []
1221
+ found = []
1222
+ for m in re.finditer(conf["regex"], line):
1223
+ raw = m.group(1) if m.groups() and m.group(1) is not None else m.group(0)
1224
+ if not self.is_valid_secret(raw, category=cat_name, full_line=line):
1225
+ continue
1226
+ usage, snippet = self.determine_usage_context(line, tool)
1227
+ found.append(Finding(
1228
+ category=cat_name,
1229
+ secret_raw=raw,
1230
+ file_path=fpath,
1231
+ line_no=lno,
1232
+ tool=tool,
1233
+ project=project,
1234
+ usage_context=usage,
1235
+ snippet=snippet,
1236
+ timestamp=mtime,
1237
+ severity=conf.get("severity", "MOYEN"),
1238
+ description=conf.get("description", "")
1239
+ ))
1240
+ return found
1241
+
1242
+ def _scan_file(self, fpath: str, patterns: Dict[str, Dict[str, str]],
1243
+ tool: str, project: str, mtime: str) -> List[Finding]:
1244
+ found = []
1245
+ try:
1246
+ with open(fpath, "r", encoding="utf-8", errors="ignore") as f:
1247
+ for lno, line in enumerate(f, 1):
1248
+ for cat_name, conf in patterns.items():
1249
+ found.extend(self._match_line(cat_name, conf, line, fpath, lno, tool, project, mtime))
1250
+ except OSError:
1251
+ pass
1252
+ return found
1253
+
1254
+ @staticmethod
1255
+ def _list_scan_files(targets: List[str]) -> List[str]:
1256
+ files = []
1257
+ for target in targets:
1258
+ tp = Path(target)
1259
+ if tp.is_file():
1260
+ files.append(target)
1261
+ elif tp.is_dir():
1262
+ for root, _, names in os.walk(tp):
1263
+ for name in names:
1264
+ if name.endswith((".jsonl", ".json", ".md")):
1265
+ files.append(os.path.join(root, name))
1266
+ return files
1267
+
1268
+ def _rg_search(self, regex: str, targets: List[str]) -> Optional[List[Tuple[str, int, str]]]:
1269
+ """Lignes trouvées par ripgrep, ou None si ripgrep refuse la regex."""
1270
+ cmd = [self.rg_path, "--null", "-H", "-n", "--no-heading", "--no-messages",
1271
+ "--glob", "!*.bak", "--glob", "!*.backup",
1272
+ "-e", self.rg_compatible_regex(regex)] + targets
1273
+ proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
1274
+ if proc.returncode == 2 and not proc.stdout and "regex" in proc.stderr.lower():
1275
+ return None
1276
+ results = []
1277
+ # --null sépare le chemin par un octet nul : un « : » dans le chemin (C:\ sous Windows)
1278
+ # ne décale plus le découpage. split("\n") et non splitlines(), qui coupe aussi sur
1279
+ # U+2028 ou \x0c présents dans le contenu.
1280
+ for record in proc.stdout.split("\n"):
1281
+ fpath, sep, rest = record.partition("\0")
1282
+ if not sep:
1283
+ continue
1284
+ lno_str, _, content = rest.partition(":")
1285
+ try:
1286
+ lno = int(lno_str)
1287
+ except ValueError:
1288
+ lno = 0
1289
+ results.append((fpath, lno, content.rstrip("\r")))
1290
+ return results
1291
+
1059
1292
  def scan(self, progress_callback=None) -> List[Finding]:
1060
1293
  self.findings = []
1061
- targets = [str(t) for t in self.get_target_directories()]
1294
+ targets = [str(p) for p in self.get_target_directories()]
1062
1295
  if not targets:
1063
1296
  return []
1064
1297
 
1298
+ meta_cache: Dict[str, Tuple[str, str, str]] = {}
1299
+
1300
+ def meta(fpath: str) -> Tuple[str, str, str]:
1301
+ if fpath not in meta_cache:
1302
+ meta_cache[fpath] = self._file_meta(fpath)
1303
+ return meta_cache[fpath]
1304
+
1305
+ python_patterns = dict(self.patterns)
1065
1306
  if self.rg_path:
1307
+ python_patterns = {}
1066
1308
  total_cats = len(self.patterns)
1067
1309
  for idx, (cat_name, conf) in enumerate(self.patterns.items(), 1):
1068
1310
  if progress_callback:
1069
1311
  progress_callback(idx, total_cats, f"Analyse : {cat_name}")
1070
- cmd = [self.rg_path, "-H", "-n", "--no-heading", "--no-messages", "--glob", "!*.bak", "--glob", "!*.backup", "-e", conf["regex"]] + targets
1071
- proc = subprocess.run(cmd, capture_output=True, text=True, errors="replace")
1072
- for line in proc.stdout.splitlines():
1073
- if not line:
1074
- continue
1075
- parts = line.split(":", 2)
1076
- if len(parts) < 3:
1077
- continue
1078
- fpath, lno_str, content = parts[0], parts[1], parts[2]
1079
- if "ai_secret_scout" in fpath or "scan_secrets" in fpath:
1080
- continue
1081
- try:
1082
- lno = int(lno_str)
1083
- except ValueError:
1084
- lno = 0
1085
-
1086
- for m in re.finditer(conf["regex"], content):
1087
- raw = m.group(1) if m.groups() else m.group(0)
1088
- if not self.is_valid_secret(raw, category=cat_name, full_line=content):
1089
- continue
1090
- tool = self.identify_tool(fpath)
1091
- project = self.resolve_project(fpath)
1092
- usage, snippet = self.determine_usage_context(content, tool)
1093
- try:
1094
- mtime = datetime.fromtimestamp(os.path.getmtime(fpath)).strftime("%Y-%m-%d %H:%M")
1095
- except Exception:
1096
- mtime = "Inconnu"
1097
-
1098
- self.findings.append(Finding(
1099
- category=cat_name,
1100
- secret_raw=raw,
1101
- file_path=fpath,
1102
- line_no=lno,
1103
- tool=tool,
1104
- project=project,
1105
- usage_context=usage,
1106
- snippet=snippet,
1107
- timestamp=mtime,
1108
- severity=conf.get("severity", "MOYEN"),
1109
- description=conf.get("description", "")
1110
- ))
1111
- else:
1112
- all_files = []
1113
- for t in targets:
1114
- tp = Path(t)
1115
- if tp.is_file():
1116
- all_files.append(tp)
1117
- elif tp.is_dir():
1118
- for root, _, files in os.walk(tp):
1119
- for f in files:
1120
- if f.endswith((".jsonl", ".json", ".md")) and not f.endswith((".bak", ".backup")):
1121
- all_files.append(Path(root) / f)
1312
+ hits = self._rg_search(conf["regex"], targets)
1313
+ if hits is None:
1314
+ python_patterns[cat_name] = conf
1315
+ continue
1316
+ for fpath, lno, content in hits:
1317
+ self.findings.extend(self._match_line(cat_name, conf, content, fpath, lno, *meta(fpath)))
1122
1318
 
1319
+ if python_patterns:
1320
+ all_files = self._list_scan_files(targets)
1123
1321
  for idx, fpath in enumerate(all_files, 1):
1124
1322
  if progress_callback and idx % 10 == 0:
1125
- progress_callback(idx, len(all_files), f"Lecture : {fpath.name}")
1126
- try:
1127
- with open(fpath, "r", encoding="utf-8", errors="ignore") as f:
1128
- for lno, line in enumerate(f, 1):
1129
- for cat_name, conf in self.patterns.items():
1130
- for m in re.finditer(conf["regex"], line):
1131
- raw = m.group(1) if m.groups() else m.group(0)
1132
- if not self.is_valid_secret(raw, category=cat_name, full_line=line):
1133
- continue
1134
- tool = self.identify_tool(str(fpath))
1135
- project = self.resolve_project(str(fpath))
1136
- usage, snippet = self.determine_usage_context(line, tool)
1137
- self.findings.append(Finding(
1138
- category=cat_name,
1139
- secret_raw=raw,
1140
- file_path=str(fpath),
1141
- line_no=lno,
1142
- tool=tool,
1143
- project=project,
1144
- usage_context=usage,
1145
- snippet=snippet,
1146
- severity=conf.get("severity", "MOYEN"),
1147
- description=conf.get("description", "")
1148
- ))
1149
- except Exception:
1150
- continue
1323
+ progress_callback(idx, len(all_files), f"Lecture : {Path(fpath).name}")
1324
+ self.findings.extend(self._scan_file(fpath, python_patterns, *meta(fpath)))
1151
1325
 
1152
1326
  dedup_map: Dict[Tuple[str, str, str, int], Finding] = {}
1153
1327
  for f in self.findings:
@@ -1159,71 +1333,43 @@ class ScoutEngine:
1159
1333
 
1160
1334
  def scan_single_file(self, fpath: Path) -> List[Finding]:
1161
1335
  """Analyse unitairement un fichier pour y détecter des secrets."""
1162
- if not fpath.exists() or not fpath.is_file() or str(fpath).endswith((".bak", ".backup")):
1336
+ if not fpath.is_file() or str(fpath).endswith((".bak", ".backup")):
1163
1337
  return []
1164
- findings = []
1165
- tool = self.identify_tool(str(fpath))
1166
- project = self.resolve_project(str(fpath))
1167
- try:
1168
- mtime = datetime.fromtimestamp(os.path.getmtime(fpath)).strftime("%Y-%m-%d %H:%M:%S")
1169
- except Exception:
1170
- mtime = "Inconnu"
1171
-
1172
- try:
1173
- with open(fpath, "r", encoding="utf-8", errors="ignore") as f:
1174
- for lno, line in enumerate(f, 1):
1175
- for cat_name, conf in self.patterns.items():
1176
- for m in re.finditer(conf["regex"], line):
1177
- raw = m.group(1) if m.groups() else m.group(0)
1178
- if not self.is_valid_secret(raw, category=cat_name, full_line=line):
1179
- continue
1180
- usage, snippet = self.determine_usage_context(line, tool)
1181
- findings.append(Finding(
1182
- category=cat_name,
1183
- secret_raw=raw,
1184
- file_path=str(fpath),
1185
- line_no=lno,
1186
- tool=tool,
1187
- project=project,
1188
- usage_context=usage,
1189
- snippet=snippet,
1190
- timestamp=mtime,
1191
- severity=conf.get("severity", "MOYEN"),
1192
- description=conf.get("description", "")
1193
- ))
1194
- except Exception:
1195
- pass
1196
- return findings
1338
+ return self._scan_file(str(fpath), self.patterns, *self._file_meta(str(fpath)))
1197
1339
 
1198
1340
  def redact_secret(self, finding: Finding) -> bool:
1341
+ """Remplace toutes les occurrences du secret. True si le fichier n'en contient plus."""
1199
1342
  target = Path(finding.file_path)
1200
- if not target.exists():
1343
+ if not finding.secret_raw or finding.secret_raw == "[REDACTED_BY_AISCOUT]" or not target.is_file():
1201
1344
  return False
1202
- backup_file = target.with_suffix(target.suffix + ".bak")
1203
- if not backup_file.exists():
1204
- shutil.copy2(target, backup_file)
1345
+ needle = finding.secret_raw.encode("utf-8")
1205
1346
  try:
1206
- with open(target, "r", encoding="utf-8", errors="ignore") as f:
1207
- content = f.read()
1208
- new_content = content.replace(finding.secret_raw, "[REDACTED_BY_AISCOUT]")
1209
- with open(target, "w", encoding="utf-8") as f:
1210
- f.write(new_content)
1347
+ # En octets : les séquences non UTF-8 et les fins de ligne restent intactes.
1348
+ content = target.read_bytes()
1349
+ if needle not in content:
1350
+ return True
1351
+ backup_file = target.with_name(target.name + ".bak")
1352
+ if not backup_file.exists():
1353
+ shutil.copy2(target, backup_file)
1354
+ target.write_bytes(content.replace(needle, b"[REDACTED_BY_AISCOUT]"))
1211
1355
  return True
1212
- except Exception:
1356
+ except OSError:
1213
1357
  return False
1214
1358
 
1215
1359
  def list_backups(self) -> List[Path]:
1216
1360
  """Recherche et liste tous les fichiers de sauvegarde .bak dans les répertoires d'IA."""
1217
1361
  backups = []
1218
- targets = self.get_target_directories()
1219
- for t in targets:
1220
- if t.is_file() and str(t).endswith(".bak"):
1221
- backups.append(t)
1222
- elif t.is_dir():
1223
- for root, _, files in os.walk(t):
1362
+ for target in self.get_candidate_targets():
1363
+ if target.is_dir():
1364
+ for root, _, files in os.walk(target):
1224
1365
  for f in files:
1225
1366
  if f.endswith(".bak"):
1226
1367
  backups.append(Path(root) / f)
1368
+ else:
1369
+ # Cible fichier (history.jsonl…) : sa sauvegarde est posée à côté.
1370
+ bak = target.with_name(target.name + ".bak")
1371
+ if bak.is_file():
1372
+ backups.append(bak)
1227
1373
  backups.sort(key=lambda p: os.path.getmtime(p) if p.exists() else 0, reverse=True)
1228
1374
  return backups
1229
1375
 
@@ -1340,13 +1486,13 @@ class ScoutTUI:
1340
1486
  lines = []
1341
1487
  lines.append(self.build_top_bar(width))
1342
1488
  lines.append("")
1343
- lines.append(self.center_line(f"{C.YELLOW}{C.BOLD}⚠️ CONFIRMATION{C.RESET}", width))
1489
+ lines.append(self.center_line(f"{C.YELLOW}{C.BOLD}{t('confirm_title')}{C.RESET}", width))
1344
1490
  lines.append("")
1345
1491
  lines.append(self.center_line(f"{C.WHITE}{question}{C.RESET}", width))
1346
1492
  while len(lines) < height - 1:
1347
1493
  lines.append("")
1348
1494
  lines = lines[:height - 1]
1349
- lines.append(self.build_bottom_bar(width, "[O/Y] Confirmer / Yes │ [Annuler / Any other key]"))
1495
+ lines.append(self.build_bottom_bar(width, t("confirm_bottom")))
1350
1496
  sys.stdout.write("\033[H" + "\n".join(lines))
1351
1497
  sys.stdout.flush()
1352
1498
  k = read_key()
@@ -1363,7 +1509,7 @@ class ScoutTUI:
1363
1509
  while len(lines) < height - 1:
1364
1510
  lines.append("")
1365
1511
  lines = lines[:height - 1]
1366
- lines.append(self.build_bottom_bar(width, "[Enter] / [Space] to continue"))
1512
+ lines.append(self.build_bottom_bar(width, t("continue_hint")))
1367
1513
  sys.stdout.write("\033[H" + "\n".join(lines))
1368
1514
  sys.stdout.flush()
1369
1515
  while read_key() not in ("ENTER", "SPACE", "ESC"):
@@ -1457,9 +1603,9 @@ class ScoutTUI:
1457
1603
  if self.active_filter_tool or self.active_filter_project:
1458
1604
  filts = []
1459
1605
  if self.active_filter_tool:
1460
- filts.append(f"Tool: {self.active_filter_tool}")
1606
+ filts.append(f"{t('filter_tool_lbl')}: {self.active_filter_tool}")
1461
1607
  if self.active_filter_project:
1462
- filts.append(f"Project: {self.active_filter_project}")
1608
+ filts.append(f"{t('filter_project_lbl')}: {self.active_filter_project}")
1463
1609
  lines_buffer.append(self.center_line(f"{C.MAGENTA}{t('filter_active', filts=', '.join(filts), count=len(self.get_filtered_findings()))}{C.RESET}", width))
1464
1610
  elif height >= 30:
1465
1611
  lines_buffer.append("")
@@ -1663,7 +1809,7 @@ class ScoutTUI:
1663
1809
  while len(lines) < height - 1:
1664
1810
  lines.append("")
1665
1811
  lines = lines[:height - 1]
1666
- hint = "[Backspace/Esc] Clear search │ [Q] Back" if self.search_query else "[Esc] / [Q] Return to dashboard"
1812
+ hint = t("table_empty_search_hint") if self.search_query else t("table_empty_hint")
1667
1813
  lines.append(self.build_bottom_bar(width, hint))
1668
1814
  sys.stdout.write("\033[H" + "\n".join(lines))
1669
1815
  sys.stdout.flush()
@@ -1857,8 +2003,8 @@ class ScoutTUI:
1857
2003
  lines.append("")
1858
2004
  lines.append(self.center_line(f"{C.RED}{C.BOLD}{t('confirm_redact_title')}{C.RESET}", width))
1859
2005
  lines.append("")
1860
- lines.append(self.center_line(f"File : {item.file_path}", width))
1861
- lines.append(self.center_line(f"Line : {item.line_no}", width))
2006
+ lines.append(self.center_line(f"{t('redact_file_lbl'):<7}: {item.file_path}", width))
2007
+ lines.append(self.center_line(f"{t('redact_line_lbl'):<7}: {item.line_no}", width))
1862
2008
  lines.append(self.center_line(f"{C.YELLOW}{t('confirm_redact_replace')}{C.RESET}", width))
1863
2009
  lines.append(self.center_line(f"{C.GREEN}{t('confirm_redact_bak')}{C.RESET}", width))
1864
2010
  while len(lines) < height - 1:
@@ -1883,7 +2029,7 @@ class ScoutTUI:
1883
2029
  while len(lines) < height - 1:
1884
2030
  lines.append("")
1885
2031
  lines = lines[:height - 1]
1886
- lines.append(self.build_bottom_bar(width, "[Enter] / [Space] to continue"))
2032
+ lines.append(self.build_bottom_bar(width, t("continue_hint")))
1887
2033
  sys.stdout.write("\033[H" + "\n".join(lines))
1888
2034
  sys.stdout.flush()
1889
2035
  while read_key() not in ("ENTER", "ESC", "SPACE"):
@@ -1898,21 +2044,21 @@ class ScoutTUI:
1898
2044
  lines = []
1899
2045
  lines.append(self.build_top_bar(width))
1900
2046
  lines.append("")
1901
- lines.append(self.center_line(f"{C.MAGENTA}{C.BOLD}🏷️ GESTION DES FILTRES PAR OUTIL IA{C.RESET}", width))
2047
+ lines.append(self.center_line(f"{C.MAGENTA}{C.BOLD}{t('filter_title')}{C.RESET}", width))
1902
2048
  lines.append("")
1903
2049
 
1904
- for i, t in enumerate(tools, 1):
1905
- act = f"{C.GREEN}(ACTIF){C.RESET}" if self.active_filter_tool == t else ""
1906
- lines.append(f" [{i}] {t} {act}")
2050
+ for i, tool_name in enumerate(tools, 1):
2051
+ act = f"{C.GREEN}{t('filter_active_tag')}{C.RESET}" if self.active_filter_tool == tool_name else ""
2052
+ lines.append(f" [{i}] {tool_name} {act}")
1907
2053
 
1908
2054
  lines.append("")
1909
- lines.append(" [R] Réinitialiser tous les filtres")
1910
- lines.append(" [Q] Retour au menu principal")
2055
+ lines.append(f" {t('filter_reset')}")
2056
+ lines.append(f" {t('filter_back')}")
1911
2057
 
1912
2058
  while len(lines) < height - 1:
1913
2059
  lines.append("")
1914
2060
  lines = lines[:height - 1]
1915
- lines.append(self.build_bottom_bar(width, "Sélectionnez un numéro [1-N], [R] pour réinitialiser ou [Q] pour retour"))
2061
+ lines.append(self.build_bottom_bar(width, t("filter_bottom")))
1916
2062
 
1917
2063
  sys.stdout.write("\033[H" + "\n".join(lines))
1918
2064
  sys.stdout.flush()
@@ -1937,16 +2083,16 @@ class ScoutTUI:
1937
2083
  lines = []
1938
2084
  lines.append(self.build_top_bar(width))
1939
2085
  lines.append("")
1940
- lines.append(self.center_line(f"{C.BLUE}{C.BOLD}💾 EXPORTER LE DOSSIER D'AUDIT COMPLET{C.RESET}", width))
2086
+ lines.append(self.center_line(f"{C.BLUE}{C.BOLD}{t('export_title')}{C.RESET}", width))
1941
2087
  lines.append("")
1942
- lines.append(f" [1] Exporter en Markdown : {C.CYAN}{md_dest.name}{C.RESET}")
1943
- lines.append(f" [2] Exporter en JSON : {C.CYAN}{json_dest.name}{C.RESET}")
1944
- lines.append(" [Q] Annuler et revenir au menu")
2088
+ lines.append(f" {t('export_md', name=C.CYAN + md_dest.name + C.RESET)}")
2089
+ lines.append(f" {t('export_json', name=C.CYAN + json_dest.name + C.RESET)}")
2090
+ lines.append(f" {t('export_cancel')}")
1945
2091
 
1946
2092
  while len(lines) < height - 1:
1947
2093
  lines.append("")
1948
2094
  lines = lines[:height - 1]
1949
- lines.append(self.build_bottom_bar(width, "Appuyez sur [1] ou [2] pour exporter, ou [Q] pour annuler"))
2095
+ lines.append(self.build_bottom_bar(width, t("export_bottom")))
1950
2096
 
1951
2097
  sys.stdout.write("\033[H" + "\n".join(lines))
1952
2098
  sys.stdout.flush()
@@ -1958,11 +2104,11 @@ class ScoutTUI:
1958
2104
  anim_lines = []
1959
2105
  anim_lines.append(self.build_top_bar(width))
1960
2106
  anim_lines.append("")
1961
- anim_lines.append(self.center_line(f"{C.CYAN}{C.BOLD}{f} Génération du dossier d'audit en cours...{C.RESET}", width))
2107
+ anim_lines.append(self.center_line(f"{C.CYAN}{C.BOLD}{f} {t('export_progress')}{C.RESET}", width))
1962
2108
  while len(anim_lines) < height - 1:
1963
2109
  anim_lines.append("")
1964
2110
  anim_lines = anim_lines[:height - 1]
1965
- anim_lines.append(self.build_bottom_bar(width, "Écriture des données sécurisées..."))
2111
+ anim_lines.append(self.build_bottom_bar(width, t("export_writing")))
1966
2112
  sys.stdout.write("\033[H" + "\n".join(anim_lines))
1967
2113
  sys.stdout.flush()
1968
2114
  time.sleep(0.05)
@@ -1976,13 +2122,13 @@ class ScoutTUI:
1976
2122
  lines = []
1977
2123
  lines.append(self.build_top_bar(width))
1978
2124
  lines.append("")
1979
- lines.append(self.center_line(f"{C.GREEN}{C.BOLD}✔ RAPPORT D'AUDIT EXPORTÉ AVEC SUCCÈS !{C.RESET}", width))
1980
- lines.append(self.center_line(f"{C.WHITE}Fichier : {C.CYAN}{target}{C.RESET}", width))
1981
- lines.append(self.center_line(f"{C.YELLOW}Total de secrets consignés : {len(items)}{C.RESET}", width))
2125
+ lines.append(self.center_line(f"{C.GREEN}{C.BOLD}{t('export_done')}{C.RESET}", width))
2126
+ lines.append(self.center_line(f"{C.WHITE}{t('export_file', path=C.CYAN + str(target) + C.RESET)}", width))
2127
+ lines.append(self.center_line(f"{C.YELLOW}{t('export_total', count=len(items))}{C.RESET}", width))
1982
2128
  while len(lines) < height - 1:
1983
2129
  lines.append("")
1984
2130
  lines = lines[:height - 1]
1985
- lines.append(self.build_bottom_bar(width, "[Entrée] ou [Espace] pour revenir au menu"))
2131
+ lines.append(self.build_bottom_bar(width, t("export_return")))
1986
2132
  sys.stdout.write("\033[H" + "\n".join(lines))
1987
2133
  sys.stdout.flush()
1988
2134
  while read_key() not in ("ENTER", "SPACE", "ESC"):
@@ -2101,7 +2247,7 @@ class ScoutTUI:
2101
2247
  while len(lines) < height - 1:
2102
2248
  lines.append("")
2103
2249
  lines = lines[:height - 1]
2104
- lines.append(self.build_bottom_bar(width, "[Enter] / [Space] to return to dashboard"))
2250
+ lines.append(self.build_bottom_bar(width, t("scan_return_hint")))
2105
2251
  sys.stdout.write("\033[H" + "\n".join(lines))
2106
2252
  sys.stdout.flush()
2107
2253
  while read_key() not in ("ENTER", "SPACE", "ESC"):
@@ -2128,7 +2274,7 @@ class ScoutTUI:
2128
2274
  while len(lines) < height - 1:
2129
2275
  lines.append("")
2130
2276
  lines = lines[:height - 1]
2131
- lines.append(self.build_bottom_bar(width, "[Enter] / [Esc] Return to dashboard"))
2277
+ lines.append(self.build_bottom_bar(width, t("scan_return_hint")))
2132
2278
  sys.stdout.write("\033[H" + "\n".join(lines))
2133
2279
  sys.stdout.flush()
2134
2280
  while read_key() not in ("ENTER", "ESC", "SPACE", "q", "Q"):
@@ -2194,43 +2340,50 @@ class ScoutTUI:
2194
2340
  selected_bak_idx = min(len(backups) - 1, selected_bak_idx + 1)
2195
2341
  elif key in ("r", "R"):
2196
2342
  target_bak = backups[selected_bak_idx]
2197
- if self.confirm_action(f"Restore {target_bak.stem} from backup copy?"):
2343
+ if self.confirm_action(t("bak_confirm_one", name=target_bak.stem)):
2198
2344
  if self.engine.restore_backup(target_bak):
2199
- self.show_flash_message(f"✔ {target_bak.stem} restored successfully!", C.GREEN)
2345
+ self.show_flash_message(t("bak_restored_one", name=target_bak.stem), C.GREEN)
2200
2346
  else:
2201
- self.show_flash_message("❌ Restoration failed.", C.RED)
2347
+ self.show_flash_message(t("bak_restore_failed"), C.RED)
2202
2348
  elif key in ("a", "A"):
2203
- if self.confirm_action(f"Restore all {len(backups)} backup files?"):
2349
+ if self.confirm_action(t("bak_confirm_all", count=len(backups))):
2204
2350
  cnt = self.engine.restore_all_backups()
2205
- self.show_flash_message(f"✔ {cnt} file(s) restored successfully!", C.GREEN)
2351
+ self.show_flash_message(t("bak_restored_all", count=cnt), C.GREEN)
2206
2352
  elif key in ("p", "P"):
2207
- if self.confirm_action(f"⚠️ PERMANENTLY PURGE all {len(backups)} backup (.bak) files?"):
2353
+ if self.confirm_action(t("bak_confirm_purge", count=len(backups))):
2208
2354
  cnt = self.engine.clean_backups()
2209
- self.show_flash_message(f"✔ {cnt} .bak file(s) permanently deleted.", C.GREEN)
2355
+ self.show_flash_message(t("bak_purged", count=cnt), C.GREEN)
2210
2356
  elif key in ("q", "Q", "ESC"):
2211
2357
  break
2212
2358
 
2213
2359
  # --- SURVEILLANCE EN TEMPS RÉEL (WATCHDOG DÉDIÉ) ---
2360
+ @staticmethod
2361
+ def _list_watched_files(targets: List[Path]) -> List[Path]:
2362
+ files = []
2363
+ for target in targets:
2364
+ if target.is_file():
2365
+ files.append(target)
2366
+ elif target.is_dir():
2367
+ for root, _, names in os.walk(target):
2368
+ for name in names:
2369
+ if not name.endswith((".bak", ".backup")):
2370
+ files.append(Path(root) / name)
2371
+ return files
2372
+
2214
2373
  def run_watchdog_screen(self):
2215
2374
  targets = self.engine.get_target_directories()
2216
2375
  known_mtimes: Dict[str, float] = {}
2217
2376
 
2218
2377
  # Scan initial des mtimes des fichiers surveillés
2219
- for t in targets:
2220
- if t.is_file():
2221
- try:
2222
- known_mtimes[str(t)] = t.stat().st_mtime
2223
- except Exception:
2224
- pass
2225
- elif t.is_dir():
2226
- for root, _, files in os.walk(t):
2227
- for f in files:
2228
- if not f.endswith((".bak", ".backup")):
2229
- fp = Path(root) / f
2230
- try:
2231
- known_mtimes[str(fp)] = fp.stat().st_mtime
2232
- except Exception:
2233
- pass
2378
+ for fp in self._list_watched_files(targets):
2379
+ try:
2380
+ known_mtimes[str(fp)] = fp.stat().st_mtime
2381
+ except OSError:
2382
+ pass
2383
+
2384
+ # Un secret n'est signalé qu'une fois : les sessions IA sont réécrites en continu,
2385
+ # et chaque modification rescanne le fichier entier.
2386
+ alerted = {(f.category, f.secret_raw, f.file_path) for f in self.findings}
2234
2387
 
2235
2388
  events_log: List[str] = [
2236
2389
  f"{C.GRAY}[{datetime.now().strftime('%H:%M:%S')}] {t('watchdog_started', files=len(known_mtimes))}{C.RESET}"
@@ -2239,10 +2392,13 @@ class ScoutTUI:
2239
2392
  scans_count = 0
2240
2393
  pulse = False
2241
2394
 
2242
- if not IS_WINDOWS:
2395
+ # Hors terminal (service, redirection), pas de lecture clavier : arrêt par signal.
2396
+ interactive = sys.stdin.isatty()
2397
+ if interactive and not IS_WINDOWS:
2243
2398
  fd = sys.stdin.fileno()
2244
2399
  old_term = termios.tcgetattr(fd)
2245
- tty.setraw(fd)
2400
+ # cbreak et non raw : raw coupe OPOST, et chaque "\n" affiché décalerait l'écran en escalier.
2401
+ tty.setcbreak(fd)
2246
2402
 
2247
2403
  try:
2248
2404
  while True:
@@ -2302,7 +2458,9 @@ class ScoutTUI:
2302
2458
 
2303
2459
  # Attente non-bloquante d'une frappe clavier (1.0 seconde)
2304
2460
  interrupted = False
2305
- if IS_WINDOWS:
2461
+ if not interactive:
2462
+ time.sleep(1.0)
2463
+ elif IS_WINDOWS:
2306
2464
  for _ in range(10):
2307
2465
  if msvcrt.kbhit():
2308
2466
  ch = msvcrt.getwch()
@@ -2320,22 +2478,12 @@ class ScoutTUI:
2320
2478
  break
2321
2479
 
2322
2480
  # Analyse des répertoires pour détecter fichiers nouveaux ou modifiés
2323
- current_files = []
2324
- for t in targets:
2325
- if t.is_file():
2326
- current_files.append(t)
2327
- elif t.is_dir():
2328
- for root, _, files in os.walk(t):
2329
- for f in files:
2330
- if not f.endswith((".bak", ".backup")):
2331
- current_files.append(Path(root) / f)
2332
-
2333
2481
  now_ts = datetime.now().strftime("%H:%M:%S")
2334
- for cp in current_files:
2482
+ for cp in self._list_watched_files(targets):
2335
2483
  sp = str(cp)
2336
2484
  try:
2337
2485
  mtime = cp.stat().st_mtime
2338
- except Exception:
2486
+ except OSError:
2339
2487
  continue
2340
2488
 
2341
2489
  # Nouveau fichier ou modification détectée
@@ -2344,7 +2492,13 @@ class ScoutTUI:
2344
2492
  known_mtimes[sp] = mtime
2345
2493
  scans_count += 1
2346
2494
 
2347
- new_findings = self.engine.scan_single_file(cp)
2495
+ file_findings = self.engine.scan_single_file(cp)
2496
+ new_findings = []
2497
+ for f in file_findings:
2498
+ key = (f.category, f.secret_raw, f.file_path)
2499
+ if key not in alerted:
2500
+ alerted.add(key)
2501
+ new_findings.append(f)
2348
2502
  if new_findings:
2349
2503
  alerts_count += len(new_findings)
2350
2504
  for f in new_findings:
@@ -2360,7 +2514,7 @@ class ScoutTUI:
2360
2514
  sys.stdout.write("\a")
2361
2515
  sys.stdout.flush()
2362
2516
  self.findings.append(f)
2363
- else:
2517
+ elif not file_findings:
2364
2518
  action_name = t("watchdog_act_creation") if is_new else t("watchdog_act_modification")
2365
2519
  events_log.append(f"{C.GRAY}[{now_ts}] {t('watchdog_clean_log', act=action_name, file=cp.name)}{C.RESET}")
2366
2520
 
@@ -2368,10 +2522,68 @@ class ScoutTUI:
2368
2522
  events_log = events_log[-50:]
2369
2523
 
2370
2524
  finally:
2371
- if not IS_WINDOWS:
2525
+ if interactive and not IS_WINDOWS:
2372
2526
  termios.tcsetattr(fd, termios.TCSADRAIN, old_term)
2373
2527
 
2374
2528
 
2529
+ # --- MISE À JOUR (npm) ---
2530
+ NPM_PACKAGE = "ai-secret-scout"
2531
+
2532
+
2533
+ def parse_version(text: str) -> Tuple[int, ...]:
2534
+ return tuple(int(n) for n in re.findall(r"\d+", text.split("-")[0])[:3])
2535
+
2536
+
2537
+ def run_update() -> int:
2538
+ """Seul accès réseau de l'outil, et uniquement sur demande : c'est npm qui interroge le registre."""
2539
+ npm = os.getenv("AISCOUT_NPM") or shutil.which("npm")
2540
+ if not npm or not os.path.isfile(npm):
2541
+ print(f"{C.RED}{t('update_no_npm')}{C.RESET}", file=sys.stderr)
2542
+ return 1
2543
+
2544
+ def npm_out(*args: str) -> str:
2545
+ proc = subprocess.run([npm, *args], capture_output=True, text=True, timeout=60)
2546
+ if proc.returncode != 0:
2547
+ detail = (proc.stderr or proc.stdout).strip().splitlines()
2548
+ raise RuntimeError(detail[-1] if detail else f"code {proc.returncode}")
2549
+ return proc.stdout.strip()
2550
+
2551
+ print(f"{C.GRAY}{t('update_checking')}{C.RESET}")
2552
+ try:
2553
+ latest = npm_out("view", NPM_PACKAGE, "version")
2554
+ except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc:
2555
+ print(f"{C.RED}{t('update_check_failed', error=exc)}{C.RESET}", file=sys.stderr)
2556
+ return 1
2557
+
2558
+ if parse_version(latest) <= parse_version(VERSION):
2559
+ print(f"{C.GREEN}{t('update_uptodate', version=VERSION)}{C.RESET}")
2560
+ return 0
2561
+
2562
+ print(f"{C.YELLOW}{C.BOLD}{t('update_available', current=VERSION, latest=latest)}{C.RESET}")
2563
+ install_cmd = ["npm", "install", "-g", f"{NPM_PACKAGE}@latest"]
2564
+ script = Path(__file__).resolve()
2565
+
2566
+ if "_npx" in script.parts:
2567
+ print(t("update_npx", cmd=f"npx {NPM_PACKAGE}@latest"))
2568
+ return 0
2569
+ try:
2570
+ global_pkg = (Path(npm_out("root", "-g")) / NPM_PACKAGE).resolve()
2571
+ except (OSError, RuntimeError, subprocess.TimeoutExpired):
2572
+ global_pkg = None
2573
+ if global_pkg is None or global_pkg not in script.parents:
2574
+ # Clone, dépendance locale ou installation par un autre gestionnaire : on n'écrase rien.
2575
+ print(t("update_manual", cmd=" ".join(install_cmd)))
2576
+ return 0
2577
+
2578
+ print(f"{C.GRAY}{t('update_running', cmd=' '.join(install_cmd))}{C.RESET}")
2579
+ code = subprocess.run([npm, *install_cmd[1:]]).returncode
2580
+ if code != 0:
2581
+ print(f"{C.RED}{t('update_failed', code=code)}{C.RESET}", file=sys.stderr)
2582
+ return code
2583
+ print(f"{C.GREEN}{C.BOLD}{t('update_done', latest=latest)}{C.RESET}")
2584
+ return 0
2585
+
2586
+
2375
2587
  # --- POINT D'ENTRÉE ---
2376
2588
  def main():
2377
2589
  # Détection précoce du paramètre --lang / -l pour adapter le message d'aide argparse
@@ -2381,9 +2593,12 @@ def main():
2381
2593
  break
2382
2594
 
2383
2595
  parser = argparse.ArgumentParser(
2596
+ prog="aiscout",
2384
2597
  description=t("cli_desc"),
2385
2598
  formatter_class=argparse.RawDescriptionHelpFormatter
2386
2599
  )
2600
+ parser.add_argument("command", nargs="?", choices=["update"], help=t("cli_help_update"))
2601
+ parser.add_argument("--version", "-V", action="version", version=f"aiscout {VERSION}", help=t("cli_help_version"))
2387
2602
  parser.add_argument("--scan", action="store_true", help=t("cli_help_scan"))
2388
2603
  parser.add_argument("--watch", action="store_true", help=t("cli_help_watch"))
2389
2604
  parser.add_argument("--restore", action="store_true", help=t("cli_help_restore"))
@@ -2399,6 +2614,9 @@ def main():
2399
2614
  if args.lang:
2400
2615
  set_lang(args.lang)
2401
2616
 
2617
+ if args.command == "update":
2618
+ sys.exit(run_update())
2619
+
2402
2620
  user_home = Path(args.home_dir) if args.home_dir else None
2403
2621
  engine = ScoutEngine(user_home=user_home)
2404
2622
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-secret-scout",
3
- "version": "2.4.4",
3
+ "version": "2.4.5",
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": {
@@ -23,7 +23,7 @@
23
23
  ],
24
24
  "scripts": {
25
25
  "start": "node ./bin/aiscout.js",
26
- "test": "node --test",
26
+ "test": "node --test \"test/*.test.js\"",
27
27
  "format": "biome format --write .",
28
28
  "lint": "biome lint .",
29
29
  "check": "biome check --write .",