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