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 +16 -10
- package/README.md +16 -10
- package/ai_secret_scout.py +461 -243
- package/package.json +2 -2
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
package/ai_secret_scout.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
3
|
"""
|
|
4
4
|
===============================================================================
|
|
5
|
-
AI SECRET SCOUT (aiscout) —
|
|
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.
|
|
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)?
|
|
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
|
|
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",
|
|
661
|
-
"abcdef", "sk-ant-xxx", "ghp_xxx", "
|
|
662
|
-
"
|
|
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
|
|
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
|
|
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
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
|
|
999
|
-
|
|
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
|
-
|
|
1013
|
-
lower_t =
|
|
1121
|
+
val = text.strip().strip("'\"`")
|
|
1122
|
+
lower_t = val.lower()
|
|
1014
1123
|
|
|
1015
|
-
if len(
|
|
1124
|
+
if len(val) < 6:
|
|
1016
1125
|
return False
|
|
1017
1126
|
|
|
1018
|
-
if
|
|
1127
|
+
if val.startswith("$") or val.startswith("<") or val.startswith("{{") or val.startswith("[") or val.startswith("("):
|
|
1019
1128
|
return False
|
|
1020
|
-
if
|
|
1129
|
+
if val.endswith(">") or val.endswith("]") or val.endswith(")") or val.endswith("}"):
|
|
1021
1130
|
return False
|
|
1022
1131
|
|
|
1023
|
-
for
|
|
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_]+$",
|
|
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)$",
|
|
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(
|
|
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(
|
|
1046
|
-
entropy = calculate_entropy(
|
|
1158
|
+
classes = count_character_classes(val)
|
|
1159
|
+
entropy = calculate_entropy(val)
|
|
1047
1160
|
|
|
1048
1161
|
if classes == 1:
|
|
1049
|
-
if
|
|
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
|
|
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(
|
|
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
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
-
|
|
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.
|
|
1336
|
+
if not fpath.is_file() or str(fpath).endswith((".bak", ".backup")):
|
|
1163
1337
|
return []
|
|
1164
|
-
|
|
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.
|
|
1343
|
+
if not finding.secret_raw or finding.secret_raw == "[REDACTED_BY_AISCOUT]" or not target.is_file():
|
|
1201
1344
|
return False
|
|
1202
|
-
|
|
1203
|
-
if not backup_file.exists():
|
|
1204
|
-
shutil.copy2(target, backup_file)
|
|
1345
|
+
needle = finding.secret_raw.encode("utf-8")
|
|
1205
1346
|
try:
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|
|
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
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
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}
|
|
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, "
|
|
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, "
|
|
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"
|
|
1606
|
+
filts.append(f"{t('filter_tool_lbl')}: {self.active_filter_tool}")
|
|
1461
1607
|
if self.active_filter_project:
|
|
1462
|
-
filts.append(f"
|
|
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 = "
|
|
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"
|
|
1861
|
-
lines.append(self.center_line(f"
|
|
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, "
|
|
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}
|
|
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,
|
|
1905
|
-
act = f"{C.GREEN}(
|
|
1906
|
-
lines.append(f" [{i}] {
|
|
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("
|
|
1910
|
-
lines.append("
|
|
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, "
|
|
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}
|
|
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"
|
|
1943
|
-
lines.append(f"
|
|
1944
|
-
lines.append("
|
|
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, "
|
|
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}
|
|
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, "
|
|
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}
|
|
1980
|
-
lines.append(self.center_line(f"{C.WHITE}
|
|
1981
|
-
lines.append(self.center_line(f"{C.YELLOW}
|
|
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, "
|
|
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, "
|
|
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, "
|
|
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(
|
|
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(
|
|
2345
|
+
self.show_flash_message(t("bak_restored_one", name=target_bak.stem), C.GREEN)
|
|
2200
2346
|
else:
|
|
2201
|
-
self.show_flash_message("
|
|
2347
|
+
self.show_flash_message(t("bak_restore_failed"), C.RED)
|
|
2202
2348
|
elif key in ("a", "A"):
|
|
2203
|
-
if self.confirm_action(
|
|
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(
|
|
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(
|
|
2353
|
+
if self.confirm_action(t("bak_confirm_purge", count=len(backups))):
|
|
2208
2354
|
cnt = self.engine.clean_backups()
|
|
2209
|
-
self.show_flash_message(
|
|
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
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 .",
|