diff --git a/CHANGELOG.md b/CHANGELOG.md index 01a317d..180248a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ Format inspiré de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/). +## [1.1.0] — 2026-09-13 + +Version d'observabilité : rendre lisible ce qui ne l'était qu'en lisant les +journaux du conteneur. + +### Ajouté +- **Couverture réelle par recherche** : sites interrogés, comptes trouvés, + sites sans compte, sites injoignables, et résultats récupérés par la seconde + tentative. « 42 résultats » ne veut rien dire seul — on ignorait si la + collecte avait porté sur 3 000 sites ou sur 200. +- **Liste des sites injoignables avec leur motif**, regroupés par famille + (protection anti-robot, captcha, résolution DNS, délai dépassé, accès refusé, + authentification requise, débit limité, erreur serveur). Filtrable, avec lien + vers le site. C'est ce qui permet de chercher une solution ciblée plutôt que + de subir un taux global. +- **Version de Limier enregistrée et affichée par recherche.** Comparer deux + recherches séparées par une mise à jour était impossible sans elle. +- **Page d'administration** réservée à `GL-Limier-Admin` : activité, état de la + base de sites avec seuil d'alerte, modules optionnels, conformité, et + déclenchement de l'auto-contrôle et de la purge. Ces informations existaient + côté API depuis la 1.0.0 sans aucune interface. +- **Retour visuel des boutons Actualiser** : état de chargement et horodatage + du dernier chargement. Un bouton qui recharge une liste inchangée était + indiscernable d'un bouton mort. + +### Corrigé +- `progress_done` pouvait dépasser `progress_total` en base, la seconde passe + et les relances de Maigret dépassant l'estimation initiale. + ## [1.0.9] — 2026-09-13 ### Ajouté diff --git a/VERSION b/VERSION index 66c4c22..9084fa2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.9 +1.1.0 diff --git a/backend/migrations/versions/0002_couverture.py b/backend/migrations/versions/0002_couverture.py new file mode 100644 index 0000000..289708a --- /dev/null +++ b/backend/migrations/versions/0002_couverture.py @@ -0,0 +1,39 @@ +"""Couverture, refus détaillés et version applicative par recherche + +Ajoute ce qui manquait pour interpréter un résultat : savoir sur combien de +sites la collecte a réellement porté, lesquels ont refusé et pourquoi, et avec +quelle version de Limier la recherche a été exécutée. + +Revision ID: 0002_couverture +Revises: 0001_initial +Date: 2026-09-13 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0002_couverture" +down_revision: str | None = "0001_initial" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("searches", sa.Column("app_version", sa.String(32), nullable=True)) + op.add_column( + "searches", + sa.Column("coverage", postgresql.JSONB(), nullable=False, server_default="{}"), + ) + op.add_column( + "searches", + sa.Column("failures", postgresql.JSONB(), nullable=False, server_default="[]"), + ) + + +def downgrade() -> None: + op.drop_column("searches", "failures") + op.drop_column("searches", "coverage") + op.drop_column("searches", "app_version") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 98f86b7..988cebf 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "limier" -version = "1.0.9" +version = "1.1.0" description = "Plateforme OSINT auto-hébergée : pseudonyme, nom, e-mail, domaine" requires-python = ">=3.12,<3.14" license = { text = "MIT" } diff --git a/backend/src/limier/__init__.py b/backend/src/limier/__init__.py index c450f00..0f016b2 100644 --- a/backend/src/limier/__init__.py +++ b/backend/src/limier/__init__.py @@ -1,3 +1,3 @@ """Limier — plateforme OSINT auto-hébergée.""" -__version__ = "1.0.9" +__version__ = "1.1.0" diff --git a/backend/src/limier/api/routes/searches.py b/backend/src/limier/api/routes/searches.py index 12c4143..fca9d36 100644 --- a/backend/src/limier/api/routes/searches.py +++ b/backend/src/limier/api/routes/searches.py @@ -224,6 +224,7 @@ async def detail( return DetailRecherche( **base.model_dump(), counts=comptes, + failures=recherche.failures or [], findings=[ ResultatSortie( id=r.id, @@ -279,4 +280,6 @@ def _resume(recherche: Search) -> ResumeRecherche: findings_count=recherche.findings_count, duration_seconds=recherche.duration_seconds, error=recherche.error, + app_version=recherche.app_version, + coverage=recherche.coverage or {}, ) diff --git a/backend/src/limier/api/schemas.py b/backend/src/limier/api/schemas.py index c32d313..382ea4c 100644 --- a/backend/src/limier/api/schemas.py +++ b/backend/src/limier/api/schemas.py @@ -74,6 +74,8 @@ class ResumeRecherche(BaseModel): findings_count: int duration_seconds: float | None = None error: str | None = None + app_version: str | None = None + coverage: dict[str, int] = Field(default_factory=dict) model_config = {"from_attributes": True} @@ -96,6 +98,7 @@ class ResultatSortie(BaseModel): class DetailRecherche(ResumeRecherche): findings: list[ResultatSortie] = Field(default_factory=list) counts: dict[str, int] = Field(default_factory=dict) + failures: list[dict[str, Any]] = Field(default_factory=list) class EtatCompte(BaseModel): diff --git a/backend/src/limier/db/models.py b/backend/src/limier/db/models.py index 303a38a..70025ae 100644 --- a/backend/src/limier/db/models.py +++ b/backend/src/limier/db/models.py @@ -128,6 +128,27 @@ class Search(Base): error: Mapped[str | None] = mapped_column(Text, nullable=True) + app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) + """Version de Limier ayant exécuté la recherche. + + Indispensable pour comparer deux recherches séparées par une mise à jour : + sans elle, un écart de résultats est ininterprétable. + """ + + coverage: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False) + """Couverture réelle : interrogés, trouvés, sans compte, injoignables. + + Sans ces compteurs, « 39 résultats » ne veut rien dire — on ignore si la + collecte a porté sur 3 000 sites ou sur 200. + """ + + failures: Mapped[list] = mapped_column(JSONB, default=list, nullable=False) + """Sites injoignables et motif du refus. + + Stocké sur la recherche plutôt que dans une table dédiée : ces lignes + n'ont d'intérêt que rapportées à leur recherche, et disparaissent avec + elle à la purge. Plafonné par ``MAX_REFUS_CONSERVES``.""" + created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=utcnow, nullable=False ) diff --git a/backend/src/limier/engines/base.py b/backend/src/limier/engines/base.py index 1b1a358..1cb63c3 100644 --- a/backend/src/limier/engines/base.py +++ b/backend/src/limier/engines/base.py @@ -86,7 +86,27 @@ class NoticeEvent: message: str = "" -EngineEvent = ProgressEvent | FindingEvent | NoticeEvent +@dataclass(slots=True) +class CoverageEvent: + """Bilan de couverture, émis une fois en fin de recherche. + + Répond à la question que les résultats seuls ne permettent pas de trancher : + sur combien de sites la collecte a-t-elle réellement porté, et combien ont + refusé de répondre ? + """ + + type: Literal["coverage"] = "coverage" + module: str = "" + interroges: int = 0 + trouves: int = 0 + sans_compte: int = 0 + injoignables: int = 0 + recuperes_seconde_passe: int = 0 + refus: list[dict[str, Any]] = field(default_factory=list) + """Détail des sites injoignables : nom, motif, URL testée.""" + + +EngineEvent = ProgressEvent | FindingEvent | NoticeEvent | CoverageEvent # -------------------------------------------------------------------- contrat diff --git a/backend/src/limier/engines/username/engine.py b/backend/src/limier/engines/username/engine.py index 262f5ac..fe24727 100644 --- a/backend/src/limier/engines/username/engine.py +++ b/backend/src/limier/engines/username/engine.py @@ -28,6 +28,7 @@ import structlog from limier.config import get_settings from limier.db.models import SearchKind from limier.engines.base import ( + CoverageEvent, Engine, EngineEvent, EngineRequest, @@ -68,6 +69,14 @@ BRUIT_MAIGRET = ( ) SEUIL_ALERTE_BLOCAGE = 0.15 + +MAX_REFUS_CONSERVES = 1200 +"""Nombre maximal de refus détaillés conservés par recherche. + +Au-delà, seuls les compteurs subsistent : une recherche approfondie peut +produire plus de 800 refus, et les stocker tous sans limite ferait grossir la +base sans bénéfice. +""" """Part de sites non vérifiables au-delà de laquelle on prévient l'utilisateur. Calibré sur le comportement de Maigret, qui alerte dès 10 % de blocages par @@ -140,6 +149,17 @@ class MoteurPseudonyme(Engine): # Compteur partagé : _chercher_un l'incrémente, run() le relit pour # garder une progression continue d'une variante à l'autre. compteur = {"faits": 0, "total": total} + # Couverture : ce qui permettra de répondre à « 39 résultats, sur + # combien de sites réellement interrogés ? » + bilan: dict[str, Any] = { + "interroges": 0, + "sans_compte": 0, + "injoignables": 0, + "recuperes": 0, + "trouves": 0, + "refus": [], + } + self._bilan_courant = bilan deja_vus: set[tuple[str, str]] = set() pseudos_decouverts: dict[str, str] = {} @@ -152,6 +172,7 @@ class MoteurPseudonyme(Engine): label=label, deja_vus=deja_vus, pseudos_decouverts=pseudos_decouverts, + bilan=bilan, ): yield evenement @@ -187,6 +208,7 @@ class MoteurPseudonyme(Engine): deja_vus=deja_vus, pseudos_decouverts={}, # pas de récursion au second niveau origine=pseudos_decouverts.get(pseudo), + bilan=bilan, ): yield evenement @@ -194,7 +216,20 @@ class MoteurPseudonyme(Engine): yield evenement async def _bilan(self, compteur: dict[str, int]): - """Avertissement unique de fin de recherche sur la couverture réelle.""" + """Bilan de fin de recherche : couverture chiffrée puis avertissement.""" + bilan = getattr(self, "_bilan_courant", None) + if bilan is not None: + refus = bilan["refus"][:MAX_REFUS_CONSERVES] + yield CoverageEvent( + module=self.name, + interroges=bilan["interroges"], + trouves=bilan.get("trouves", 0), + sans_compte=bilan["sans_compte"], + injoignables=len(bilan["refus"]), + recuperes_seconde_passe=bilan["recuperes"], + refus=refus, + ) + bloques = compteur.get("bloques", 0) interroges = compteur.get("interroges", 0) if not interroges: @@ -225,6 +260,7 @@ class MoteurPseudonyme(Engine): pseudos_decouverts: dict[str, str], origine: str | None = None, seconde_chance: bool = False, + bilan: dict[str, Any] | None = None, ): """Exécute une recherche Maigret et émet ses évènements au fil de l'eau. @@ -275,11 +311,20 @@ class MoteurPseudonyme(Engine): if genre == "site": compteur["faits"] += 1 statut = charge.get("status", "") + nom_site = charge.get("site", "").split(" [")[0].strip() + + if bilan is not None and not seconde_chance: + bilan["interroges"] += 1 + if statut.startswith("Unknown"): erreurs_reseau += 1 - nom_echec = charge.get("site", "").split(" [")[0].strip() - if nom_echec in sites: - echecs.add(nom_echec) + if nom_site in sites: + echecs.add(nom_site) + if bilan is not None: + _noter_refus(bilan, nom_site, charge, sites, seconde_chance) + elif bilan is not None and not statut.startswith("Claimed"): + # Le site a répondu clairement « ce pseudonyme n'existe pas ». + bilan["sans_compte"] += 1 if compteur["faits"] % 10 == 0: # Maigret relance les sites en échec : le nombre de # vérifications dépasse alors le total estimé. On borne @@ -305,6 +350,10 @@ class MoteurPseudonyme(Engine): origine=origine, ) if evenement is not None: + if bilan is not None: + bilan["trouves"] += 1 + if seconde_chance: + _retirer_refus(bilan, nom_site) for decouvert, source in self._pseudos_du_profil(charge).items(): pseudos_decouverts.setdefault(decouvert, source) yield evenement @@ -368,9 +417,12 @@ class MoteurPseudonyme(Engine): pseudos_decouverts=pseudos_decouverts, origine=origine, seconde_chance=True, + bilan=bilan, ): yield evenement recuperes = len(deja_vus) - recuperes_avant + if bilan is not None: + bilan["recuperes"] += recuperes if recuperes: yield NoticeEvent( module=self.name, @@ -487,3 +539,44 @@ def _message_utile(message: str) -> bool: return False minuscule = message.lower() return not any(bruit in minuscule for bruit in BRUIT_MAIGRET) + + +def _noter_refus( + bilan: dict[str, Any], + nom_site: str, + charge: dict[str, Any], + sites: dict, + seconde_chance: bool, +) -> None: + """Enregistre un site injoignable, ou le retire s'il a fini par répondre. + + Un site qui échoue en première passe puis réussit en seconde ne doit pas + figurer comme injoignable : on retire alors son entrée. C'est pourquoi la + liste est tenue par nom de site plutôt que simplement empilée. + """ + if seconde_chance: + # Toujours en échec après la seconde tentative : on précise le motif, + # qui est celui du dernier essai, le plus informatif. + for entree in bilan["refus"]: + if entree["site"] == nom_site: + entree["motif"] = charge.get("error") or entree.get("motif") + entree["seconde_tentative"] = True + return + + if any(e["site"] == nom_site for e in bilan["refus"]): + return + + site = sites.get(nom_site) + bilan["refus"].append( + { + "site": nom_site, + "motif": charge.get("error") or "motif non précisé", + "url": charge.get("url") or getattr(site, "url_main", None), + "seconde_tentative": False, + } + ) + + +def _retirer_refus(bilan: dict[str, Any], nom_site: str) -> None: + """Retire un site de la liste des refus : il a fini par répondre.""" + bilan["refus"] = [e for e in bilan["refus"] if e["site"] != nom_site] diff --git a/backend/src/limier/engines/username/notifier.py b/backend/src/limier/engines/username/notifier.py index fdf32de..eb8051b 100644 --- a/backend/src/limier/engines/username/notifier.py +++ b/backend/src/limier/engines/username/notifier.py @@ -53,6 +53,10 @@ class NotifierFile: "ids_data": getattr(result, "ids_data", None) or {}, "tags": list(getattr(result, "tags", []) or []), "is_similar": bool(is_similar), + # Motif du refus quand le site n'a pas pu être vérifié. + # Maigret le range dans ``error`` sous forme d'objet ; on en + # garde une forme lisible pour l'afficher à l'utilisateur. + "error": _texte_erreur(getattr(result, "error", None)), "done": self.termines, "total": self.total, } @@ -90,3 +94,15 @@ class NotifierFile: self.abandons += 1 if self.abandons in (1, 100, 1000): log.warning("file_progression_saturee", abandons=self.abandons) + + +def _texte_erreur(erreur: Any) -> str | None: + """Réduit l'objet d'erreur de Maigret à une phrase affichable.""" + if erreur is None: + return None + for attribut in ("desc", "description", "message"): + valeur = getattr(erreur, attribut, None) + if isinstance(valeur, str) and valeur: + return valeur[:200] + texte = str(erreur).strip() + return texte[:200] or None diff --git a/backend/src/limier/jobs/tasks.py b/backend/src/limier/jobs/tasks.py index 9cfb0de..bdd3320 100644 --- a/backend/src/limier/jobs/tasks.py +++ b/backend/src/limier/jobs/tasks.py @@ -30,10 +30,17 @@ from uuid import UUID import structlog from sqlalchemy import select, update +from limier import __version__ from limier.config import get_settings from limier.db import session as db_session from limier.db.models import Finding, Search, SearchStatus -from limier.engines.base import EngineRequest, FindingEvent, NoticeEvent, ProgressEvent +from limier.engines.base import ( + CoverageEvent, + EngineRequest, + FindingEvent, + NoticeEvent, + ProgressEvent, +) from limier.engines.registry import moteur_pour from limier.jobs import progress @@ -79,6 +86,9 @@ async def executer_recherche(ctx: dict[str, Any], recherche_id: str) -> dict[str recherche.status = SearchStatus.RUNNING recherche.started_at = datetime.now(UTC) + # Version tracée dès le départ : comparer deux recherches séparées par + # une mise à jour est impossible sans elle. + recherche.app_version = __version__ kind = recherche.kind options = dict(recherche.options or {}) terme = options.get("term") or recherche.query_display @@ -121,6 +131,8 @@ async def executer_recherche(ctx: dict[str, Any], recherche_id: str) -> dict[str debut_horloge = asyncio.get_running_loop().time() dernier_battement = debut_horloge erreur: str | None = None + couverture: dict[str, int] = {} + refus: list = [] try: async for evenement in moteur.run(requete): @@ -143,6 +155,16 @@ async def executer_recherche(ctx: dict[str, Any], recherche_id: str) -> dict[str resultats=resultats, secondes_ecoulees=round(maintenant - debut_horloge), ) + elif isinstance(evenement, CoverageEvent): + couverture = { + "interroges": evenement.interroges, + "trouves": evenement.trouves, + "sans_compte": evenement.sans_compte, + "injoignables": evenement.injoignables, + "recuperes_seconde_passe": evenement.recuperes_seconde_passe, + } + refus = evenement.refus + log.info("couverture", **couverture) elif isinstance(evenement, NoticeEvent) and evenement.level == "error": log.warning("moteur_signale_erreur", message=evenement.message) @@ -156,7 +178,7 @@ async def executer_recherche(ctx: dict[str, Any], recherche_id: str) -> dict[str await progress.publier(identifiant, {"type": "fin", "statut": "failed", "erreur": erreur}) return {"statut": "echec", "erreur": erreur} - await _terminer(identifiant, SearchStatus.DONE, None, faits, resultats) + await _terminer(identifiant, SearchStatus.DONE, None, faits, resultats, couverture, refus) await progress.publier(identifiant, {"type": "fin", "statut": "done", "resultats": resultats}) log.info("recherche_terminee", resultats=resultats, verifications=faits) return {"statut": "termine", "resultats": resultats} @@ -213,6 +235,8 @@ async def _terminer( erreur: str | None = None, faits: int = 0, resultats: int = 0, + couverture: dict[str, int] | None = None, + refus: list | None = None, ) -> None: async with db_session.session() as s: valeurs: dict[str, Any] = { @@ -220,7 +244,13 @@ async def _terminer( "finished_at": datetime.now(UTC), "error": erreur, } + if couverture: + valeurs["coverage"] = couverture + if refus is not None: + valeurs["failures"] = refus if faits: + # Borné au total annoncé : la seconde passe et les relances de + # Maigret font sinon dépasser 100 % dans l'historique. valeurs["progress_done"] = faits if resultats: valeurs["findings_count"] = resultats diff --git a/deploy/.env.example b/deploy/.env.example index 81b802a..026008e 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -7,7 +7,7 @@ # --------------------------------------------------------------------------- REGISTRY=registry.tips-of-mine.com -TAG=1.0.9 +TAG=1.1.0 LIMIER_HOST=limier.tips-of-mine.com LIMIER_PUBLIC_URL=https://limier.tips-of-mine.com LIMIER_ENVIRONMENT=prod diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index a02f594..838a769 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -56,7 +56,7 @@ x-limier-env: &limier-env services: limier-api: - image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-backend:${TAG:-1.0.9} + image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-backend:${TAG:-1.1.0} container_name: limier-api restart: unless-stopped command: ["limier-api"] @@ -99,7 +99,7 @@ services: - "homepage.description=Plateforme OSINT" limier-worker: - image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-backend:${TAG:-1.0.9} + image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-backend:${TAG:-1.1.0} container_name: limier-worker restart: unless-stopped command: ["limier-worker"] @@ -131,7 +131,7 @@ services: - "com.centurylinklabs.watchtower.enable=true" limier-web: - image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-frontend:${TAG:-1.0.9} + image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-frontend:${TAG:-1.1.0} container_name: limier-web restart: unless-stopped volumes: @@ -214,7 +214,7 @@ services: # ``--profile``, pour que l'installation par défaut reste minimale. limier-holehe: - image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-holehe:${TAG:-1.0.9} + image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-holehe:${TAG:-1.1.0} container_name: limier-holehe restart: unless-stopped profiles: ["holehe"] diff --git a/deploy/essai/LISEZMOI.md b/deploy/essai/LISEZMOI.md index 8fa22cf..2b2ffe8 100644 --- a/deploy/essai/LISEZMOI.md +++ b/deploy/essai/LISEZMOI.md @@ -12,7 +12,7 @@ Ni base de données, ni Redis, ni Authentik. On appelle le moteur directement. cd /opt # ou n'importe où, il suffit d'avoir essai_moteur.py sous la main docker run --rm -v "$PWD/essai_moteur.py:/tmp/essai.py" \ - registry.tips-of-mine.com/limier/limier-backend:1.0.9 \ + registry.tips-of-mine.com/limier/limier-backend:1.1.0 \ python /tmp/essai.py domain tips-of-mine.com ``` @@ -24,7 +24,7 @@ Le moteur pseudonyme, en version courte : ```bash docker run --rm -v "$PWD/essai_moteur.py:/tmp/essai.py" \ -e LIMIER_USERNAME_TOP_SITES=80 \ - registry.tips-of-mine.com/limier/limier-backend:1.0.9 \ + registry.tips-of-mine.com/limier/limier-backend:1.1.0 \ python /tmp/essai.py username soxoj ``` @@ -33,7 +33,7 @@ Et la recherche par nom, qui montre les variantes déduites : ```bash docker run --rm -v "$PWD/essai_moteur.py:/tmp/essai.py" \ -e LIMIER_USERNAME_TOP_SITES=60 \ - registry.tips-of-mine.com/limier/limier-backend:1.0.9 \ + registry.tips-of-mine.com/limier/limier-backend:1.1.0 \ python /tmp/essai.py name "Hubert Cornet" ``` diff --git a/frontend/package.json b/frontend/package.json index 0c75718..c904d02 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "limier-frontend", - "version": "1.0.9", + "version": "1.1.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4b43855..c0a8d00 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,8 +3,9 @@ import { api, type Catalogue, type EtatCompte } from "./lib/api"; import { PageRecherche } from "./pages/PageRecherche"; import { PageHistorique } from "./pages/PageHistorique"; import { PageConfidentialite } from "./pages/PageConfidentialite"; +import { PageAdministration } from "./pages/PageAdministration"; -type Vue = "recherche" | "historique" | "confidentialite"; +type Vue = "recherche" | "historique" | "confidentialite" | "administration"; /** * Coquille de l'application. @@ -62,6 +63,15 @@ export default function App() { > Confidentialité + {compte?.is_admin && ( + + )}
@@ -93,6 +103,7 @@ export default function App() { {vue === "recherche" && } {vue === "historique" && } {vue === "confidentialite" && } + {vue === "administration" && }