diff --git a/CHANGELOG.md b/CHANGELOG.md index 148eefa..ba1fea2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,43 @@ Format inspiré de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/). +## [1.0.8] — 2026-09-12 + +### Modifié +- **Les en-têtes de navigateur passent en seconde tentative.** La première + passe conserve le comportement natif de Maigret ; seuls les sites l'ayant + refusée sont rejoués en se présentant comme un navigateur. Le surcoût ne + porte donc que sur les échecs, et les sites qui répondent normalement ne sont + jamais sollicités deux fois. Le nombre de résultats récupérés par cette + seconde passe est annoncé dans l'interface. + +### Corrigé +- **Les en-têtes étaient écrits sur les objets de la base en cache**, donc de + façon permanente et partagée entre les recherches simultanées. La seconde + passe travaille désormais sur des copies. + +## [1.0.7] — 2026-09-12 + +### Ajouté +- **En-têtes de navigateur sur les requêtes sortantes.** Les sites qui ne + déclarent pas leurs propres en-têtes dans la base reçoivent désormais un jeu + complet — `Sec-Fetch-*`, `Upgrade-Insecure-Requests`, `DNT`, `Priority`, + User-Agent Firefox. Un client qui n'envoie qu'un User-Agent se distingue + immédiatement d'un navigateur. Désactivable par + `LIMIER_OUTBOUND_BROWSER_HEADERS=false`. + + Trois en-têtes du jeu Firefox complet sont écartés : `Host` est propre à + chaque requête, `Connection` et `TE` relèvent du transport. `Accept-Encoding` + omet `zstd`, qu'aiohttp ne sait pas décoder — l'annoncer reproduirait le + défaut Brotli corrigé en 1.0.3. + + Les sites déclarant déjà leurs en-têtes (LinkedIn et quelques autres) sont + laissés intacts : leurs valeurs ont été choisies pour eux. + +### Corrigé +- `LIMIER_OUTBOUND_USER_AGENT` ne s'appliquait qu'aux moteurs e-mail et domaine. + Il vaut désormais aussi pour le moteur pseudonyme. + ## [1.0.6] — 2026-09-12 ### Corrigé diff --git a/VERSION b/VERSION index af0b7dd..b0f3d96 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.6 +1.0.8 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 171ddd6..ce5a90d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "limier" -version = "1.0.6" +version = "1.0.8" 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 b541fdb..24a6ec6 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.6" +__version__ = "1.0.8" diff --git a/backend/src/limier/config.py b/backend/src/limier/config.py index 33492ad..d5cf8ae 100644 --- a/backend/src/limier/config.py +++ b/backend/src/limier/config.py @@ -124,10 +124,23 @@ class Settings(BaseSettings): """Ex. ``http://flaresolverr:8191``. Vide => bypass Cloudflare désactivé.""" outbound_user_agent: str = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/124.0 Safari/537.36" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:155.0) Gecko/20100101 Firefox/155.0" ) + outbound_browser_headers: bool = True + """Présenter un jeu d'en-têtes de navigateur sur les requêtes sortantes. + + Appliqué uniquement aux sites qui ne déclarent pas déjà leurs propres + en-têtes dans la base Maigret : LinkedIn, par exemple, garde les siens. + + Trois en-têtes du jeu Firefox complet sont volontairement écartés : + ``Host`` est propre à chaque requête et casserait toutes les autres, + ``Connection`` et ``TE`` sont gérés par la couche de transport et provoquent + des erreurs s'ils sont imposés. ``Accept-Encoding`` omet ``zstd``, qu'aiohttp + ne sait pas décoder ici — l'annoncer reproduirait le défaut Brotli corrigé + en 1.0.3. + """ + # ------------------------------------------------------------------- RGPD retention_days: int = 30 """Durée de conservation des recherches et de leurs résultats.""" diff --git a/backend/src/limier/engines/username/database.py b/backend/src/limier/engines/username/database.py index ea9dfff..a343148 100644 --- a/backend/src/limier/engines/username/database.py +++ b/backend/src/limier/engines/username/database.py @@ -124,6 +124,36 @@ def charger_base(forcer: bool = False): return db +def entetes_navigateur() -> dict[str, str]: + """Jeu d'en-têtes présenté aux sites qui n'en déclarent pas. + + Un client qui n'envoie qu'un User-Agent se distingue immédiatement d'un + navigateur : ce sont les en-têtes ``Sec-Fetch-*`` et ``Upgrade-Insecure- + Requests`` qu'un navigateur émet systématiquement et qu'un script omet + presque toujours. + + À tempérer : les protections modernes s'appuient d'abord sur l'empreinte + TLS, que ces en-têtes ne changent pas. Le gain réel est mesurable site par + site, il n'est pas garanti. + """ + from limier.config import get_settings + + return { + "User-Agent": get_settings().outbound_user_agent, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "fr,fr-FR;q=0.9,en-US;q=0.8,en;q=0.7", + "Accept-Encoding": "gzip, deflate, br", + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "DNT": "1", + "Sec-GPC": "1", + "Priority": "u=0, i", + } + + def selectionner_sites( *, top: int, tags: list[str] | None = None, id_type: str = "username" ) -> dict: @@ -143,6 +173,28 @@ def selectionner_sites( ) +def dupliquer_avec_entetes(sites: dict) -> dict: + """Copie des sites donnés, avec les en-têtes de navigateur ajoutés. + + **Duplication et non modification sur place.** Les objets rendus par + ``ranked_sites_dict`` proviennent de la base en cache, partagée par tous les + travaux du processus : les modifier contaminerait durablement les recherches + suivantes et celles qui tournent en parallèle. + + Les sites qui déclarent déjà leurs propres en-têtes dans la base sont + laissés intacts — leurs valeurs ont été choisies pour eux. + """ + from maigret.sites import MaigretSite + + entetes = entetes_navigateur() + copies = {} + for nom, site in sites.items(): + copie = MaigretSite(site.name, dict(site.json)) + copie.headers = dict(site.headers) if site.headers else dict(entetes) + copies[nom] = copie + return copies + + def statistiques() -> dict: """Résumé de la base, exposé par ``/api/catalogue``.""" db = charger_base() diff --git a/backend/src/limier/engines/username/engine.py b/backend/src/limier/engines/username/engine.py index eb17823..9d45501 100644 --- a/backend/src/limier/engines/username/engine.py +++ b/backend/src/limier/engines/username/engine.py @@ -224,6 +224,7 @@ class MoteurPseudonyme(Engine): deja_vus: set[tuple[str, str]], pseudos_decouverts: dict[str, str], origine: str | None = None, + seconde_chance: bool = False, ): """Exécute une recherche Maigret et émet ses évènements au fil de l'eau. @@ -258,6 +259,9 @@ class MoteurPseudonyme(Engine): ) erreurs_reseau = 0 + # Sites dont la vérification a échoué : ce sont eux, et eux seuls, que + # la seconde passe réinterrogera avec des en-têtes de navigateur. + echecs: set[str] = set() while True: if tache.done() and file.empty(): @@ -273,6 +277,9 @@ class MoteurPseudonyme(Engine): statut = charge.get("status", "") 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 compteur["faits"] % 10 == 0: # Maigret relance les sites en échec : le nombre de # vérifications dépasse alors le total estimé. On borne @@ -329,6 +336,44 @@ class MoteurPseudonyme(Engine): # Le bilan des blocages est cumulé puis émis une seule fois en fin de # recherche : le répéter par variante produisait cinq avertissements # quasi identiques pour une même cause. + # ------------------------------------------------------ seconde passe + # Stratégie en deux temps : on interroge d'abord chaque site avec le + # comportement natif de Maigret, puis on rejoue les seuls échecs en se + # présentant comme un navigateur. Le surcoût ne porte donc que sur les + # sites qui ont refusé — les autres ne sont jamais sollicités deux fois. + if not seconde_chance and self.settings.outbound_browser_headers and echecs: + a_rejouer = {n: s for n, s in sites.items() if n in echecs} + yield NoticeEvent( + module=self.name, + level="info", + message=( + f"Seconde tentative sur {len(a_rejouer)} site(s) en échec, " + "avec des en-têtes de navigateur." + ), + ) + # Le total inclut désormais cette seconde passe, sinon la barre de + # progression dépasserait son maximum. + compteur["total"] += len(a_rejouer) + recuperes_avant = len(deja_vus) + async for evenement in self._chercher_un( + pseudo=pseudo, + sites=database.dupliquer_avec_entetes(a_rejouer), + compteur=compteur, + label=f"{label} — seconde tentative", + deja_vus=deja_vus, + pseudos_decouverts=pseudos_decouverts, + origine=origine, + seconde_chance=True, + ): + yield evenement + recuperes = len(deja_vus) - recuperes_avant + if recuperes: + yield NoticeEvent( + module=self.name, + level="info", + message=(f"{recuperes} résultat(s) récupéré(s) grâce à la seconde tentative."), + ) + compteur["bloques"] = compteur.get("bloques", 0) + erreurs_reseau compteur["interroges"] = compteur.get("interroges", 0) + len(sites) diff --git a/deploy/.env.example b/deploy/.env.example index b857661..4d4b6f2 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -7,7 +7,7 @@ # --------------------------------------------------------------------------- REGISTRY=registry.tips-of-mine.com -TAG=1.0.6 +TAG=1.0.8 LIMIER_HOST=limier.tips-of-mine.com LIMIER_PUBLIC_URL=https://limier.tips-of-mine.com LIMIER_ENVIRONMENT=prod @@ -71,4 +71,9 @@ LIMIER_HIBP_API_KEY= # --- Divers ---------------------------------------------------------------- LIMIER_DNS_RESOLVERS=1.1.1.1,9.9.9.9 + +# Seconde tentative avec des en-têtes de navigateur sur les seuls sites ayant +# refusé la première requête. Mettre à false pour s'en tenir au comportement +# natif de Maigret et comparer les taux de blocage. +LIMIER_OUTBOUND_BROWSER_HEADERS=true LIMIER_WORKER_MAX_JOBS=4 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index ceb0585..9938fe3 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.6} + image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-backend:${TAG:-1.0.8} 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.6} + image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-backend:${TAG:-1.0.8} 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.6} + image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-frontend:${TAG:-1.0.8} 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.6} + image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-holehe:${TAG:-1.0.8} container_name: limier-holehe restart: unless-stopped profiles: ["holehe"] diff --git a/deploy/essai/LISEZMOI.md b/deploy/essai/LISEZMOI.md index f201b4c..03b2d8a 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.6 \ + registry.tips-of-mine.com/limier/limier-backend:1.0.8 \ 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.6 \ + registry.tips-of-mine.com/limier/limier-backend:1.0.8 \ 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.6 \ + registry.tips-of-mine.com/limier/limier-backend:1.0.8 \ python /tmp/essai.py name "Hubert Cornet" ``` diff --git a/frontend/package.json b/frontend/package.json index aa7e30a..2117804 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "limier-frontend", - "version": "1.0.6", + "version": "1.0.8", "private": true, "type": "module", "scripts": {