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