first sync
CI / Backend — lint, types, tests (push) Failing after 4m44s
CI / Interface — types et compilation (push) Successful in 10m23s
CI / Construction des images (sans publication) (push) Skipped

This commit is contained in:
hcornet committed 2026-09-12 13:10:16 +02:00
1 parent e563fa87d1
commit 78fb13b0e5
112 files changed
+10921 -413

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
[*.py]
indent_size = 4
max_line_length = 100
[*.{ts,tsx,js,jsx,json,css,html}]
indent_size = 2
[*.{yml,yaml}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
+125
View File
@@ -0,0 +1,125 @@
# Contrôle continu : à chaque poussée et sur chaque demande de fusion.
# Rapide et sans dépendance externe — aucune image n'est publiée ici.
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
PYTHON_VERSION: "3.12"
NODE_VERSION: "22"
jobs:
backend:
name: Backend — lint, types, tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
cache-dependency-path: backend/pyproject.toml
- name: Installation
working-directory: backend
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Lint (ruff)
working-directory: backend
run: ruff check src tests
- name: Format (ruff)
working-directory: backend
run: ruff format --check src tests
- name: Types (mypy)
working-directory: backend
# Non bloquant : le typage strict de SQLAlchemy et de Maigret produit
# du bruit qu'on ne veut pas traiter comme une régression.
continue-on-error: true
run: mypy src/limier
- name: Tests
working-directory: backend
env:
LIMIER_ENVIRONMENT: dev
LIMIER_SESSION_SECRET: secret-de-ci-suffisamment-long-pour-passer
LIMIER_IDENTIFIER_HASH_PEPPER: poivre-de-ci
run: pytest -q
- name: Cohérence des migrations
working-directory: backend
# Vérifie qu'aucun changement de modèle n'a été oublié dans une
# migration : c'est le défaut qui se découvre autrement en production.
env:
LIMIER_DATABASE_URL: postgresql+asyncpg://limier:limier@localhost:5432/limier
run: |
python - <<'PY'
from alembic.config import Config
from alembic.script import ScriptDirectory
cfg = Config("alembic.ini")
scripts = ScriptDirectory.from_config(cfg)
tetes = scripts.get_heads()
assert len(tetes) == 1, f"Plusieurs tetes de migration : {tetes}"
print("Tete de migration unique :", tetes[0])
PY
frontend:
name: Interface — types et compilation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Installation
working-directory: frontend
run: npm ci --no-audit --no-fund
- name: Vérification des types
working-directory: frontend
run: npm run lint
- name: Compilation
working-directory: frontend
run: npm run build
images:
name: Construction des images (sans publication)
runs-on: ubuntu-latest
needs: [backend, frontend]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Backend
uses: docker/build-push-action@v6
with:
context: ./backend
push: false
load: false
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Interface
uses: docker/build-push-action@v6
with:
context: ./frontend
push: false
load: false
cache-from: type=gha
cache-to: type=gha,mode=max
+67
View File
@@ -0,0 +1,67 @@
# Entretien planifié de la base de sites.
#
# Ce travail existe parce qu'une base de sites non entretenue se dégrade
# silencieusement : les plateformes changent leur HTML, la détection casse, et
# le scanner se met à produire des faux positifs sans que rien ne le signale.
#
# Le worker exécute déjà l'auto-contrôle chaque nuit à 3 h sur l'instance. Ce
# workflow fait la même chose dans la CI, sur la base versionnée du dépôt, et
# ouvre une demande de fusion quand la base amont a bougé — ce qui donne une
# trace et un contrôle humain avant mise en production.
name: Entretien de la base de sites
on:
schedule:
- cron: "0 4 * * 1" # tous les lundis à 4 h
workflow_dispatch:
jobs:
controler:
name: Auto-contrôle des sites
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Installation
working-directory: backend
run: pip install -e .
- name: Contrôle sans désactivation
id: controle
working-directory: backend
# --auto-disable n'est PAS utilisé ici : depuis un exécuteur de CI, les
# blocages géographiques et les IP de centre de données font échouer des
# sites parfaitement sains. On se contente de mesurer et d'alerter.
run: |
python - <<'PY' | tee rapport.txt
import asyncio, logging, os, sys
sys.path.insert(0, "src")
from limier.engines.username import database
stats = database.statistiques()
total = max(1, stats["total"])
taux = stats["desactives"] / total * 100
print(f"sites totaux : {stats['total']}")
print(f"sites actifs : {stats['actifs']}")
print(f"sites desactives : {stats['desactives']} ({taux:.1f} %)")
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
f.write(f"taux={taux:.1f}\n")
f.write(f"actifs={stats['actifs']}\n")
PY
- name: Alerte si la couverture se dégrade
if: ${{ steps.controle.outputs.taux != '' }}
run: |
TAUX="${{ steps.controle.outputs.taux }}"
echo "### Base de sites" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
cat backend/rapport.txt >> $GITHUB_STEP_SUMMARY
if [ "$(echo "$TAUX > 15" | bc -l)" = "1" ]; then
echo "::warning::Taux de sites desactives a ${TAUX} % — au-dela du seuil de 15 %."
fi
+97
View File
@@ -0,0 +1,97 @@
# Publication vers Harbor. Déclenchée par une étiquette v*, jamais par une
# poussée : une image publiée doit correspondre à une version nommée.
#
# Prérequis — secrets du dépôt Gitea :
# HARBOR_USERNAME, HARBOR_PASSWORD compte robot du projet Harbor « limier »
# Variable du dépôt :
# HARBOR_REGISTRY registry.tips-of-mine.com
name: Publication
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Étiquette à publier (ex. 1.0.0)"
required: true
jobs:
publier:
name: Images vers Harbor
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Détermination de la version
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "valeur=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
else
echo "valeur=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
fi
- name: Cohérence des versions
# Garde-fou : l'étiquette Git, pyproject.toml et package.json doivent
# annoncer la même version. Une divergence ici produit des images dont
# le /api/sante ment sur ce qui tourne.
run: |
V="${{ steps.version.outputs.valeur }}"
PY=$(grep -m1 '^version' backend/pyproject.toml | cut -d'"' -f2)
JS=$(node -p "require('./frontend/package.json').version")
echo "etiquette=$V pyproject=$PY package.json=$JS"
[ "$V" = "$PY" ] || { echo "::error::pyproject.toml annonce $PY"; exit 1; }
[ "$V" = "$JS" ] || { echo "::error::package.json annonce $JS"; exit 1; }
- uses: docker/setup-buildx-action@v3
- name: Connexion à Harbor
uses: docker/login-action@v3
with:
registry: ${{ vars.HARBOR_REGISTRY }}
username: ${{ secrets.HARBOR_USERNAME }}
password: ${{ secrets.HARBOR_PASSWORD }}
- name: Backend
uses: docker/build-push-action@v6
with:
context: ./backend
push: true
tags: |
${{ vars.HARBOR_REGISTRY }}/limier/limier-backend:${{ steps.version.outputs.valeur }}
${{ vars.HARBOR_REGISTRY }}/limier/limier-backend:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Interface
uses: docker/build-push-action@v6
with:
context: ./frontend
push: true
tags: |
${{ vars.HARBOR_REGISTRY }}/limier/limier-frontend:${{ steps.version.outputs.valeur }}
${{ vars.HARBOR_REGISTRY }}/limier/limier-frontend:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Passerelle holehe
# Image distincte, sous licence GPLv3 — voir services/holehe/LICENSE.
uses: docker/build-push-action@v6
with:
context: ./services/holehe
push: true
tags: |
${{ vars.HARBOR_REGISTRY }}/limier/limier-holehe:${{ steps.version.outputs.valeur }}
${{ vars.HARBOR_REGISTRY }}/limier/limier-holehe:latest
- name: Récapitulatif
run: |
echo "### Limier ${{ steps.version.outputs.valeur }} publié" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Mise à jour sur SLDOKP03 :" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "cd /opt/limier && TAG=${{ steps.version.outputs.valeur }} docker compose up -d" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
+30 -411
View File
@@ -1,416 +1,35 @@
# ---> VisualStudio
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
# Python
__pycache__/
*.pyc
*.py[cod]
*.egg-info/
.venv/
venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Node
node_modules/
dist/
*.tsbuildinfo
# Tabs Studio
*.tss
# Secrets et données locales — ne jamais versionner
.env
.env.*
!.env.example
deploy/.env
deploy/data/
*.pem
*.key
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
# Base de sites corrigée par l'auto-contrôle : régénérée, pas versionnée
data/sites/
backend/rapport.txt
# Outils
.DS_Store
.idea/
.vscode/
*.swp
+46
View File
@@ -0,0 +1,46 @@
# Journal des versions
Format inspiré de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/).
## [1.0.0] — 2026-09-12
Première version.
### Moteurs
- Recherche par **pseudonyme** sur la base Maigret (3 302 sites, 500 par défaut,
3 000 en approfondie), avec extraction des données de profil.
- Recherche par **nom civil** : génération de variantes ordonnées par
probabilité, traitées comme une identité unique.
- Recherche par **e-mail** : Gravatar (SHA-256), posture de messagerie,
détection des adresses jetables, fuites HIBP (optionnel), holehe (optionnel),
pivot vers le moteur pseudonyme.
- Recherche par **domaine** : RDAP avec bootstrap IANA, DNS complet,
sous-domaines par transparence des certificats avec séparation actifs /
historiques.
- **Notation** des résultats sur signaux observables, en trois niveaux, avec
restitution des signaux à l'utilisateur.
- Recherche récursive bornée sur les pseudonymes découverts dans les profils.
### Plateforme
- API FastAPI, worker arq, progression en direct par SSE avec reprise après
coupure.
- Authentification Authentik (OIDC, PKCE), session par cookie signé.
- Quotas mensuels par plan, limitation de débit horaire.
- Espace d'administration : compteurs, état des modules, du pool de proxies et
de la base de sites.
- Pool de proxies avec mise en quarantaine des proxies défaillants.
- Auto-contrôle nocturne de la base de sites.
- Métriques Prometheus, journal structuré JSON avec masquage des identifiants.
### Conformité
- Minimisation : conservation facultative du terme en clair.
- Purge automatique selon politique de conservation.
- Export des données (art. 20), demande d'effacement avec liste d'exclusion
(art. 17, HTTP 451), politique publiée reflétant la configuration réelle.
### Livraison
- Images Docker publiées sur Harbor par étiquette, avec contrôle de cohérence
des versions.
- CI : lint, types, tests, unicité de la tête de migration, construction des
images.
- Entretien hebdomadaire de la base de sites.
+23
View File
@@ -0,0 +1,23 @@
MIT License
Copyright (c) 2026 Hubert Cornet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Note : le répertoire services/holehe/ est distribué sous GPLv3 — voir NOTICE.md.
+73
View File
@@ -0,0 +1,73 @@
# Raccourcis de développement.
# La production passe par docker compose — voir docs/02-deploiement.md.
.DEFAULT_GOAL := aide
SHELL := /bin/bash
.PHONY: aide
aide: ## Affiche cette aide
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
# ------------------------------------------------------------------ backend
.PHONY: install
install: ## Installe le backend et l'interface en mode développement
cd backend && pip install -e ".[dev]"
cd frontend && npm ci --no-audit --no-fund
.PHONY: test
test: ## Lance les tests du backend
cd backend && LIMIER_ENVIRONMENT=dev \
LIMIER_SESSION_SECRET=secret-de-dev-suffisamment-long-pour-passer \
LIMIER_IDENTIFIER_HASH_PEPPER=poivre-de-dev \
pytest -q
.PHONY: lint
lint: ## Lint et vérification des types
cd backend && ruff check src tests && ruff format --check src tests
cd frontend && npm run lint
.PHONY: format
format: ## Reformate le code
cd backend && ruff format src tests && ruff check --fix src tests
.PHONY: api
api: ## Démarre l'API en local (port 8000)
cd backend && LIMIER_ENVIRONMENT=dev \
LIMIER_SESSION_SECRET=secret-de-dev-suffisamment-long-pour-passer \
LIMIER_IDENTIFIER_HASH_PEPPER=poivre-de-dev \
PYTHONPATH=src python -m limier.entrypoints.api
.PHONY: worker
worker: ## Démarre le worker en local
cd backend && LIMIER_ENVIRONMENT=dev \
LIMIER_SESSION_SECRET=secret-de-dev-suffisamment-long-pour-passer \
LIMIER_IDENTIFIER_HASH_PEPPER=poivre-de-dev \
PYTHONPATH=src python -m limier.entrypoints.worker
.PHONY: web
web: ## Démarre l'interface en local (port 5173, relaie /api vers 8000)
cd frontend && npm run dev
# --------------------------------------------------------------- migrations
.PHONY: migration
migration: ## Génère une migration — make migration M="ajout de X"
cd backend && alembic revision --autogenerate -m "$(M)"
.PHONY: migrer
migrer: ## Applique les migrations
cd backend && alembic upgrade head
# ------------------------------------------------------------------ images
.PHONY: images
images: ## Construit les images localement
docker build -t limier-backend:dev ./backend
docker build -t limier-frontend:dev ./frontend
docker build -t limier-holehe:dev ./services/holehe
.PHONY: verifier
verifier: lint test ## Tout ce que la CI vérifie
cd frontend && npm run build
+58
View File
@@ -0,0 +1,58 @@
# Composants tiers
Limier est distribué sous licence MIT (voir `LICENSE`).
## Dépendance principale
**Maigret** — https://github.com/soxoj/maigret — licence **MIT**.
Moteur de recherche par pseudonyme et base de 3 300+ sites. La licence MIT
autorise l'usage commercial sans restriction. Limier l'utilise comme
bibliothèque Python, sans modification.
Maigret embarque lui-même **socid_extractor** (même auteur, MIT) pour
l'extraction des données de profil.
## Composant sous GPLv3 — isolé
**holehe** — https://github.com/megadose/holehe — licence **GPLv3**.
holehe n'est **pas** une dépendance de Limier. Il vit dans un conteneur séparé
(`services/holehe/`) qui communique avec Limier exclusivement par HTTP, ce qui
en fait un **programme séparé** au sens de la GPL : sa licence s'applique à cette
image et au code de ce répertoire, pas au reste du projet.
Le répertoire `services/holehe/` est donc distribué sous GPLv3 (voir
`services/holehe/LICENSE`).
Cette isolation répond aussi à deux considérations pratiques : holehe n'a plus
de publication réelle depuis décembre 2023, et sa méthode — solliciter les
formulaires « mot de passe oublié » des plateformes — justifie de pouvoir
l'éteindre sans toucher au reste. Le module est désactivé par défaut.
## Sources de données interrogées
Aucune n'est redistribuée ; elles sont consultées à l'exécution.
| Source | Usage | Conditions |
| ------ | ----- | ---------- |
| Gravatar | profils publics | API publique, 100 req/h sans clé |
| IANA RDAP | bootstrap RFC 9224 | registre public |
| Serveurs RDAP des registres | enregistrement des domaines | RFC 9083 |
| crt.sh (Sectigo) | transparence des certificats | ressource communautaire gratuite |
| Certspotter (SSLMate) | idem, en secours | limité par IP sans compte |
| Have I Been Pwned | fuites connues | clé payante, optionnel |
crt.sh et Certspotter sont des ressources communautaires : ne pas les
solliciter plus que nécessaire. Le cache et la limitation de débit de Limier y
contribuent.
## Bibliothèques
Backend : FastAPI, Starlette, Uvicorn, SQLAlchemy, Alembic, Pydantic, arq,
redis-py, httpx, dnspython, joserfc, itsdangerous, structlog,
prometheus-client — toutes sous licences permissives (MIT, BSD, Apache 2.0).
Interface : React, Vite, TypeScript — MIT.
Images : `python:3.12-slim`, `node:22-alpine`, `nginx:1.27-alpine`,
`postgres:16-alpine`, `redis:7.4-alpine`.
+135 -2
View File
@@ -1,3 +1,136 @@
# limier
# Limier
Plateforme OSINT auto-hébergée — recherche par pseudonyme, nom, e-mail ou domaine, avec résultats notés et progression en direct.
Plateforme OSINT auto-hébergée : recherche d'informations publiquement
accessibles à partir d'un **pseudonyme**, d'un **prénom et nom**, d'une
**adresse e-mail** ou d'un **nom de domaine**.
Bâtie sur [Maigret](https://github.com/soxoj/maigret) pour la recherche par
pseudonyme, avec deux moteurs écrits pour l'occasion — Maigret ne couvre ni
l'e-mail ni le domaine.
Le nom est un clin d'œil : un limier suit une piste, et Maigret doit le sien au
commissaire de Simenon.
---
## Ce que ça fait
**Recherche par pseudonyme** sur 3 300+ sites, avec extraction des données de
profil : avatar, nom affiché, biographie, abonnés, date d'inscription.
**Recherche par nom civil.** « Hubert Cornet » est traité comme **une seule
identité** : les pseudonymes plausibles sont déduits par ordre de probabilité
(`hubertcornet`, `hubert.cornet`, `hcornet`, `h.cornet`…) puis recherchés
ensemble. La liste est affichée avant lancement.
**Recherche par e-mail** : profil Gravatar et comptes vérifiés qui y sont
déclarés, posture de messagerie du domaine (MX, SPF, DMARC), détection des
adresses jetables, fuites connues (optionnel), comptes rattachés (optionnel).
**Recherche par domaine** : enregistrement RDAP, DNS complet, et sous-domaines
issus des journaux de transparence des certificats, séparés entre ceux qui
résolvent encore et ceux qui sont historiques.
**Des résultats notés, pas des pastilles vertes.** Un scanner naïf considère
« trouvé » tout site répondant 200 — alors que beaucoup rendent 200 sur une page
« utilisateur inconnu ». Chaque résultat est jugé sur des signaux observables
(profil extrait, nom affiché, avatar, métriques, classement du site) puis classé
**confirmé / probable / faible**. Les signaux sont affichés : on voit *pourquoi*
un résultat est confirmé.
**Progression en direct.** Les résultats arrivent au fil de l'eau, site par
site, pas en bloc après deux minutes de sablier.
## Architecture
| Composant | Rôle |
| ----------------- | ------------------------------------------------ |
| `limier-web` | interface React compilée, servie par nginx |
| `limier-api` | FastAPI — acceptation, authentification, flux SSE|
| `limier-worker` | arq — exécution des recherches, travaux planifiés|
| `limier-postgres` | recherches, résultats, comptes, quotas |
| `limier-redis` | file de travaux et progression |
| `limier-holehe` | optionnel, isolé — licence GPLv3 |
| `limier-flaresolverr` | optionnel — contournement Cloudflare |
Détail dans [`docs/01-architecture.md`](docs/01-architecture.md).
## Arborescence
```
limier/
├── backend/ API + worker (une image, deux commandes)
│ ├── src/limier/
│ │ ├── config.py toutes les variables d'environnement
│ │ ├── api/routes/ une route = un fichier
│ │ ├── auth/ OIDC Authentik, session par cookie signé
│ │ ├── db/ modèles SQLAlchemy
│ │ ├── engines/ contrat commun + un dossier par moteur
│ │ │ ├── base.py le contrat
│ │ │ ├── scoring.py la notation anti-faux-positifs
│ │ │ ├── username/ Maigret
│ │ │ ├── email/ Gravatar, DNS, fuites, holehe, pivot
│ │ │ └── domain/ RDAP, DNS, transparence des certificats
│ │ ├── jobs/ file arq, progression, travaux
│ │ ├── net/ client HTTP, pool de proxies
│ │ ├── privacy/ hachage des identifiants, conservation
│ │ └── quotas/ quotas mensuels, limitation de débit
│ ├── migrations/ Alembic
│ └── tests/
├── frontend/ React 19 + Vite, sans framework CSS
├── services/holehe/ microservice isolé (GPLv3)
├── deploy/ compose, config Traefik, .env.example
├── docs/ sept documents, voir ci-dessous
└── .gitea/workflows/ CI, publication, entretien
```
## Documentation
| Fichier | Contenu |
| ------- | ------- |
| [`01-architecture.md`](docs/01-architecture.md) | découpe, contrat des moteurs, où chercher quoi |
| [`02-deploiement.md`](docs/02-deploiement.md) | installation sur SLDOKP03 |
| [`03-authentik.md`](docs/03-authentik.md) | groupes, fournisseur OAuth2, pièges |
| [`04-moteurs.md`](docs/04-moteurs.md) | sources, limites, notation |
| [`05-rgpd.md`](docs/05-rgpd.md) | conservation, effacement, ce qui reste à ta charge |
| [`06-exploitation.md`](docs/06-exploitation.md) | sortie réseau, entretien, pannes |
| [`07-api.md`](docs/07-api.md) | routes, flux SSE, codes de refus |
## Démarrage rapide
```bash
cp deploy/.env.example .env # renseigner les trois secrets
docker compose run --rm limier-api alembic upgrade head
docker compose up -d
curl -s https://limier.tips-of-mine.com/api/pret | jq
```
Développement :
```bash
cd backend && pip install -e ".[dev]" && pytest -q
cd frontend && npm ci && npm run dev
```
## Deux points à connaître avant d'ouvrir au public
**La sortie réseau.** Une recherche émet 500 requêtes vers 500 domaines en
quelques secondes. Depuis une IP unique, cela déclenche des blocages et le taux
de sites non vérifiables monte vite. Le pool de proxies est prévu pour ça — c'est
le poste de coût principal d'une instance publique. Voir
[`06-exploitation.md`](docs/06-exploitation.md).
**Le RGPD.** Un service public de recherche sur des personnes fait de toi un
responsable de traitement. Ce qui est implémenté : minimisation (le terme peut
n'être conservé que sous forme d'empreinte), purge automatique, export, demande
d'effacement avec liste d'exclusion. Ce qui reste à ta charge : base légale,
mentions légales, modération. Voir [`05-rgpd.md`](docs/05-rgpd.md).
## Licences
Limier est sous **licence MIT**. Voir [`NOTICE.md`](NOTICE.md) pour les
composants tiers — en particulier `services/holehe/`, sous **GPLv3**, maintenu
comme programme séparé précisément pour cette raison.
**Usage licite uniquement.** L'outil n'interroge que des sources publiques, mais
il ne distingue pas un usage légitime d'un harcèlement.
+1
View File
@@ -0,0 +1 @@
1.0.0
+71
View File
@@ -0,0 +1,71 @@
# Image unique pour l'API et le worker : même code, même base de sites, deux
# commandes. Deux images divergeraient à la première mise à jour oubliée.
#
# Construction en deux étapes : la couche de dépendances ne se reconstruit que
# lorsque pyproject.toml change, ce qui évite de retélécharger Maigret et ses
# 3 300 sites à chaque modification de code.
FROM python:3.12-slim-bookworm AS base
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
TZ=Europe/Paris
# tzdata : l'image slim n'embarque aucune base de fuseaux, la variable TZ
# resterait sans effet et tous les horodatages sortiraient en UTC.
RUN apt-get update \
&& apt-get install -y --no-install-recommends tzdata ca-certificates curl \
&& ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
&& rm -rf /var/lib/apt/lists/*
# ------------------------------------------------------------------ build
FROM base AS build
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY pyproject.toml ./
COPY src/limier/__init__.py src/limier/__init__.py
# Installation des dépendances seules, dans un préfixe isolé et copiable.
RUN pip install --prefix=/opt/limier --no-warn-script-location \
"$(python - <<'PY'
import tomllib, pathlib
d = tomllib.loads(pathlib.Path("pyproject.toml").read_text())
print(" ".join(d["project"]["dependencies"]))
PY
)"
# ---------------------------------------------------------------- runtime
FROM base AS runtime
COPY --from=build /opt/limier /usr/local
WORKDIR /app
COPY pyproject.toml alembic.ini ./
COPY migrations ./migrations
COPY src ./src
RUN pip install --no-deps -e . \
&& groupadd -r limier -g 1000 \
&& useradd -r -u 1000 -g limier -d /app -s /sbin/nologin limier \
&& mkdir -p /data/sites \
&& chown -R limier:limier /app /data
USER limier
# Base de sites inscriptible : l'auto-contrôle nocturne y écrit la version
# corrigée. Le paquet Maigret, lui, est en lecture seule.
ENV LIMIER_SITES_DB_PATH=/data/sites/data.json \
PYTHONPATH=/app/src
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD curl -fsS http://127.0.0.1:8000/api/sante || exit 1
CMD ["limier-api"]
+41
View File
@@ -0,0 +1,41 @@
[alembic]
script_location = migrations
prepend_sys_path = src
path_separator = os
# L'URL réelle est injectée par migrations/env.py depuis LIMIER_DATABASE_URL :
# la laisser ici mettrait un mot de passe dans le dépôt.
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+68
View File
@@ -0,0 +1,68 @@
"""Environnement Alembic.
L'URL de connexion vient de la configuration applicative, jamais de
``alembic.ini`` : un seul endroit à renseigner, et aucun mot de passe versionné.
Le pilote est basculé de ``asyncpg`` vers ``psycopg`` n'est pas nécessaire ici :
on exécute les migrations dans un moteur asynchrone, comme le reste de
l'application.
"""
from __future__ import annotations
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy.ext.asyncio import async_engine_from_config
from sqlalchemy import pool
from limier.config import get_settings
from limier.db.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", str(get_settings().database_url))
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def _appliquer(connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
moteur = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with moteur.connect() as connexion:
await connexion.run_sync(_appliquer)
await moteur.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Date: ${create_date}
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: str | None = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+165
View File
@@ -0,0 +1,165 @@
"""Schéma initial de Limier 1.0.0
Revision ID: 0001_initial
Revises:
Date: 2026-09-12
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0001_initial"
down_revision: str | None = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Les types énumérés sont créés explicitement : laisser SQLAlchemy les créer
# implicitement par table les rend impossibles à supprimer proprement au
# downgrade, et provoque un « type already exists » si une table est
# recréée.
search_kind = postgresql.ENUM(
"USERNAME", "NAME", "EMAIL", "DOMAIN", name="search_kind", create_type=False
)
search_status = postgresql.ENUM(
"QUEUED", "RUNNING", "DONE", "FAILED", "CANCELLED",
name="search_status", create_type=False,
)
finding_confidence = postgresql.ENUM(
"CONFIRMED", "PROBABLE", "WEAK", name="finding_confidence", create_type=False
)
search_kind.create(op.get_bind(), checkfirst=True)
search_status.create(op.get_bind(), checkfirst=True)
finding_confidence.create(op.get_bind(), checkfirst=True)
op.create_table(
"users",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("subject", sa.String(255), nullable=False),
sa.Column("email", sa.String(320), nullable=True),
sa.Column("display_name", sa.String(255), nullable=True),
sa.Column("plan", sa.String(32), nullable=False, server_default="gratuit"),
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("is_blocked", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
)
op.create_index("ix_users_subject", "users", ["subject"], unique=True)
op.create_table(
"searches",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True),
sa.Column("kind", search_kind, nullable=False),
sa.Column("status", search_status, nullable=False, server_default="QUEUED"),
sa.Column("query_display", sa.String(512), nullable=True),
sa.Column("query_hash", sa.String(64), nullable=False),
sa.Column("options", postgresql.JSONB(), nullable=False, server_default="{}"),
sa.Column("progress_total", sa.Integer(), nullable=False, server_default="0"),
sa.Column("progress_done", sa.Integer(), nullable=False, server_default="0"),
sa.Column("findings_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_searches_user_id", "searches", ["user_id"])
op.create_index("ix_searches_query_hash", "searches", ["query_hash"])
op.create_index("ix_searches_user_created", "searches", ["user_id", "created_at"])
# Index dédié à la purge : sans lui, le travail horaire balaie toute la table.
op.create_index("ix_searches_expires", "searches", ["expires_at"])
op.create_table(
"findings",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("search_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("searches.id", ondelete="CASCADE"), nullable=False),
sa.Column("module", sa.String(64), nullable=False),
sa.Column("source", sa.String(128), nullable=False),
sa.Column("subject", sa.String(512), nullable=False),
sa.Column("url", sa.Text(), nullable=True),
sa.Column("confidence", finding_confidence, nullable=False),
sa.Column("score", sa.Float(), nullable=False, server_default="0"),
sa.Column("signals", postgresql.JSONB(), nullable=False, server_default="{}"),
sa.Column("profile", postgresql.JSONB(), nullable=False, server_default="{}"),
sa.Column("tags", postgresql.JSONB(), nullable=False, server_default="[]"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
sa.UniqueConstraint("search_id", "module", "source", "subject",
name="uq_finding_identity"),
)
op.create_index("ix_findings_search_id", "findings", ["search_id"])
op.create_index("ix_findings_search_confidence", "findings", ["search_id", "confidence"])
op.create_table(
"quota_ledger",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("period", sa.String(7), nullable=False),
sa.Column("used", sa.Integer(), nullable=False, server_default="0"),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
sa.UniqueConstraint("user_id", "period", name="uq_quota_user_period"),
)
op.create_index("ix_quota_ledger_user_id", "quota_ledger", ["user_id"])
op.create_table(
"suppression_requests",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("identifier_hash", sa.String(64), nullable=False),
sa.Column("identifier_display", sa.String(512), nullable=True),
sa.Column("contact_email", sa.String(320), nullable=True),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("handled", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("handled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_rows", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
)
op.create_index("ix_suppression_requests_hash", "suppression_requests",
["identifier_hash"])
op.create_table(
"blocked_identifiers",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("identifier_hash", sa.String(64), nullable=False, unique=True),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
)
op.create_table(
"audit_events",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("action", sa.String(64), nullable=False),
sa.Column("detail", postgresql.JSONB(), nullable=False, server_default="{}"),
sa.Column("client_ip", sa.String(64), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
)
op.create_index("ix_audit_created", "audit_events", ["created_at"])
def downgrade() -> None:
op.drop_table("audit_events")
op.drop_table("blocked_identifiers")
op.drop_table("suppression_requests")
op.drop_table("quota_ledger")
op.drop_table("findings")
op.drop_table("searches")
op.drop_table("users")
for nom in ("finding_confidence", "search_status", "search_kind"):
op.execute(f"DROP TYPE IF EXISTS {nom}")
+80
View File
@@ -0,0 +1,80 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "limier"
version = "1.0.0"
description = "Plateforme OSINT auto-hébergée : pseudonyme, nom, e-mail, domaine"
readme = "../README.md"
requires-python = ">=3.12,<3.14"
license = { text = "MIT" }
# Versions figées et vérifiées le 12/09/2026.
# Contrainte à connaître : arq impose redis<6 (redis[hiredis]>=4.2,<6).
# Monter redis en 6.x ou 8.x casse l'installation — voir docs/06-exploitation.md.
dependencies = [
"fastapi==0.141.1",
"uvicorn[standard]==0.52.4",
"arq==0.28.0",
"redis[hiredis]==5.3.1",
"sqlalchemy[asyncio]==2.0.52",
"asyncpg==0.31.0",
"alembic==1.20.0",
"pydantic==2.13.5",
"pydantic-settings==2.15.0",
"httpx==0.28.1",
"dnspython==2.8.0",
"joserfc==1.7.5",
"itsdangerous==2.2.0",
"structlog==26.1.0",
"prometheus-client==0.26.0",
"maigret==0.6.5",
]
[project.optional-dependencies]
dev = [
"pytest==9.1.1",
"pytest-asyncio==1.4.0",
"ruff==0.16.7",
"mypy==2.3.1",
"aiosqlite==0.21.0",
]
[project.scripts]
limier-api = "limier.entrypoints.api:main"
limier-worker = "limier.entrypoints.worker:main"
[tool.hatch.build.targets.wheel]
packages = ["src/limier"]
[tool.ruff]
line-length = 100
target-version = "py312"
src = ["src", "tests"]
[tool.ruff.lint]
select = ["E", "F", "B", "I", "UP", "C4", "SIM", "RUF"]
ignore = [
"E501", # la longueur est gérée par le formateur
"RUF001", # les guillemets français « » sont voulus
"RUF002",
"RUF003",
"B008", # Depends() en valeur par défaut : c'est l'idiome FastAPI
"UP042", # str+Enum volontaire : StrEnum change la sérialisation attendue
# par le type Enum de SQLAlchemy et par Pydantic
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
filterwarnings = ["ignore::DeprecationWarning"]
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
warn_unused_ignores = true
plugins = []
+3
View File
@@ -0,0 +1,3 @@
"""Limier — plateforme OSINT auto-hébergée."""
__version__ = "1.0.0"
View File
Whitespace-only changes.
+127
View File
@@ -0,0 +1,127 @@
"""Fabrique de l'application FastAPI.
L'API est montée sous ``/api``. Le frontend est servi par un conteneur nginx
distinct : l'API ne sert aucun fichier statique, ce qui garde les deux cycles de
vie indépendants et permet de reconstruire l'interface sans toucher au backend.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
import structlog
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, Response
from starlette.middleware.cors import CORSMiddleware
from limier import __version__
from limier.api.routes import admin, auth, catalog, health, privacy, searches, stream
from limier.config import get_settings
from limier.logging import configurer as configurer_journal
log = structlog.get_logger(__name__)
@asynccontextmanager
async def cycle_de_vie(app: FastAPI):
settings = get_settings()
problemes = settings.check_production_readiness()
if problemes:
for probleme in problemes:
log.error("configuration_incomplete", probleme=probleme)
raise RuntimeError("Configuration de production incomplète : " + " ; ".join(problemes))
log.info(
"api_demarree",
version=__version__,
environnement=settings.environment,
url=settings.public_url,
)
yield
from limier.db.session import fermer as fermer_base
from limier.jobs.queue import fermer_redis
from limier.net.http import close_client
await close_client()
await fermer_redis()
await fermer_base()
log.info("api_arretee")
def creer_application() -> FastAPI:
configurer_journal()
settings = get_settings()
app = FastAPI(
title="Limier",
version=__version__,
description=(
"Plateforme OSINT auto-hébergée : recherche par pseudonyme, nom, "
"adresse électronique ou nom de domaine."
),
docs_url=f"{settings.api_prefix}/docs",
openapi_url=f"{settings.api_prefix}/openapi.json",
redoc_url=None,
lifespan=cycle_de_vie,
)
# En production, l'interface et l'API partagent la même origine derrière
# Traefik : CORS n'a rien à autoriser. En développement, Vite sert
# l'interface sur un autre port.
if not settings.is_prod:
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
prefixe = settings.api_prefix
app.include_router(health.routeur, prefix=prefixe)
app.include_router(auth.routeur, prefix=prefixe)
app.include_router(searches.routeur, prefix=prefixe)
app.include_router(stream.routeur, prefix=prefixe)
app.include_router(catalog.routeur, prefix=prefixe)
app.include_router(privacy.routeur, prefix=prefixe)
app.include_router(admin.routeur, prefix=prefixe)
if settings.metrics_enabled:
@app.get(f"{prefixe}/metriques", include_in_schema=False)
async def metriques() -> Response:
from limier.observability.metrics import rendu
return Response(rendu(), media_type="text/plain; version=0.0.4")
@app.exception_handler(RequestValidationError)
async def erreur_validation(requete: Request, exc: RequestValidationError) -> JSONResponse:
"""Message de validation lisible, sans recopier la valeur fautive.
Le comportement par défaut de FastAPI renvoie l'entrée de l'utilisateur
dans la réponse ; s'agissant d'identifiants recherchés, on s'en abstient.
"""
details = []
for erreur in exc.errors():
champ = ".".join(str(p) for p in erreur.get("loc", []) if p != "body")
details.append(f"{champ or 'requête'} : {erreur.get('msg', 'valeur invalide')}")
return JSONResponse(
status_code=422,
content={"detail": " ; ".join(details), "code": "requete_invalide"},
)
@app.middleware("http")
async def contexte_journal(requete: Request, suite):
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(chemin=requete.url.path, methode=requete.method)
reponse = await suite(requete)
reponse.headers["X-Content-Type-Options"] = "nosniff"
reponse.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
return reponse
return app
app = creer_application()
+88
View File
@@ -0,0 +1,88 @@
"""Dépendances FastAPI : session base, utilisateur courant, contrôle des droits."""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Annotated
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from limier.auth import session as session_cookie
from limier.db.models import User
from limier.db.session import fabrique_session
async def obtenir_session() -> AsyncIterator[AsyncSession]:
async with fabrique_session()() as s:
try:
yield s
await s.commit()
except Exception:
await s.rollback()
raise
SessionBase = Annotated[AsyncSession, Depends(obtenir_session)]
def ip_client(requete: Request) -> str:
"""IP réelle du visiteur derrière le tunnel Cloudflare et Traefik.
cloudflared ne transmet que ``CF-Connecting-IP`` et n'ajoute pas de
``X-Forwarded-For`` — c'est la raison d'être du greffon cloudflarewarp côté
Traefik. On lit les deux en-têtes, dans cet ordre, et on retombe sur
l'adresse du socket si aucun n'est présent.
"""
for entete in ("cf-connecting-ip", "x-forwarded-for", "x-real-ip"):
valeur = requete.headers.get(entete)
if valeur:
return valeur.split(",")[0].strip()
return requete.client.host if requete.client else "inconnue"
async def utilisateur_optionnel(requete: Request, s: SessionBase) -> User | None:
donnees = session_cookie.lire(requete)
if not donnees:
return None
identifiant = donnees.get("uid")
if not identifiant:
return None
from uuid import UUID
try:
utilisateur = await s.get(User, UUID(identifiant))
except (ValueError, TypeError):
return None
if utilisateur is None or utilisateur.is_blocked:
return None
return utilisateur
UtilisateurOptionnel = Annotated[User | None, Depends(utilisateur_optionnel)]
async def utilisateur_requis(utilisateur: UtilisateurOptionnel) -> User:
if utilisateur is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Connexion requise.",
headers={"X-Limier-Code": "authentification_requise"},
)
return utilisateur
UtilisateurConnecte = Annotated[User, Depends(utilisateur_requis)]
async def administrateur_requis(utilisateur: UtilisateurConnecte) -> User:
if not utilisateur.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Droits d'administration requis.",
headers={"X-Limier-Code": "droits_insuffisants"},
)
return utilisateur
Administrateur = Annotated[User, Depends(administrateur_requis)]
Whitespace-only changes.
+247
View File
@@ -0,0 +1,247 @@
"""Espace d'administration, réservé au groupe ``GL-Limier-Admin``.
Périmètre volontairement restreint : exploitation et conformité. On n'y lit
jamais les recherches d'autrui — ce serait contredire la politique de
minimisation annoncée aux visiteurs. Ce qui est exposé, ce sont des compteurs,
l'état des dépendances et le traitement des demandes d'effacement.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from uuid import UUID
import structlog
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import func, select
from limier.api.deps import Administrateur, SessionBase
from limier.api.schemas import ReponseSimple
from limier.config import get_settings
from limier.db.models import QuotaLedger, Search, SearchStatus, SuppressionRequest, User
from limier.net.proxy_pool import PoolProxies
from limier.privacy import retention
log = structlog.get_logger(__name__)
routeur = APIRouter(prefix="/admin", tags=["administration"])
@routeur.get("/tableau-de-bord")
async def tableau_de_bord(admin: Administrateur, s: SessionBase) -> dict:
depuis = datetime.now(UTC) - timedelta(days=7)
utilisateurs = await s.scalar(select(func.count()).select_from(User))
recherches_7j = await s.scalar(
select(func.count()).select_from(Search).where(Search.created_at >= depuis)
)
par_statut = await s.execute(
select(Search.status, func.count())
.where(Search.created_at >= depuis)
.group_by(Search.status)
)
par_type = await s.execute(
select(Search.kind, func.count()).where(Search.created_at >= depuis).group_by(Search.kind)
)
duree_moyenne = await s.scalar(
select(
func.avg(
func.extract("epoch", Search.finished_at) - func.extract("epoch", Search.started_at)
)
).where(Search.status == SearchStatus.DONE, Search.created_at >= depuis)
)
return {
"utilisateurs": utilisateurs or 0,
"recherches_7_jours": recherches_7j or 0,
"par_statut": {k.value: v for k, v in par_statut.all()},
"par_type": {k.value: v for k, v in par_type.all()},
"duree_moyenne_secondes": round(float(duree_moyenne), 1) if duree_moyenne else None,
"conformite": await retention.resume(),
}
@routeur.get("/sites")
async def etat_sites(admin: Administrateur) -> dict:
"""Etat de la base de sites.
Le taux de sites desactives est l'indicateur a surveiller : au-dela de 15 %,
la couverture se degrade et il faut relancer l'auto-controle ou mettre la
base a jour.
"""
settings = get_settings()
if not settings.engine_username_enabled:
return {"actif": False}
from limier.engines.username import database
stats = database.statistiques()
total = max(1, stats["total"])
return {
"actif": True,
**stats,
"tags": dict(list(stats["tags"].items())[:30]),
"taux_desactives": round(stats["desactives"] / total * 100, 1),
}
@routeur.post("/sites/auto-controle")
async def lancer_auto_controle(admin: Administrateur) -> dict:
"""Declenche l'auto-controle a la demande.
Long — plusieurs minutes sur la base complete — donc mis en file plutot
qu'execute dans la requete.
"""
from limier.jobs import queue
travail = await queue.empiler("auto_controle_sites", True)
if travail is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="File de travaux indisponible.",
)
return {"ok": True, "travail": travail}
@routeur.get("/proxies")
async def etat_proxies(admin: Administrateur) -> dict:
pool = PoolProxies()
return {
"actif": pool.actif,
"strategie": get_settings().proxy_strategy,
"proxies": await pool.etat(),
}
@routeur.get("/modules")
async def etat_modules(admin: Administrateur) -> dict:
from limier.engines.email import breaches, holehe_client
return {
"comptes_lies": await holehe_client.sante(),
"fuites": {"actif": breaches.actif()},
"contournement_cloudflare": {"actif": bool(get_settings().flaresolverr_url)},
}
# ------------------------------------------------------- demandes d'effacement
@routeur.get("/effacements")
async def lister_effacements(
admin: Administrateur, s: SessionBase, traitees: bool = False
) -> list[dict]:
resultat = await s.execute(
select(SuppressionRequest)
.where(SuppressionRequest.handled.is_(traitees))
.order_by(SuppressionRequest.created_at.desc())
.limit(100)
)
return [
{
"id": str(d.id),
"identifiant": d.identifier_display,
"contact": d.contact_email,
"motif": d.reason,
"recue_le": d.created_at.isoformat(),
"traitee": d.handled,
"lignes_supprimees": d.deleted_rows,
}
for d in resultat.scalars().all()
]
@routeur.post("/effacements/{demande_id}/appliquer", response_model=ReponseSimple)
async def appliquer_effacement(
demande_id: UUID, admin: Administrateur, s: SessionBase, bloquer: bool = True
) -> ReponseSimple:
demande = await s.get(SuppressionRequest, demande_id)
if demande is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demande introuvable.")
if demande.handled:
return ReponseSimple(ok=True, message="Demande deja traitee.")
supprimees = await retention.effacer_identifiant(demande.identifier_hash, bloquer=bloquer)
demande.handled = True
demande.handled_at = datetime.now(UTC)
demande.deleted_rows = supprimees
# Le terme en clair n'a plus de raison d'etre conserve une fois la demande
# appliquee : l'empreinte suffit a faire respecter le blocage.
demande.identifier_display = None
log.info("effacement_traite", recherches=supprimees, bloque=bloquer)
return ReponseSimple(ok=True, message=f"{supprimees} recherche(s) supprimee(s).")
@routeur.post("/purge", response_model=ReponseSimple)
async def purge_manuelle(admin: Administrateur) -> ReponseSimple:
supprimees = await retention.purger_expirees()
return ReponseSimple(ok=True, message=f"{supprimees} recherche(s) expiree(s) supprimee(s).")
# --------------------------------------------------------------- utilisateurs
@routeur.get("/utilisateurs")
async def lister_utilisateurs(admin: Administrateur, s: SessionBase) -> list[dict]:
from limier.quotas.service import periode_courante
periode = periode_courante()
resultat = await s.execute(
select(User, QuotaLedger.used)
.outerjoin(
QuotaLedger,
(QuotaLedger.user_id == User.id) & (QuotaLedger.period == periode),
)
.order_by(User.created_at.desc())
.limit(200)
)
return [
{
"id": str(u.id),
"nom": u.display_name,
"email": u.email,
"plan": u.plan,
"admin": u.is_admin,
"bloque": u.is_blocked,
"consomme_ce_mois": consomme or 0,
"derniere_visite": u.last_seen_at.isoformat(),
}
for u, consomme in resultat.all()
]
@routeur.post("/utilisateurs/{utilisateur_id}/plan", response_model=ReponseSimple)
async def changer_plan(
utilisateur_id: UUID, plan: str, admin: Administrateur, s: SessionBase
) -> ReponseSimple:
settings = get_settings()
if plan not in settings.plan_quotas:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Plan inconnu. Plans definis : {', '.join(settings.plan_quotas)}.",
)
utilisateur = await s.get(User, utilisateur_id)
if utilisateur is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Utilisateur introuvable."
)
utilisateur.plan = plan
return ReponseSimple(ok=True, message=f"Plan « {plan} » applique.")
@routeur.post("/utilisateurs/{utilisateur_id}/blocage", response_model=ReponseSimple)
async def basculer_blocage(
utilisateur_id: UUID, bloque: bool, admin: Administrateur, s: SessionBase
) -> ReponseSimple:
utilisateur = await s.get(User, utilisateur_id)
if utilisateur is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Utilisateur introuvable."
)
if utilisateur.id == admin.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Impossible de se bloquer soi-meme.",
)
utilisateur.is_blocked = bloque
return ReponseSimple(ok=True, message="Blocage applique." if bloque else "Blocage leve.")
+222
View File
@@ -0,0 +1,222 @@
"""Routes d'authentification et de compte.
Parcours complet :
1. ``GET /api/auth/connexion`` — pose un cookie de transit (état + vérificateur
PKCE) et redirige vers Authentik.
2. ``GET /api/auth/retour`` — reçoit le code, vérifie l'état, échange le code,
valide le jeton d'identité, crée ou met à jour l'utilisateur, ouvre la
session, redirige vers l'interface.
3. ``POST /api/auth/deconnexion`` — ferme la session locale et, si Authentik
l'expose, renvoie l'URL de fermeture de session côté fournisseur.
L'appartenance aux groupes est relue **à chaque connexion** : retirer quelqu'un
de ``GL-Limier-Admin`` dans Authentik lui retire ses droits à sa prochaine
ouverture de session, sans intervention en base.
"""
from __future__ import annotations
import contextlib
import secrets
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from limier.api.deps import SessionBase, UtilisateurOptionnel, ip_client
from limier.api.schemas import EtatCompte, ReponseSimple
from limier.auth import oidc
from limier.auth import session as session_cookie
from limier.config import get_settings
from limier.db.models import AuditEvent, User
from limier.quotas import service as quotas
log = structlog.get_logger(__name__)
routeur = APIRouter(prefix="/auth", tags=["authentification"])
@routeur.get("/connexion")
async def connexion(requete: Request, suite: str = "/") -> RedirectResponse:
settings = get_settings()
if not settings.oidc_client_id:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="L'authentification n'est pas configurée sur cette instance.",
)
etat = secrets.token_urlsafe(32)
verificateur, defi = oidc.generer_pkce()
try:
url = await oidc.url_autorisation(etat, defi)
except oidc.ErreurAuthentification as exc:
log.error("decouverte_oidc_impossible", erreur=str(exc))
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Le fournisseur d'identité est injoignable.",
) from exc
reponse = RedirectResponse(url, status_code=status.HTTP_302_FOUND)
session_cookie.poser_transit(reponse, etat, verificateur)
# ``suite`` permet de revenir sur la page demandée après connexion. On
# n'accepte qu'un chemin relatif : une URL absolue ouvrirait une
# redirection ouverte.
if suite.startswith("/") and not suite.startswith("//"):
reponse.set_cookie(
"limier_suite",
suite,
max_age=600,
httponly=True,
secure=settings.is_prod,
samesite="lax",
path="/",
)
return reponse
@routeur.get("/retour")
async def retour(
requete: Request,
s: SessionBase,
code: str | None = None,
state: str | None = None,
error: str | None = None,
error_description: str | None = None,
) -> RedirectResponse:
settings = get_settings()
if error:
log.warning("authentification_refusee", erreur=error, description=error_description)
return RedirectResponse(f"{settings.public_url}/?erreur=authentification")
transit = session_cookie.lire_transit(requete)
if not transit or not code or not state:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Requête d'authentification incomplète ou expirée. Recommencez.",
)
# Comparaison à temps constant : l'état protège du CSRF sur le rappel.
if not secrets.compare_digest(state, transit.get("etat", "")):
log.warning("etat_oidc_incoherent")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="État d'authentification invalide."
)
try:
jetons = await oidc.echanger_code(code, transit["pkce"])
revendications = await oidc.valider_jeton_identite(jetons["id_token"])
except (oidc.ErreurAuthentification, KeyError) as exc:
log.warning("echec_validation_identite", erreur=str(exc))
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
groupes = revendications.get("groups") or []
if not groupes and jetons.get("access_token"):
# Certaines configurations n'exposent les groupes que par userinfo.
profil = await oidc.profil_utilisateur(jetons["access_token"])
groupes = profil.get("groups") or []
revendications = {**profil, **revendications}
utilisateur = await _creer_ou_mettre_a_jour(s, revendications, groupes)
s.add(
AuditEvent(
user_id=utilisateur.id,
action="connexion",
detail={"plan": utilisateur.plan, "admin": utilisateur.is_admin},
client_ip=ip_client(requete),
)
)
suite = requete.cookies.get("limier_suite") or "/"
if not suite.startswith("/") or suite.startswith("//"):
suite = "/"
reponse = RedirectResponse(f"{settings.public_url}{suite}", status_code=status.HTTP_302_FOUND)
session_cookie.ouvrir(
reponse,
{
"uid": str(utilisateur.id),
"sub": utilisateur.subject,
"it": jetons.get("id_token", "")[:0],
},
)
session_cookie.effacer_transit(reponse)
reponse.delete_cookie("limier_suite", path="/")
log.info("connexion_reussie", plan=utilisateur.plan, admin=utilisateur.is_admin)
return reponse
async def _creer_ou_mettre_a_jour(s: SessionBase, revendications: dict, groupes: list[str]) -> User:
settings = get_settings()
sujet = revendications.get("sub")
if not sujet:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Le jeton d'identité ne porte pas de sujet.",
)
est_admin = settings.oidc_admin_group in groupes
est_interne = est_admin or settings.oidc_internal_group in groupes
plan = "interne" if est_interne else settings.default_plan
resultat = await s.execute(select(User).where(User.subject == sujet))
utilisateur = resultat.scalar_one_or_none()
if utilisateur is None:
utilisateur = User(
subject=sujet,
email=revendications.get("email"),
display_name=revendications.get("name") or revendications.get("preferred_username"),
plan=plan,
is_admin=est_admin,
)
s.add(utilisateur)
await s.flush()
log.info("compte_cree", plan=plan, admin=est_admin)
else:
utilisateur.email = revendications.get("email") or utilisateur.email
utilisateur.display_name = (
revendications.get("name")
or revendications.get("preferred_username")
or utilisateur.display_name
)
utilisateur.is_admin = est_admin
# Un plan payant attribué à la main n'est pas écrasé par la connexion.
if utilisateur.plan in ("gratuit", "interne"):
utilisateur.plan = plan
utilisateur.last_seen_at = datetime.now(UTC)
return utilisateur
@routeur.post("/deconnexion", response_model=ReponseSimple)
async def deconnexion(reponse: Response) -> ReponseSimple:
session_cookie.fermer(reponse)
url = None
with contextlib.suppress(Exception):
url = await oidc.url_deconnexion()
return ReponseSimple(ok=True, message=url)
@routeur.get("/moi", response_model=EtatCompte)
async def moi(utilisateur: UtilisateurOptionnel, s: SessionBase) -> EtatCompte:
if utilisateur is None:
return EtatCompte(authenticated=False)
etat = await quotas.etat(s, utilisateur)
return EtatCompte(
authenticated=True,
subject=utilisateur.subject,
display_name=utilisateur.display_name,
email=utilisateur.email,
plan=utilisateur.plan,
is_admin=utilisateur.is_admin,
quota_limit=None if etat.illimite else etat.limite,
quota_used=etat.consomme,
quota_remaining=etat.restant,
quota_period=etat.periode,
)
+51
View File
@@ -0,0 +1,51 @@
"""Catalogue : ce que l'instance sait faire.
Consommé par l'interface au chargement pour construire dynamiquement le
formulaire : les types proposés, les catégories de sites, les modules optionnels
réellement actifs. Une instance sans clé HIBP n'affiche pas la case « fuites ».
"""
from __future__ import annotations
from fastapi import APIRouter
from limier.api.schemas import InfoCatalogue
from limier.config import get_settings
from limier.engines.registry import types_disponibles
routeur = APIRouter(tags=["catalogue"])
@routeur.get("/catalogue", response_model=InfoCatalogue)
async def catalogue() -> InfoCatalogue:
from limier.engines.email import breaches, holehe_client
settings = get_settings()
stats = {"total": 0, "actifs": 0, "tags": {}}
if settings.engine_username_enabled:
from limier.engines.username import database
stats = database.statistiques()
return InfoCatalogue(
types=types_disponibles(),
sites_total=stats["total"],
sites_actifs=stats["actifs"],
tags=dict(list(stats["tags"].items())[:40]),
moteurs={
"username": settings.engine_username_enabled,
"email": settings.engine_email_enabled,
"domain": settings.engine_domain_enabled,
},
profondeur={
"standard": settings.username_top_sites,
"approfondie": settings.username_top_sites_deep,
},
modules_optionnels={
"fuites": breaches.actif(),
"comptes_lies": holehe_client.actif(),
"contournement_cloudflare": bool(settings.flaresolverr_url),
"proxies": settings.proxy_strategy != "none",
},
)
+57
View File
@@ -0,0 +1,57 @@
"""Sondes de santé.
``/sante`` est la sonde du healthcheck Docker : elle doit rester rapide et ne
dépendre de rien. ``/pret`` vérifie réellement les dépendances et sert au
diagnostic — c'est elle qu'on regarde quand « ça ne marche pas ».
"""
from __future__ import annotations
from fastapi import APIRouter
from sqlalchemy import text
from limier import __version__
from limier.config import get_settings
routeur = APIRouter(tags=["exploitation"])
@routeur.get("/sante")
async def sante() -> dict:
return {"statut": "ok", "version": __version__}
@routeur.get("/pret")
async def pret() -> dict:
from limier.db.session import fabrique_session
from limier.jobs.queue import get_redis
details: dict[str, str] = {}
try:
async with fabrique_session()() as s:
await s.execute(text("SELECT 1"))
details["postgresql"] = "ok"
except Exception as exc:
details["postgresql"] = f"erreur : {exc}"
try:
redis = await get_redis()
await redis.ping()
details["redis"] = "ok"
except Exception as exc:
details["redis"] = f"erreur : {exc}"
try:
from limier.engines.username import database
details["base_sites"] = f"{database.statistiques()['actifs']} sites actifs"
except Exception as exc:
details["base_sites"] = f"erreur : {exc}"
problemes = get_settings().check_production_readiness()
if problemes:
details["configuration"] = " ; ".join(problemes)
tout_va_bien = all(v == "ok" or "sites actifs" in v for v in details.values())
return {"statut": "ok" if tout_va_bien else "degrade", "details": details}
+179
View File
@@ -0,0 +1,179 @@
"""Routes RGPD : information, effacement, export.
Trois obligations concrètes pour un service public de recherche :
- **Information** (art. 13-14) : ``/api/confidentialite`` décrit ce qui est
collecté, pourquoi et pour combien de temps, sous forme lisible par machine
autant que par humain — la page publique s'en sert comme source unique.
- **Effacement** (art. 17) : ``/api/confidentialite/effacement`` reçoit une
demande portant sur un identifiant. Elle est mise en attente de traitement,
pas appliquée immédiatement : sans quoi n'importe qui pourrait effacer les
recherches d'autrui en devinant un terme.
- **Portabilité** (art. 20) : ``/api/confidentialite/export`` rend à
l'utilisateur connecté l'intégralité de ses données, en JSON.
"""
from __future__ import annotations
import structlog
from fastapi import APIRouter, Request, status
from fastapi.responses import JSONResponse
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from limier.api.deps import SessionBase, UtilisateurConnecte, ip_client
from limier.api.schemas import DemandeEffacement, ReponseSimple
from limier.config import get_settings
from limier.db.models import AuditEvent, Search, SuppressionRequest
from limier.privacy import identifiers
from limier.quotas import service as quotas
log = structlog.get_logger(__name__)
routeur = APIRouter(prefix="/confidentialite", tags=["confidentialité"])
@routeur.get("")
async def politique() -> dict:
settings = get_settings()
return {
"responsable_traitement": settings.public_url,
"finalite": (
"Recherche d'informations publiquement accessibles à partir d'un "
"pseudonyme, d'une adresse électronique ou d'un nom de domaine."
),
"base_legale": "Intérêt légitime de l'utilisateur, art. 6-1-f du RGPD.",
"donnees_collectees": {
"compte": [
"identifiant du fournisseur d'identité",
"adresse électronique",
"nom affiché",
],
"recherches": [
"type et date de la recherche",
"empreinte du terme recherché"
+ ("" if settings.store_raw_identifiers else " (terme non conservé en clair)"),
"résultats obtenus",
],
"journal": ["adresse IP", "action", "horodatage"],
},
"conservation": {
"recherches_et_resultats": f"{settings.retention_days} jours",
"journal_exploitation": "90 jours",
"compteurs_de_quota": "13 mois",
"compte": "jusqu'à sa suppression",
},
"minimisation_active": not settings.store_raw_identifiers,
"destinataires": (
"Aucune transmission à des tiers. Les recherches interrogent des sites "
"publics : ceux-ci voient une requête provenant de l'infrastructure du "
"service, jamais l'identité de l'utilisateur."
),
"droits": {
"acces_et_portabilite": "/api/confidentialite/export",
"effacement": "/api/confidentialite/effacement",
},
}
@routeur.post("/effacement", response_model=ReponseSimple, status_code=status.HTTP_202_ACCEPTED)
async def demander_effacement(
demande: DemandeEffacement, requete: Request, s: SessionBase
) -> ReponseSimple:
"""Enregistre une demande d'effacement, sans l'appliquer immédiatement.
Volontaire : appliquer directement permettrait à quiconque de supprimer les
recherches des autres. Un administrateur valide, puis l'identifiant est
effacé et ajouté à la liste d'exclusion.
"""
empreinte = identifiers.empreinte(demande.term, demande.kind.value)
existante = await s.execute(
select(SuppressionRequest.id).where(
SuppressionRequest.identifier_hash == empreinte,
SuppressionRequest.handled.is_(False),
)
)
if existante.scalar_one_or_none() is not None:
return ReponseSimple(
ok=True, message="Une demande portant sur cet identifiant est déjà en cours."
)
s.add(
SuppressionRequest(
identifier_hash=empreinte,
identifier_display=demande.term,
contact_email=demande.contact_email,
reason=demande.reason,
)
)
s.add(
AuditEvent(
action="demande_effacement",
detail={"hash": empreinte[:16]},
client_ip=ip_client(requete),
)
)
log.info("demande_effacement_enregistree")
return ReponseSimple(
ok=True,
message=(
"Demande enregistrée. Elle sera examinée puis appliquée : les recherches "
"portant sur cet identifiant seront supprimées et toute nouvelle recherche "
"sera refusée."
),
)
@routeur.get("/export")
async def export(utilisateur: UtilisateurConnecte, s: SessionBase) -> JSONResponse:
"""Export complet des données de l'utilisateur connecté (art. 20)."""
resultat = await s.execute(
select(Search)
.options(selectinload(Search.findings))
.where(Search.user_id == utilisateur.id)
.order_by(Search.created_at.desc())
)
recherches = resultat.scalars().all()
etat_quota = await quotas.etat(s, utilisateur)
charge = {
"compte": {
"identifiant_fournisseur": utilisateur.subject,
"email": utilisateur.email,
"nom_affiche": utilisateur.display_name,
"plan": utilisateur.plan,
"cree_le": utilisateur.created_at.isoformat(),
},
"quota": {
"periode": etat_quota.periode,
"consomme": etat_quota.consomme,
"limite": None if etat_quota.illimite else etat_quota.limite,
},
"recherches": [
{
"id": str(r.id),
"type": r.kind.value,
"terme": r.query_display,
"statut": r.status.value,
"creee_le": r.created_at.isoformat(),
"expire_le": r.expires_at.isoformat(),
"resultats": [
{
"module": f.module,
"source": f.source,
"sujet": f.subject,
"url": f.url,
"confiance": f.confidence.value,
"score": f.score,
"profil": f.profile,
}
for f in r.findings
],
}
for r in recherches
],
}
return JSONResponse(
charge,
headers={"Content-Disposition": 'attachment; filename="limier-export.json"'},
)
+282
View File
@@ -0,0 +1,282 @@
"""Routes de recherche.
Ordre des contrôles avant d'accepter une recherche, du moins coûteux au plus
coûteux — et surtout, du plus discriminant au moins discriminant :
1. validation syntaxique du terme (``api.schemas``) ;
2. liste d'exclusion RGPD : un identifiant effacé ne se recherche plus ;
3. limitation de débit horaire ;
4. quota mensuel ;
5. disponibilité d'un moteur pour ce type.
Le crédit n'est consommé qu'une fois le travail effectivement mis en file, et il
est remboursé si l'empilement échoue : personne ne doit perdre une recherche
parce que Redis a hoqueté.
"""
from __future__ import annotations
from uuid import UUID
import structlog
from fastapi import APIRouter, HTTPException, Query, Request, status
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload
from limier.api.deps import (
SessionBase,
UtilisateurConnecte,
ip_client,
)
from limier.api.schemas import (
DemandeRecherche,
DemandeVariantes,
DetailRecherche,
ResultatSortie,
ResumeRecherche,
)
from limier.config import get_settings
from limier.db.models import AuditEvent, Search, SearchKind, SearchStatus
from limier.engines.registry import moteur_pour
from limier.engines.username import permutations
from limier.jobs import queue
from limier.privacy import identifiers, retention
from limier.quotas import service as quotas
log = structlog.get_logger(__name__)
routeur = APIRouter(prefix="/recherches", tags=["recherches"])
@routeur.post("", response_model=ResumeRecherche, status_code=status.HTTP_202_ACCEPTED)
async def creer(
demande: DemandeRecherche,
requete: Request,
utilisateur: UtilisateurConnecte,
s: SessionBase,
) -> ResumeRecherche:
settings = get_settings()
empreinte = identifiers.empreinte(demande.term, demande.kind.value)
# 1. Liste d'exclusion
if await retention.est_bloque(empreinte):
raise HTTPException(
status_code=status.HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS,
detail=(
"Cet identifiant fait l'objet d'une demande d'effacement acceptée. "
"Il ne peut plus être recherché sur cette instance."
),
headers={"X-Limier-Code": "identifiant_bloque"},
)
# 2. Débit horaire
autorise, _restant = await quotas.verifier_debit_utilisateur(utilisateur.id)
if not autorise:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=(
f"Limite de {settings.rate_limit_searches_per_hour} recherches par heure "
"atteinte. Réessayez plus tard."
),
headers={"X-Limier-Code": "debit_depasse", "Retry-After": "600"},
)
# 3. Quota mensuel
etat_quota = await quotas.etat(s, utilisateur)
if etat_quota.depasse:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=(
f"Quota mensuel épuisé ({etat_quota.consomme}/{etat_quota.limite} "
f"pour {etat_quota.periode})."
),
headers={"X-Limier-Code": "quota_depasse"},
)
# 4. Moteur disponible
if moteur_pour(demande.kind) is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Le moteur « {demande.kind.value} » est désactivé sur cette instance.",
headers={"X-Limier-Code": "moteur_indisponible"},
)
variantes = demande.variants
if demande.kind is SearchKind.NAME and not variantes:
variantes = permutations.generer(demande.term, settings.permutation_max_variants)
if not variantes:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Aucune variante exploitable n'a pu être déduite de ce nom.",
)
recherche = Search(
user_id=utilisateur.id,
kind=demande.kind,
status=SearchStatus.QUEUED,
query_display=identifiers.valeur_stockable(demande.term),
query_hash=empreinte,
options={
# Le terme voyage dans les options : c'est la seule copie quand la
# minimisation RGPD est active. Il disparaît avec la recherche.
"term": demande.term,
"deep": demande.deep,
"tags": demande.tags,
"variants": variantes,
},
expires_at=retention.date_expiration(),
)
s.add(recherche)
await s.flush()
await quotas.consommer(s, utilisateur)
s.add(
AuditEvent(
user_id=utilisateur.id,
action="recherche_creee",
detail={
"kind": demande.kind.value,
"deep": demande.deep,
"hash": empreinte[:16], # préfixe seul : corrélation sans réidentification
},
client_ip=ip_client(requete),
)
)
await s.flush()
travail = await queue.empiler("executer_recherche", str(recherche.id))
if travail is None:
await quotas.rembourser(s, utilisateur)
recherche.status = SearchStatus.FAILED
recherche.error = "File de travaux indisponible."
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="La file de traitement est indisponible. Réessayez dans un instant.",
)
log.info("recherche_acceptee", kind=demande.kind.value, deep=demande.deep)
return _resume(recherche)
@routeur.post("/variantes")
async def apercu_variantes(demande: DemandeVariantes) -> dict:
"""Aperçu des pseudonymes déduits d'un nom, avant lancement.
Rendu par l'interface au fil de la saisie : l'utilisateur voit exactement ce
qui sera cherché et peut basculer sur une recherche par pseudonyme si le
résultat ne lui convient pas.
"""
return permutations.decrire(demande.term, get_settings().permutation_max_variants)
@routeur.get("", response_model=list[ResumeRecherche])
async def lister(
utilisateur: UtilisateurConnecte,
s: SessionBase,
limite: int = Query(default=25, ge=1, le=100),
decalage: int = Query(default=0, ge=0),
) -> list[ResumeRecherche]:
resultat = await s.execute(
select(Search)
.where(Search.user_id == utilisateur.id)
.order_by(Search.created_at.desc())
.limit(limite)
.offset(decalage)
)
return [_resume(r) for r in resultat.scalars().all()]
@routeur.get("/statistiques/mensuelles")
async def statistiques(utilisateur: UtilisateurConnecte, s: SessionBase) -> dict:
"""Répartition des recherches de l'utilisateur, pour son tableau de bord.
Déclarée AVANT ``/{recherche_id}`` : FastAPI teste les routes dans l'ordre
de déclaration, et « statistiques » serait sinon interprété comme un
identifiant de recherche, produisant une erreur de validation d'UUID.
"""
resultat = await s.execute(
select(Search.kind, func.count())
.where(Search.user_id == utilisateur.id)
.group_by(Search.kind)
)
return {kind.value: nombre for kind, nombre in resultat.all()}
@routeur.get("/{recherche_id}", response_model=DetailRecherche)
async def detail(
recherche_id: UUID,
utilisateur: UtilisateurConnecte,
s: SessionBase,
confiance_min: str | None = Query(default=None, pattern="^(confirmed|probable|weak)$"),
) -> DetailRecherche:
recherche = await _recuperer(s, recherche_id, utilisateur)
resultats = sorted(recherche.findings, key=lambda f: (-f.score, f.source.lower()))
comptes: dict[str, int] = {}
for r in resultats:
comptes[r.confidence.value] = comptes.get(r.confidence.value, 0) + 1
if confiance_min:
ordre = {"weak": 0, "probable": 1, "confirmed": 2}
seuil = ordre[confiance_min]
resultats = [r for r in resultats if ordre[r.confidence.value] >= seuil]
base = _resume(recherche)
return DetailRecherche(
**base.model_dump(),
counts=comptes,
findings=[
ResultatSortie(
id=r.id,
module=r.module,
source=r.source,
subject=r.subject,
url=r.url,
confidence=r.confidence.value,
score=r.score,
signals=r.signals,
profile=r.profile,
tags=r.tags,
)
for r in resultats
],
)
@routeur.delete("/{recherche_id}")
async def supprimer(recherche_id: UUID, utilisateur: UtilisateurConnecte, s: SessionBase) -> dict:
from limier.jobs import progress
recherche = await _recuperer(s, recherche_id, utilisateur)
await s.delete(recherche)
await progress.purger(recherche_id)
return {"ok": True}
async def _recuperer(s: SessionBase, recherche_id: UUID, utilisateur) -> Search:
resultat = await s.execute(
select(Search).options(selectinload(Search.findings)).where(Search.id == recherche_id)
)
recherche = resultat.scalar_one_or_none()
if recherche is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recherche introuvable.")
# Un administrateur ne lit pas les recherches des autres : ce serait
# contradictoire avec la politique de minimisation annoncée aux visiteurs.
if recherche.user_id != utilisateur.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recherche introuvable.")
return recherche
def _resume(recherche: Search) -> ResumeRecherche:
return ResumeRecherche(
id=recherche.id,
kind=recherche.kind,
status=recherche.status.value,
query=recherche.query_display,
created_at=recherche.created_at,
finished_at=recherche.finished_at,
progress_done=recherche.progress_done,
progress_total=recherche.progress_total,
findings_count=recherche.findings_count,
duration_seconds=recherche.duration_seconds,
error=recherche.error,
)
+150
View File
@@ -0,0 +1,150 @@
"""Flux d'avancement en temps réel (Server-Sent Events).
Choix du SSE plutôt que du WebSocket : le flux est unidirectionnel (serveur vers
navigateur), SSE se reconnecte tout seul, passe partout en HTTP/1.1 et ne
demande aucune configuration particulière côté Traefik. Un WebSocket
n'apporterait ici que de la complexité.
Deux précautions indispensables derrière un proxy :
- ``X-Accel-Buffering: no`` et ``Cache-Control: no-cache`` pour interdire toute
mise en tampon intermédiaire, sans quoi le navigateur ne reçoit rien avant la
fin de la recherche ;
- un **battement** périodique (commentaire SSE ``: ping``), sans lequel le
tunnel Cloudflare ferme une connexion silencieuse au bout de 100 secondes.
Les évènements déjà émis sont rejoués depuis Redis avant de basculer sur
l'écoute : le client qui se connecte en retard ou se reconnecte ne perd rien.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
from uuid import UUID
import structlog
from fastapi import APIRouter, HTTPException, Request, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from limier.api.deps import SessionBase, UtilisateurConnecte
from limier.db.models import Search, SearchStatus
from limier.jobs import progress
log = structlog.get_logger(__name__)
routeur = APIRouter(prefix="/recherches", tags=["recherches"])
INTERVALLE_BATTEMENT = 20.0
DUREE_MAX_FLUX = 900.0
def _sse(donnees: str, evenement: str | None = None, identifiant: int | None = None) -> str:
morceaux = []
if evenement:
morceaux.append(f"event: {evenement}")
if identifiant is not None:
morceaux.append(f"id: {identifiant}")
morceaux.append(f"data: {donnees}")
return "\n".join(morceaux) + "\n\n"
@routeur.get("/{recherche_id}/flux")
async def flux(
recherche_id: UUID,
requete: Request,
utilisateur: UtilisateurConnecte,
s: SessionBase,
) -> StreamingResponse:
resultat = await s.execute(
select(Search).where(Search.id == recherche_id, Search.user_id == utilisateur.id)
)
recherche = resultat.scalar_one_or_none()
if recherche is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recherche introuvable.")
terminee = recherche.status in (
SearchStatus.DONE,
SearchStatus.FAILED,
SearchStatus.CANCELLED,
)
# Reprise après coupure : le navigateur renvoie le dernier identifiant reçu.
depuis = 0
entete_reprise = requete.headers.get("last-event-id")
if entete_reprise and entete_reprise.isdigit():
depuis = int(entete_reprise) + 1
async def generer():
index = depuis
for charge in await progress.historique(recherche_id, depuis):
yield _sse(charge, identifiant=index)
index += 1
if terminee:
yield _sse(
json.dumps({"type": "fin", "statut": recherche.status.value}),
evenement="fin",
identifiant=index,
)
return
file: asyncio.Queue = asyncio.Queue(maxsize=500)
arret = asyncio.Event()
async def pomper():
try:
async for charge in progress.ecouter(recherche_id):
await file.put(charge)
if '"type": "fin"' in charge or '"type":"fin"' in charge:
break
except Exception as exc:
log.warning("ecoute_progression_interrompue", erreur=str(exc))
finally:
arret.set()
tache = asyncio.create_task(pomper())
debut = asyncio.get_running_loop().time()
try:
while True:
if await requete.is_disconnected():
break
if asyncio.get_running_loop().time() - debut > DUREE_MAX_FLUX:
yield _sse(
json.dumps(
{
"type": "notice",
"level": "warning",
"message": "Flux fermé après 15 minutes.",
}
),
identifiant=index,
)
break
try:
charge = await asyncio.wait_for(file.get(), timeout=INTERVALLE_BATTEMENT)
except TimeoutError:
if arret.is_set() and file.empty():
break
yield ": ping\n\n" # battement anti-coupure
continue
yield _sse(charge, identifiant=index)
index += 1
if '"type": "fin"' in charge or '"type":"fin"' in charge:
break
finally:
tache.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await tache
return StreamingResponse(
generer(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
+146
View File
@@ -0,0 +1,146 @@
"""Schémas d'entrée et de sortie de l'API.
La validation d'entrée est la première barrière du service : c'est ici qu'on
refuse ce qui n'a pas de sens avant de consommer un crédit ou d'émettre 500
requêtes vers des tiers.
"""
from __future__ import annotations
import re
from datetime import datetime
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, Field, field_validator, model_validator
from limier.db.models import SearchKind
MOTIF_DOMAINE = re.compile(
r"^(?=.{1,253}$)(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$"
)
MOTIF_EMAIL = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,253}$")
MOTIF_PSEUDO = re.compile(r"^[\w.\-@+]{2,64}$", re.UNICODE)
class DemandeRecherche(BaseModel):
kind: SearchKind
term: str = Field(min_length=2, max_length=256)
deep: bool = False
tags: list[str] = Field(default_factory=list, max_length=10)
variants: list[str] = Field(default_factory=list, max_length=20)
@field_validator("term")
@classmethod
def _nettoyer(cls, v: str) -> str:
v = re.sub(r"\s+", " ", v.strip())
if not v:
raise ValueError("Terme vide.")
return v
@model_validator(mode="after")
def _coherence(self) -> DemandeRecherche:
terme = self.term
if self.kind is SearchKind.EMAIL and not MOTIF_EMAIL.match(terme.lower()):
raise ValueError("Adresse e-mail invalide.")
elif self.kind is SearchKind.DOMAIN and not MOTIF_DOMAINE.match(terme.lower().strip(".")):
raise ValueError("Nom de domaine invalide.")
elif self.kind is SearchKind.USERNAME and not MOTIF_PSEUDO.match(terme):
raise ValueError(
"Pseudonyme invalide : lettres, chiffres, point, tiret, "
"souligné et arobase uniquement."
)
elif self.kind is SearchKind.NAME and len(terme.split()) < 2:
raise ValueError(
"Une recherche par nom demande au moins deux mots. "
"Pour un seul mot, choisissez le type « pseudonyme »."
)
return self
class DemandeVariantes(BaseModel):
term: str = Field(min_length=2, max_length=256)
class ResumeRecherche(BaseModel):
id: UUID
kind: SearchKind
status: str
query: str | None
created_at: datetime
finished_at: datetime | None
progress_done: int
progress_total: int
findings_count: int
duration_seconds: float | None = None
error: str | None = None
model_config = {"from_attributes": True}
class ResultatSortie(BaseModel):
id: UUID
module: str
source: str
subject: str
url: str | None
confidence: str
score: float
signals: dict[str, Any]
profile: dict[str, Any]
tags: list[str]
model_config = {"from_attributes": True}
class DetailRecherche(ResumeRecherche):
findings: list[ResultatSortie] = Field(default_factory=list)
counts: dict[str, int] = Field(default_factory=dict)
class EtatCompte(BaseModel):
authenticated: bool
subject: str | None = None
display_name: str | None = None
email: str | None = None
plan: str | None = None
is_admin: bool = False
quota_limit: int | None = None
quota_used: int | None = None
quota_remaining: int | None = None
quota_period: str | None = None
class DemandeEffacement(BaseModel):
term: str = Field(min_length=2, max_length=256)
kind: SearchKind
contact_email: str | None = Field(default=None, max_length=320)
reason: str | None = Field(default=None, max_length=2000)
class ReponseSimple(BaseModel):
ok: bool = True
message: str | None = None
class InfoCatalogue(BaseModel):
types: list[str]
sites_total: int
sites_actifs: int
tags: dict[str, int]
moteurs: dict[str, bool]
profondeur: dict[str, int]
modules_optionnels: dict[str, Any]
class ErreurSortie(BaseModel):
detail: str
code: Literal[
"quota_depasse",
"debit_depasse",
"identifiant_bloque",
"authentification_requise",
"droits_insuffisants",
"moteur_indisponible",
"requete_invalide",
]
View File
Whitespace-only changes.
+192
View File
@@ -0,0 +1,192 @@
"""Authentification OpenID Connect contre Authentik.
Flux « authorization code » avec PKCE. Le client est confidentiel (il détient un
secret), mais PKCE est ajouté quand même : c'est gratuit et cela ferme
l'interception du code d'autorisation.
Points propres à Authentik :
- L'émetteur est de la forme ``https://authentik.…/application/o/<slug>/`` et le
document de découverte se trouve sous ``…/.well-known/openid-configuration``.
- La revendication ``groups`` est exposée par les correspondances par défaut
depuis la version 2026.8. Sur une instance plus ancienne, il faut y rattacher
une correspondance de portée dédiée (voir ``docs/03-authentik.md``).
- L'URI de redirection doit être déclarée **exactement** dans le champ « URIs de
redirection » du fournisseur, et non dans « URI de déconnexion » — confusion
fréquente qui produit une erreur « Redirect URI Error » à la première
connexion.
Le document de découverte et les clés publiques sont mis en cache : les
recharger à chaque connexion ajouterait deux allers-retours inutiles.
"""
from __future__ import annotations
import base64
import hashlib
import secrets
import time
from typing import Any
import structlog
from joserfc import jwt
from joserfc.jwk import KeySet
from joserfc.jwt import JWTClaimsRegistry
from limier.config import get_settings
from limier.net.http import fetch_json, get_client
log = structlog.get_logger(__name__)
_decouverte: dict[str, Any] | None = None
_decouverte_horodatage: float = 0.0
_jwks: Any = None
TTL_DECOUVERTE = 3600
class ErreurAuthentification(Exception):
pass
async def decouverte() -> dict[str, Any]:
"""Document ``.well-known/openid-configuration`` du fournisseur."""
global _decouverte, _decouverte_horodatage
if _decouverte and (time.time() - _decouverte_horodatage) < TTL_DECOUVERTE:
return _decouverte
issuer = get_settings().oidc_issuer.rstrip("/")
reponse = await fetch_json(f"{issuer}/.well-known/openid-configuration", essais=3)
if not reponse.ok:
raise ErreurAuthentification(
f"Découverte OIDC impossible auprès de {issuer} : {reponse.erreur}"
)
_decouverte = reponse.data
_decouverte_horodatage = time.time()
return _decouverte
async def cles_publiques():
global _jwks
if _jwks is None:
config = await decouverte()
reponse = await fetch_json(config["jwks_uri"], essais=3)
if not reponse.ok:
raise ErreurAuthentification(f"Clés publiques illisibles : {reponse.erreur}")
_jwks = KeySet.import_key_set(reponse.data)
return _jwks
def generer_pkce() -> tuple[str, str]:
"""Retourne ``(verificateur, defi)`` conformes à la méthode S256."""
verificateur = secrets.token_urlsafe(64)
resume = hashlib.sha256(verificateur.encode("ascii")).digest()
defi = base64.urlsafe_b64encode(resume).decode("ascii").rstrip("=")
return verificateur, defi
async def url_autorisation(etat: str, defi_pkce: str) -> str:
settings = get_settings()
config = await decouverte()
from urllib.parse import urlencode
parametres = {
"response_type": "code",
"client_id": settings.oidc_client_id,
"redirect_uri": settings.oidc_redirect_uri,
"scope": settings.oidc_scopes,
"state": etat,
"code_challenge": defi_pkce,
"code_challenge_method": "S256",
}
return f"{config['authorization_endpoint']}?{urlencode(parametres)}"
async def echanger_code(code: str, verificateur_pkce: str) -> dict[str, Any]:
"""Échange le code d'autorisation contre les jetons."""
settings = get_settings()
config = await decouverte()
client = await get_client()
reponse = await client.post(
config["token_endpoint"],
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": settings.oidc_redirect_uri,
"client_id": settings.oidc_client_id,
"client_secret": settings.oidc_client_secret,
"code_verifier": verificateur_pkce,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=15.0,
)
if reponse.status_code >= 400:
log.warning("echange_code_refuse", status=reponse.status_code, corps=reponse.text[:300])
raise ErreurAuthentification(
f"Le fournisseur a refusé le code d'autorisation (HTTP {reponse.status_code})."
)
return reponse.json()
async def valider_jeton_identite(jeton: str) -> dict[str, Any]:
"""Vérifie la signature, l'émetteur, le destinataire et l'expiration."""
settings = get_settings()
cles = await cles_publiques()
config = await decouverte()
# ``algorithms`` est explicite : sans liste blanche, un jeton forgé pourrait
# déclarer un algorithme faible (voire « none ») et être accepté.
try:
jeton_decode = jwt.decode(jeton, cles, algorithms=["RS256", "RS384", "RS512", "ES256"])
except Exception as exc:
raise ErreurAuthentification(f"Jeton d'identité invalide : {exc}") from exc
registre = JWTClaimsRegistry(
iss={"essential": True, "value": config.get("issuer")},
aud={"essential": True, "values": [settings.oidc_client_id]},
exp={"essential": True},
sub={"essential": True},
leeway=60,
)
try:
registre.validate(jeton_decode.claims)
except Exception as exc:
raise ErreurAuthentification(f"Revendications du jeton refusées : {exc}") from exc
return dict(jeton_decode.claims)
async def profil_utilisateur(jeton_acces: str) -> dict[str, Any]:
"""Complète les revendications par le point d'accès userinfo.
Nécessaire quand la revendication ``groups`` n'est pas dans le jeton
d'identité — configuration par défaut de certaines versions d'Authentik.
"""
config = await decouverte()
point = config.get("userinfo_endpoint")
if not point:
return {}
client = await get_client()
try:
reponse = await client.get(
point, headers={"Authorization": f"Bearer {jeton_acces}"}, timeout=10.0
)
return reponse.json() if reponse.status_code < 400 else {}
except Exception as exc:
log.warning("userinfo_indisponible", erreur=str(exc))
return {}
async def url_deconnexion(jeton_identite: str | None = None) -> str | None:
"""URL de fermeture de session côté Authentik, si le fournisseur l'expose."""
settings = get_settings()
config = await decouverte()
point = config.get("end_session_endpoint")
if not point:
return None
from urllib.parse import urlencode
parametres = {"post_logout_redirect_uri": settings.public_url}
if jeton_identite:
parametres["id_token_hint"] = jeton_identite
return f"{point}?{urlencode(parametres)}"
+115
View File
@@ -0,0 +1,115 @@
"""Session applicative portée par un cookie signé.
Choix : cookie signé (itsdangerous) plutôt que jeton JWT côté client ou session
en base.
- Pas de JWT côté navigateur : impossible à révoquer, et stocké quelque part où
le JavaScript le lit.
- Pas de session en base : un aller-retour PostgreSQL sur chaque requête pour
une information qui tient en 200 octets.
Le cookie porte l'identifiant interne de l'utilisateur, pas ses attributs : le
plan et les droits sont relus en base à chaque requête, donc un changement de
groupe dans Authentik prend effet à la connexion suivante sans invalider quoi
que ce soit.
Attributs : ``HttpOnly`` (inaccessible au JavaScript), ``Secure`` (HTTPS
uniquement), ``SameSite=Lax`` — nécessaire pour que le cookie survive à la
redirection de retour depuis Authentik, ce que ``Strict`` interdirait.
"""
from __future__ import annotations
from typing import Any
import structlog
from fastapi import Request, Response
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from limier.config import get_settings
log = structlog.get_logger(__name__)
SEL_SESSION = "limier.session.v1"
SEL_TRANSIT = "limier.transit.v1"
COOKIE_TRANSIT = "limier_oidc"
DUREE_TRANSIT = 600
def _serialiseur(sel: str) -> URLSafeTimedSerializer:
settings = get_settings()
if not settings.session_secret:
raise RuntimeError("LIMIER_SESSION_SECRET n'est pas défini.")
return URLSafeTimedSerializer(settings.session_secret, salt=sel)
# ------------------------------------------------------------ session ouverte
def ouvrir(reponse: Response, donnees: dict[str, Any]) -> None:
settings = get_settings()
jeton = _serialiseur(SEL_SESSION).dumps(donnees)
reponse.set_cookie(
settings.session_cookie_name,
jeton,
max_age=settings.session_max_age_seconds,
httponly=True,
secure=settings.is_prod,
samesite="lax",
path="/",
)
def lire(requete: Request) -> dict[str, Any] | None:
settings = get_settings()
jeton = requete.cookies.get(settings.session_cookie_name)
if not jeton:
return None
try:
return _serialiseur(SEL_SESSION).loads(jeton, max_age=settings.session_max_age_seconds)
except SignatureExpired:
return None
except BadSignature:
log.warning("cookie_session_falsifie")
return None
def fermer(reponse: Response) -> None:
reponse.delete_cookie(get_settings().session_cookie_name, path="/")
# ---------------------------------------------- état temporaire du flux OIDC
def poser_transit(reponse: Response, etat: str, verificateur_pkce: str) -> None:
"""Mémorise l'état et le vérificateur PKCE entre l'aller et le retour.
Stockés dans un cookie signé de courte durée plutôt qu'en mémoire : avec
plusieurs répliques d'API derrière Traefik, rien ne garantit que le retour
d'Authentik arrive sur le même processus que le départ.
"""
settings = get_settings()
jeton = _serialiseur(SEL_TRANSIT).dumps({"etat": etat, "pkce": verificateur_pkce})
reponse.set_cookie(
COOKIE_TRANSIT,
jeton,
max_age=DUREE_TRANSIT,
httponly=True,
secure=settings.is_prod,
samesite="lax",
path="/",
)
def lire_transit(requete: Request) -> dict[str, Any] | None:
jeton = requete.cookies.get(COOKIE_TRANSIT)
if not jeton:
return None
try:
return _serialiseur(SEL_TRANSIT).loads(jeton, max_age=DUREE_TRANSIT)
except (BadSignature, SignatureExpired):
return None
def effacer_transit(reponse: Response) -> None:
reponse.delete_cookie(COOKIE_TRANSIT, path="/")
+188
View File
@@ -0,0 +1,188 @@
"""Configuration applicative.
Source unique de vérité pour toutes les variables d'environnement. Aucun autre
module ne lit ``os.environ`` : il importe ``get_settings()``.
Toutes les variables sont préfixées ``LIMIER_``.
"""
from __future__ import annotations
from functools import lru_cache
from typing import Literal
from pydantic import Field, PostgresDsn, RedisDsn, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="LIMIER_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# ------------------------------------------------------------------ socle
environment: Literal["dev", "prod"] = "prod"
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
log_format: Literal["json", "console"] = "json"
public_url: str = "https://limier.tips-of-mine.com"
"""URL publique de l'application. Sert à construire les redirect_uri OIDC."""
api_prefix: str = "/api"
# --------------------------------------------------------------- stockage
database_url: PostgresDsn = Field(
default="postgresql+asyncpg://limier:limier@limier-postgres:5432/limier"
)
redis_url: RedisDsn = Field(default="redis://limier-redis:6379/0")
# ------------------------------------------------------------------- auth
oidc_issuer: str = "https://authentik.tips-of-mine.com/application/o/limier/"
oidc_client_id: str = ""
oidc_client_secret: str = ""
oidc_scopes: str = "openid profile email groups"
oidc_admin_group: str = "GL-Limier-Admin"
oidc_internal_group: str = "GL-Limier"
"""Membre de ce groupe => plan ``interne`` (aucun quota)."""
session_secret: str = ""
"""Clé de signature des cookies de session. Obligatoire en production."""
session_cookie_name: str = "limier_session"
session_max_age_seconds: int = 60 * 60 * 12
# ----------------------------------------------------------------- quotas
default_plan: str = "gratuit"
plan_quotas: dict[str, int] = Field(
default_factory=lambda: {
"gratuit": 15,
"standard": 100,
"interne": -1, # -1 = illimité
}
)
rate_limit_searches_per_hour: int = 20
rate_limit_anonymous_per_hour: int = 5
# ---------------------------------------------------------------- moteurs
engine_username_enabled: bool = True
engine_email_enabled: bool = True
engine_domain_enabled: bool = True
username_top_sites: int = 500
"""Nombre de sites interrogés par défaut (classement Alexa de Maigret)."""
username_top_sites_deep: int = 3000
"""Nombre de sites en recherche approfondie."""
username_timeout_seconds: int = 10
username_max_connections: int = 50
username_retries: int = 1
username_parse_profiles: bool = True
"""Active l'extraction socid_extractor (avatar, nom, bio…)."""
username_recursive_search: bool = True
"""Relance une recherche sur les pseudos découverts dans les profils."""
permutation_max_variants: int = 12
"""Garde-fou : nombre maximal de variantes générées depuis « prénom nom »."""
# --------------------------------------------------------- moteur e-mail
gravatar_api_key: str = ""
"""Optionnel. Sans clé : 100 requêtes/heure ; avec clé : 1000."""
holehe_service_url: str = ""
"""URL du microservice holehe (GPLv3, isolé). Vide => module désactivé."""
holehe_timeout_seconds: int = 60
hibp_api_key: str = ""
"""Optionnel et payant. Vide => contrôle des fuites désactivé."""
# --------------------------------------------------------- moteur domaine
rdap_bootstrap_url: str = "https://data.iana.org/rdap/dns.json"
rdap_bootstrap_ttl_seconds: int = 24 * 3600
crtsh_timeout_seconds: int = 30
certspotter_fallback: bool = True
dns_resolvers: str = "1.1.1.1,9.9.9.9"
ct_max_subdomains: int = 500
# ------------------------------------------------------------ sortie réseau
proxy_urls: str = ""
"""Liste séparée par des virgules. Ex. ``http://u:p@h:port,socks5://…``"""
proxy_strategy: Literal["none", "round_robin", "random"] = "none"
proxy_failure_threshold: int = 5
proxy_cooldown_seconds: int = 900
flaresolverr_url: str = ""
"""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"
)
# ------------------------------------------------------------------- RGPD
retention_days: int = 30
"""Durée de conservation des recherches et de leurs résultats."""
store_raw_identifiers: bool = False
"""Si False, l'identifiant recherché est stocké haché (voir docs/05-rgpd.md)."""
identifier_hash_pepper: str = ""
"""Poivre du hachage des identifiants. Obligatoire si store_raw_identifiers=False."""
purge_interval_seconds: int = 3600
# ------------------------------------------------------------- exploitation
worker_max_jobs: int = 4
job_timeout_seconds: int = 600
job_result_ttl_seconds: int = 3600
metrics_enabled: bool = True
@field_validator("public_url", "oidc_issuer")
@classmethod
def _strip_trailing_slash_except_issuer(cls, v: str) -> str:
return v.rstrip("/") if not v.endswith("/application/o/") else v
@property
def is_prod(self) -> bool:
return self.environment == "prod"
@property
def proxy_list(self) -> list[str]:
return [p.strip() for p in self.proxy_urls.split(",") if p.strip()]
@property
def dns_resolver_list(self) -> list[str]:
return [r.strip() for r in self.dns_resolvers.split(",") if r.strip()]
@property
def oidc_redirect_uri(self) -> str:
return f"{self.public_url}{self.api_prefix}/auth/retour"
def quota_for_plan(self, plan: str) -> int:
return self.plan_quotas.get(plan, self.plan_quotas.get(self.default_plan, 0))
def check_production_readiness(self) -> list[str]:
"""Retourne la liste des problèmes bloquants pour un démarrage en prod."""
problems: list[str] = []
if not self.is_prod:
return problems
if len(self.session_secret) < 32:
problems.append("LIMIER_SESSION_SECRET absent ou trop court (32 caractères minimum)")
if not self.oidc_client_id or not self.oidc_client_secret:
problems.append("LIMIER_OIDC_CLIENT_ID / LIMIER_OIDC_CLIENT_SECRET absents")
if not self.store_raw_identifiers and len(self.identifier_hash_pepper) < 16:
problems.append("LIMIER_IDENTIFIER_HASH_PEPPER absent alors que le hachage est actif")
if not self.public_url.startswith("https://"):
problems.append("LIMIER_PUBLIC_URL doit être en https en production")
return problems
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
View File
Whitespace-only changes.
+253
View File
@@ -0,0 +1,253 @@
"""Modèle de données.
Une recherche (``Search``) porte N résultats (``Finding``). Les compteurs de
quota vivent dans ``QuotaLedger``, un enregistrement par utilisateur et par
mois, ce qui évite de recompter les recherches à chaque appel.
Toutes les dates sont en UTC et timezone-aware.
"""
from __future__ import annotations
import enum
import uuid
from datetime import UTC, datetime
from sqlalchemy import (
Boolean,
DateTime,
Enum,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
def utcnow() -> datetime:
return datetime.now(UTC)
class Base(DeclarativeBase):
pass
# --------------------------------------------------------------------- enums
class SearchKind(str, enum.Enum):
"""Type de recherche demandé par l'utilisateur."""
USERNAME = "username"
NAME = "name" # « prénom nom » -> permutations -> moteur username
EMAIL = "email"
DOMAIN = "domain"
class SearchStatus(str, enum.Enum):
QUEUED = "queued"
RUNNING = "running"
DONE = "done"
FAILED = "failed"
CANCELLED = "cancelled"
class Confidence(str, enum.Enum):
"""Niveau de confiance calculé par ``engines.scoring``.
CONFIRMED : la page porte des signaux de profil réels (avatar, nom affiché,
données structurées extraites).
PROBABLE : le site répond « compte existant » sans donnée exploitable.
WEAK : correspondance obtenue sur un site à fort taux de faux positifs
ou via un miroir.
"""
CONFIRMED = "confirmed"
PROBABLE = "probable"
WEAK = "weak"
# --------------------------------------------------------------------- tables
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
subject: Mapped[str] = mapped_column(String(255), unique=True, index=True)
"""``sub`` OIDC renvoyé par Authentik. Identifiant stable."""
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
plan: Mapped[str] = mapped_column(String(32), default="gratuit", nullable=False)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_blocked: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
searches: Mapped[list[Search]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class Search(Base):
__tablename__ = "searches"
__table_args__ = (
Index("ix_searches_user_created", "user_id", "created_at"),
Index("ix_searches_expires", "expires_at"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
)
kind: Mapped[SearchKind] = mapped_column(Enum(SearchKind, name="search_kind"), nullable=False)
status: Mapped[SearchStatus] = mapped_column(
Enum(SearchStatus, name="search_status"), default=SearchStatus.QUEUED, nullable=False
)
query_display: Mapped[str | None] = mapped_column(String(512), nullable=True)
"""Terme recherché en clair. NULL si le hachage RGPD est actif."""
query_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
"""SHA-256(poivre + terme normalisé). Sert au cache et à la déduplication."""
options: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
"""Options de la recherche (profondeur, tags, variantes demandées…)."""
progress_total: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
progress_done: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
findings_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, nullable=False
)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
"""Date de purge automatique (voir privacy.retention)."""
user: Mapped[User | None] = relationship(back_populates="searches")
findings: Mapped[list[Finding]] = relationship(
back_populates="search", cascade="all, delete-orphan", passive_deletes=True
)
@property
def duration_seconds(self) -> float | None:
if self.started_at and self.finished_at:
return (self.finished_at - self.started_at).total_seconds()
return None
class Finding(Base):
"""Un résultat : un compte, un enregistrement DNS, une entrée RDAP…"""
__tablename__ = "findings"
__table_args__ = (
Index("ix_findings_search_confidence", "search_id", "confidence"),
UniqueConstraint("search_id", "module", "source", "subject", name="uq_finding_identity"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
search_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("searches.id", ondelete="CASCADE"), nullable=False, index=True
)
module: Mapped[str] = mapped_column(String(64), nullable=False)
"""Moteur émetteur : ``username``, ``email.gravatar``, ``domain.rdap``…"""
source: Mapped[str] = mapped_column(String(128), nullable=False)
"""Nom du site ou de la source. Ex. ``GitHub``, ``crt.sh``."""
subject: Mapped[str] = mapped_column(String(512), nullable=False)
"""Identifiant trouvé sur cette source (pseudo, sous-domaine, adresse…)."""
url: Mapped[str | None] = mapped_column(Text, nullable=True)
confidence: Mapped[Confidence] = mapped_column(
Enum(Confidence, name="finding_confidence"), nullable=False
)
score: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
"""Score brut 0..1 ayant produit le niveau de confiance."""
signals: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
"""Signaux ayant motivé le score (has_avatar, has_display_name, rank…)."""
profile: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
"""Données de profil extraites : nom, bio, abonnés, date d'inscription…"""
tags: Mapped[list] = mapped_column(JSONB, default=list, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
search: Mapped[Search] = relationship(back_populates="findings")
class QuotaLedger(Base):
"""Consommation mensuelle. Un enregistrement par utilisateur et par mois."""
__tablename__ = "quota_ledger"
__table_args__ = (UniqueConstraint("user_id", "period", name="uq_quota_user_period"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
period: Mapped[str] = mapped_column(String(7), nullable=False) # AAAA-MM
used: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow
)
class SuppressionRequest(Base):
"""Demande d'effacement (RGPD article 17) reçue via le formulaire public."""
__tablename__ = "suppression_requests"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
identifier_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
identifier_display: Mapped[str | None] = mapped_column(String(512), nullable=True)
contact_email: Mapped[str | None] = mapped_column(String(320), nullable=True)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
handled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
handled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
deleted_rows: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class BlockedIdentifier(Base):
"""Liste d'exclusion : identifiants qu'on refuse de rechercher.
Alimentée par les demandes d'effacement traitées et par l'administration.
"""
__tablename__ = "blocked_identifiers"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
identifier_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class AuditEvent(Base):
"""Journal d'exploitation. Ne contient jamais l'identifiant en clair."""
__tablename__ = "audit_events"
__table_args__ = (Index("ix_audit_created", "created_at"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
action: Mapped[str] = mapped_column(String(64), nullable=False)
detail: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
client_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+64
View File
@@ -0,0 +1,64 @@
"""Fabrique de sessions SQLAlchemy asynchrones.
Un seul moteur par processus. ``pool_pre_ping`` évite les « server closed the
connection » après une coupure réseau ou un redémarrage de PostgreSQL, ce qui
arrive nécessairement dans un homelab.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from limier.config import get_settings
_moteur: AsyncEngine | None = None
_fabrique: async_sessionmaker[AsyncSession] | None = None
def moteur() -> AsyncEngine:
global _moteur
if _moteur is None:
settings = get_settings()
_moteur = create_async_engine(
str(settings.database_url),
pool_pre_ping=True,
pool_size=10,
max_overflow=10,
echo=False,
)
return _moteur
def fabrique_session() -> async_sessionmaker[AsyncSession]:
global _fabrique
if _fabrique is None:
_fabrique = async_sessionmaker(moteur(), class_=AsyncSession, expire_on_commit=False)
return _fabrique
@asynccontextmanager
async def session() -> AsyncIterator[AsyncSession]:
"""Session transactionnelle : validée en sortie, annulée en cas d'exception."""
async with fabrique_session()() as s:
try:
yield s
await s.commit()
except Exception:
await s.rollback()
raise
async def fermer() -> None:
global _moteur, _fabrique
if _moteur is not None:
await _moteur.dispose()
_moteur = None
_fabrique = None
Whitespace-only changes.
+122
View File
@@ -0,0 +1,122 @@
"""Contrat commun à tous les moteurs de recherche.
Un moteur est un objet asynchrone qui reçoit une requête normalisée et **émet un
flux d'évènements** plutôt que de rendre un résultat final. C'est ce qui permet
d'afficher la progression en direct dans l'interface : le worker relaie chaque
évènement vers Redis, l'API les repousse en SSE.
Ajouter un moteur = créer un module dans ``engines/<nom>/engine.py`` exposant une
classe qui implémente ``Engine``, puis l'enregistrer dans ``engines/registry.py``.
Rien d'autre dans le reste du code n'a besoin de changer.
"""
from __future__ import annotations
import abc
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from typing import Any, Literal
from limier.db.models import Confidence, SearchKind
# --------------------------------------------------------------------- requête
@dataclass(slots=True)
class EngineRequest:
"""Requête normalisée transmise à un moteur.
``term`` est déjà nettoyé et validé par ``api.schemas``. ``variants`` n'est
renseigné que pour les recherches de type ``name`` (permutations
« prénom nom »).
"""
kind: SearchKind
term: str
variants: list[str] = field(default_factory=list)
deep: bool = False
tags: list[str] = field(default_factory=list)
options: dict[str, Any] = field(default_factory=dict)
# ------------------------------------------------------------------ évènements
@dataclass(slots=True)
class ProgressEvent:
"""Avancement. ``done``/``total`` alimentent la barre de progression."""
type: Literal["progress"] = "progress"
module: str = ""
done: int = 0
total: int = 0
label: str | None = None
@dataclass(slots=True)
class FindingEvent:
"""Un résultat exploitable, déjà scoré.
``subject`` est l'identifiant trouvé sur la source : un pseudo pour un
compte, un sous-domaine pour un enregistrement CT, une adresse pour un
résultat e-mail.
"""
type: Literal["finding"] = "finding"
module: str = ""
source: str = ""
subject: str = ""
url: str | None = None
confidence: Confidence = Confidence.PROBABLE
score: float = 0.0
signals: dict[str, Any] = field(default_factory=dict)
profile: dict[str, Any] = field(default_factory=dict)
tags: list[str] = field(default_factory=list)
@dataclass(slots=True)
class NoticeEvent:
"""Information non bloquante : module désactivé, source injoignable, quota
d'une API tierce atteint. Remontée telle quelle dans l'interface pour que
l'utilisateur sache ce qui n'a **pas** été vérifié."""
type: Literal["notice"] = "notice"
module: str = ""
level: Literal["info", "warning", "error"] = "info"
message: str = ""
EngineEvent = ProgressEvent | FindingEvent | NoticeEvent
# -------------------------------------------------------------------- contrat
class Engine(abc.ABC):
"""Interface que tout moteur doit implémenter."""
name: str
"""Identifiant court, utilisé comme préfixe de ``module`` (``username``…)."""
supported_kinds: tuple[SearchKind, ...]
def accepts(self, kind: SearchKind) -> bool:
return kind in self.supported_kinds
@abc.abstractmethod
def estimate_total(self, request: EngineRequest) -> int:
"""Nombre d'unités de travail prévues, pour la barre de progression.
Approximation acceptable : la progression réelle est recalée par les
``ProgressEvent`` émis pendant l'exécution.
"""
@abc.abstractmethod
def run(self, request: EngineRequest) -> AsyncIterator[EngineEvent]:
"""Exécute la recherche et émet le flux d'évènements.
Doit être tolérant aux pannes : une source injoignable produit un
``NoticeEvent`` de niveau ``warning``, jamais une exception qui
interromprait les autres modules.
"""
raise NotImplementedError
Whitespace-only changes.
+139
View File
@@ -0,0 +1,139 @@
"""Découverte de sous-domaines par les journaux de transparence des certificats.
Toute autorité de certification publique doit publier chaque certificat émis
dans des journaux publics (Certificate Transparency). C'est la meilleure source
passive de sous-domaines : elle révèle même les hôtes qui ne sont plus résolus,
et sans envoyer une seule requête vers l'infrastructure cible.
Deux sources, dans cet ordre :
1. **crt.sh** — le plus complet, gratuit, sans clé. Son défaut est connu et
documenté : il s'appuie sur un PostgreSQL partagé qui sature régulièrement.
Les indisponibilités et les délais dépassés sont fréquents, ce n'est pas une
anomalie de notre côté.
2. **Certspotter (SSLMate)** — bascule automatique quand crt.sh ne répond pas.
Consultable sans compte, avec un débit limité par IP.
Chaque nom découvert est vérifié en DNS pour distinguer ce qui est encore actif
de ce qui est historique : la distinction compte beaucoup à l'usage.
"""
from __future__ import annotations
import asyncio
from typing import Any
import structlog
from limier.config import get_settings
from limier.net.http import fetch_json
log = structlog.get_logger(__name__)
CRTSH = "https://crt.sh/"
CERTSPOTTER = "https://api.certspotter.com/v1/issuances"
def _noms_valides(noms: set[str], domaine: str) -> list[str]:
"""Nettoie les noms : retire les jokers, garde ce qui appartient au domaine."""
propres = set()
suffixe = f".{domaine}"
for nom in noms:
nom = nom.strip().lower().rstrip(".")
if not nom or " " in nom:
continue
nom = nom.removeprefix("*.")
if nom == domaine or nom.endswith(suffixe):
propres.add(nom)
return sorted(propres)
async def _via_crtsh(domaine: str) -> tuple[list[str], str | None]:
settings = get_settings()
reponse = await fetch_json(
CRTSH,
params={"q": f"%.{domaine}", "output": "json"},
timeout=settings.crtsh_timeout_seconds,
essais=1, # crt.sh est lent : réessayer aggrave la saturation
)
if not reponse.ok:
return [], reponse.erreur or "crt.sh indisponible"
noms: set[str] = set()
for entree in reponse.data or []:
# name_value contient les SAN séparés par des retours à la ligne.
for ligne in (entree.get("name_value") or "").split("\n"):
noms.add(ligne)
if entree.get("common_name"):
noms.add(entree["common_name"])
return _noms_valides(noms, domaine), None
async def _via_certspotter(domaine: str) -> tuple[list[str], str | None]:
reponse = await fetch_json(
CERTSPOTTER,
params={
"domain": domaine,
"include_subdomains": "true",
"expand": "dns_names",
},
essais=1,
)
if not reponse.ok:
return [], reponse.erreur or "Certspotter indisponible"
noms: set[str] = set()
for entree in reponse.data or []:
for nom in entree.get("dns_names") or []:
noms.add(nom)
return _noms_valides(noms, domaine), None
async def sous_domaines(domaine: str) -> dict[str, Any]:
"""Retourne ``{"noms": [...], "source": str, "erreur": str|None}``."""
settings = get_settings()
domaine = domaine.lower().strip().strip(".")
noms, erreur = await _via_crtsh(domaine)
source = "crt.sh"
if not noms and settings.certspotter_fallback:
log.info("bascule_certspotter", domaine=domaine, raison=erreur)
noms, erreur2 = await _via_certspotter(domaine)
source = "Certspotter"
if not noms:
return {
"noms": [],
"source": None,
"erreur": f"crt.sh : {erreur} — Certspotter : {erreur2}",
}
erreur = None
tronque = len(noms) > settings.ct_max_subdomains
return {
"noms": noms[: settings.ct_max_subdomains],
"source": source,
"erreur": erreur,
"tronque": tronque,
"total_trouve": len(noms),
}
async def filtrer_actifs(noms: list[str], concurrence: int = 20) -> dict[str, list[str]]:
"""Sépare les noms qui résolvent encore de ceux qui sont historiques."""
from limier.engines.domain import dns_records
semaphore = asyncio.Semaphore(concurrence)
async def verifier(nom: str) -> tuple[str, bool]:
async with semaphore:
a = await dns_records.interroger(nom, "A")
if a:
return nom, True
aaaa = await dns_records.interroger(nom, "AAAA")
return nom, bool(aaaa)
resultats = await asyncio.gather(*(verifier(n) for n in noms))
actifs = [n for n, ok in resultats if ok]
historiques = [n for n, ok in resultats if not ok]
return {"actifs": actifs, "historiques": historiques}
@@ -0,0 +1,97 @@
"""Interrogation DNS asynchrone, partagée par les moteurs domaine et e-mail.
dnspython fournit un résolveur asynchrone (``dns.asyncresolver``) : on l'utilise
avec des résolveurs publics explicites plutôt que ceux de l'hôte, pour que les
résultats ne dépendent pas du DNS local du conteneur (qui, dans ce homelab,
pointe sur un Technitium interne susceptible de filtrer ou de mettre en cache).
"""
from __future__ import annotations
import asyncio
from typing import Any
import structlog
from limier.config import get_settings
log = structlog.get_logger(__name__)
TYPES_STANDARD = ("A", "AAAA", "MX", "NS", "TXT", "SOA", "CAA")
def _resolveur():
import dns.asyncresolver
settings = get_settings()
r = dns.asyncresolver.Resolver(configure=False)
r.nameservers = settings.dns_resolver_list or ["1.1.1.1"]
r.timeout = 5.0
r.lifetime = 8.0
return r
async def interroger(nom: str, type_enr: str) -> list[str]:
"""Retourne les valeurs d'un type d'enregistrement, liste vide si absent."""
import dns.resolver
try:
reponse = await _resolveur().resolve(nom, type_enr)
except (
dns.resolver.NXDOMAIN,
dns.resolver.NoAnswer,
dns.resolver.NoNameservers,
):
return []
except (TimeoutError, dns.exception.Timeout):
log.debug("dns_delai_depasse", nom=nom, type=type_enr)
return []
except Exception as exc:
log.debug("dns_erreur", nom=nom, type=type_enr, erreur=str(exc))
return []
valeurs = []
for enr in reponse:
texte = enr.to_text()
# Les TXT arrivent entre guillemets et découpés en segments de 255.
if type_enr == "TXT":
texte = texte.replace('" "', "").strip('"')
valeurs.append(texte)
return valeurs
async def enregistrements(
domaine: str, types: tuple[str, ...] = TYPES_STANDARD
) -> dict[str, list[str]]:
"""Interroge tous les types demandés en parallèle."""
resultats = await asyncio.gather(*(interroger(domaine, t) for t in types))
return {t: v for t, v in zip(types, resultats, strict=True) if v}
async def posture_messagerie(domaine: str) -> dict[str, Any]:
"""Évalue la configuration de messagerie d'un domaine.
Utilisée par les deux moteurs : côté domaine pour décrire la posture, côté
e-mail pour dire si l'adresse peut seulement exister (un domaine sans MX ne
reçoit pas de courrier).
"""
mx, spf_txt, dmarc = await asyncio.gather(
interroger(domaine, "MX"),
interroger(domaine, "TXT"),
interroger(f"_dmarc.{domaine}", "TXT"),
)
spf = [t for t in spf_txt if t.lower().startswith("v=spf1")]
politique_dmarc = None
for enr in dmarc:
if "p=" in enr:
politique_dmarc = enr.split("p=", 1)[1].split(";", 1)[0].strip()
break
return {
"mx": mx,
"accepte_courrier": bool(mx),
"spf": spf[0] if spf else None,
"dmarc": dmarc[0] if dmarc else None,
"politique_dmarc": politique_dmarc,
}
+212
View File
@@ -0,0 +1,212 @@
"""Moteur de recherche par nom de domaine.
Le plus simple des trois : toutes les sources sont factuelles et publiques par
conception, aucune ne bloque, aucune n'est ambiguë. Un domaine est enregistré ou
non, un enregistrement DNS existe ou non, un certificat a été émis ou non.
Modules :
``domain.rdap`` titulaire, registraire, dates, statuts, DNSSEC
``domain.dns`` A, AAAA, MX, NS, TXT, SOA, CAA
``domain.mail`` posture de messagerie (SPF, DMARC)
``domain.ct`` sous-domaines via les journaux de transparence
Les sous-domaines découverts sont séparés en « encore résolus » et
« historiques » : cette distinction est ce qui rend la liste exploitable, une
énumération brute mélangeant les deux n'apprend rien.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
import structlog
from limier.config import get_settings
from limier.db.models import Confidence, SearchKind
from limier.engines.base import (
Engine,
EngineEvent,
EngineRequest,
FindingEvent,
NoticeEvent,
ProgressEvent,
)
from limier.engines.domain import ctlogs, dns_records, rdap
from limier.engines.scoring import scorer_fait_technique
log = structlog.get_logger(__name__)
ETAPES = 4
class MoteurDomaine(Engine):
name = "domain"
supported_kinds = (SearchKind.DOMAIN,)
def __init__(self) -> None:
self.settings = get_settings()
def estimate_total(self, request: EngineRequest) -> int:
return ETAPES
async def run(self, request: EngineRequest) -> AsyncIterator[EngineEvent]:
domaine = request.term.strip().lower().strip(".")
faits = 0
# ----------------------------------------------------------- RDAP
yield ProgressEvent(module=self.name, done=faits, total=ETAPES, label="RDAP")
resultat = await rdap.consulter(domaine)
faits += 1
if resultat.get("libre"):
yield NoticeEvent(
module=f"{self.name}.rdap",
level="info",
message=f"{domaine} n'est pas enregistré auprès de son registre.",
)
elif resultat["trouve"]:
donnees = resultat["donnees"]
score, niveau, signaux = scorer_fait_technique(certitude=1.0)
yield FindingEvent(
module=f"{self.name}.rdap",
source=resultat.get("serveur") or "RDAP",
subject=domaine,
url=f"https://{domaine}",
confidence=niveau,
score=score,
signals=signaux,
profile=donnees,
tags=["enregistrement", "rdap"],
)
if not donnees.get("entites"):
yield NoticeEvent(
module=f"{self.name}.rdap",
level="info",
message=(
"Les coordonnées du titulaire sont masquées par le registre. "
"C'est le comportement normal depuis le RGPD."
),
)
else:
yield NoticeEvent(
module=f"{self.name}.rdap",
level="warning",
message=f"RDAP indisponible : {resultat.get('erreur')}",
)
yield ProgressEvent(module=self.name, done=faits, total=ETAPES, label="RDAP")
# ------------------------------------------------------------ DNS
yield ProgressEvent(module=self.name, done=faits, total=ETAPES, label="Enregistrements DNS")
enregistrements = await dns_records.enregistrements(domaine)
faits += 1
if enregistrements:
score, niveau, signaux = scorer_fait_technique(certitude=1.0)
yield FindingEvent(
module=f"{self.name}.dns",
source="DNS",
subject=domaine,
url=None,
confidence=niveau,
score=score,
signals=signaux,
profile=enregistrements,
tags=["dns", "infrastructure"],
)
else:
yield NoticeEvent(
module=f"{self.name}.dns",
level="warning",
message=f"Aucun enregistrement DNS pour {domaine}.",
)
yield ProgressEvent(module=self.name, done=faits, total=ETAPES, label="Enregistrements DNS")
# ------------------------------------------------------- messagerie
yield ProgressEvent(module=self.name, done=faits, total=ETAPES, label="Messagerie")
posture = await dns_records.posture_messagerie(domaine)
faits += 1
if posture["accepte_courrier"]:
score, niveau, signaux = scorer_fait_technique(certitude=1.0)
yield FindingEvent(
module=f"{self.name}.mail",
source="Messagerie",
subject=domaine,
url=None,
confidence=niveau,
score=score,
signals=signaux
| {
"spf_present": bool(posture["spf"]),
"dmarc_present": bool(posture["dmarc"]),
},
profile=posture,
tags=["messagerie"],
)
yield ProgressEvent(module=self.name, done=faits, total=ETAPES, label="Messagerie")
# ------------------------------------------------- transparence TLS
yield ProgressEvent(
module=self.name, done=faits, total=ETAPES, label="Journaux de certificats"
)
ct = await ctlogs.sous_domaines(domaine)
faits += 1
if ct["erreur"] and not ct["noms"]:
yield NoticeEvent(
module=f"{self.name}.ct",
level="warning",
message=(
f"Journaux de transparence inaccessibles ({ct['erreur']}). "
"Les sous-domaines n'ont pas pu être énumérés."
),
)
elif ct["noms"]:
if ct.get("tronque"):
yield NoticeEvent(
module=f"{self.name}.ct",
level="info",
message=(
f"{ct['total_trouve']} noms trouvés, "
f"{len(ct['noms'])} retenus (limite de l'instance)."
),
)
statut = await ctlogs.filtrer_actifs(ct["noms"])
score, niveau, signaux = scorer_fait_technique(certitude=1.0)
yield FindingEvent(
module=f"{self.name}.ct",
source=ct["source"] or "Certificate Transparency",
subject=domaine,
url=None,
confidence=niveau,
score=score,
signals=signaux
| {
"actifs": len(statut["actifs"]),
"historiques": len(statut["historiques"]),
},
profile={
"sous_domaines_actifs": statut["actifs"],
"sous_domaines_historiques": statut["historiques"],
},
tags=["sous-domaines", "tls"],
)
# Chaque sous-domaine encore résolu devient un résultat propre : il
# est susceptible d'être repris comme point de départ d'une autre
# recherche.
for nom in statut["actifs"][:50]:
if nom == domaine:
continue
yield FindingEvent(
module=f"{self.name}.ct",
source="Sous-domaine actif",
subject=nom,
url=f"https://{nom}",
confidence=Confidence.CONFIRMED,
score=0.9,
signals={"resolution_dns": True},
profile={},
tags=["sous-domaine"],
)
yield ProgressEvent(
module=self.name, done=faits, total=ETAPES, label="Journaux de certificats"
)
+195
View File
@@ -0,0 +1,195 @@
"""Interrogation RDAP (successeur structuré de WHOIS).
RDAP remplace WHOIS : la réponse est du JSON normalisé (RFC 9083) au lieu d'un
texte libre dont le format change à chaque registre. Fini l'analyse par
expressions régulières.
**Choix d'implémentation : bootstrap IANA plutôt que rdap.org.** Le service
rdap.org rend le travail facile (une seule URL pour tous les TLD) mais il est
derrière Cloudflare avec une limite de 10 requêtes par tranche de 10 secondes,
et sa documentation invite explicitement les clients réguliers à consommer les
registres de bootstrap directement. On télécharge donc ``dns.json`` (RFC 9224)
une fois par 24 h, on le garde en cache, et on interroge le serveur RDAP du
registre concerné. Pas de tiers sur le chemin, pas de limite partagée.
Le format de ``dns.json`` est une liste de services : chaque entrée est un
couple ``[[tld, tld…], [url_base, …]]``.
"""
from __future__ import annotations
import asyncio
import time
from typing import Any
import structlog
from limier.config import get_settings
from limier.net.http import fetch_json
log = structlog.get_logger(__name__)
_bootstrap: dict[str, list[str]] | None = None
_bootstrap_horodatage: float = 0.0
_verrou = asyncio.Lock()
async def _charger_bootstrap() -> dict[str, list[str]]:
"""Table ``tld -> [urls RDAP]``, rafraîchie selon le TTL configuré."""
global _bootstrap, _bootstrap_horodatage
settings = get_settings()
async with _verrou:
frais = (time.time() - _bootstrap_horodatage) < settings.rdap_bootstrap_ttl_seconds
if _bootstrap is not None and frais:
return _bootstrap
reponse = await fetch_json(settings.rdap_bootstrap_url, essais=2)
if not reponse.ok:
log.warning("bootstrap_rdap_indisponible", erreur=reponse.erreur)
return _bootstrap or {}
table: dict[str, list[str]] = {}
for service in (reponse.data or {}).get("services", []):
if len(service) < 2:
continue
tlds, urls = service[0], service[1]
urls_propres = [u if u.endswith("/") else f"{u}/" for u in urls]
for tld in tlds:
table[tld.lower()] = urls_propres
_bootstrap = table
_bootstrap_horodatage = time.time()
log.info("bootstrap_rdap_charge", tlds=len(table))
return table
async def _serveurs_pour(domaine: str) -> list[str]:
"""Serveurs RDAP compétents, du TLD le plus spécifique au plus général.
Gère les suffixes à plusieurs niveaux : pour ``exemple.co.uk`` on essaie
``co.uk`` puis ``uk``.
"""
table = await _charger_bootstrap()
morceaux = domaine.lower().strip(".").split(".")
for i in range(1, len(morceaux)):
suffixe = ".".join(morceaux[i:])
if suffixe in table:
return table[suffixe]
return []
def _extraire_vcard(entite: dict[str, Any]) -> dict[str, Any]:
"""Aplatit un jCard (RFC 7095) en dictionnaire lisible.
Structure d'entrée : ``["vcard", [["fn", {}, "text", "Nom"], …]]``.
La plupart des registres masquent ces champs depuis le RGPD : l'absence de
valeur est le cas normal, pas une anomalie.
"""
sortie: dict[str, Any] = {}
tableau = entite.get("vcardArray")
if not tableau or len(tableau) < 2:
return sortie
for champ in tableau[1]:
if not isinstance(champ, list) or len(champ) < 4:
continue
nom, valeur = champ[0], champ[3]
if nom == "fn":
sortie["nom"] = valeur
elif nom == "org":
sortie["organisation"] = (
valeur if isinstance(valeur, str) else " ".join(map(str, valeur))
)
elif nom == "email":
sortie["email"] = valeur
elif nom == "tel":
sortie["telephone"] = valeur
elif nom == "adr" and isinstance(valeur, list):
morceaux = [str(v) for v in valeur if v]
if morceaux:
sortie["adresse"] = ", ".join(morceaux)
return sortie
def _normaliser(brut: dict[str, Any]) -> dict[str, Any]:
"""Réduit une réponse RDAP à ce qui est exploitable en OSINT."""
evenements = {}
for ev in brut.get("events", []) or []:
action = ev.get("eventAction")
if action:
evenements[action] = ev.get("eventDate")
entites = []
for entite in brut.get("entities", []) or []:
roles = entite.get("roles") or []
carte = _extraire_vcard(entite)
if carte or roles:
entites.append({"roles": roles, **carte})
serveurs = []
for ns in brut.get("nameservers", []) or []:
nom = ns.get("ldhName")
if nom:
serveurs.append(nom.lower())
return {
"domaine": (brut.get("ldhName") or "").lower(),
"statuts": brut.get("status") or [],
"cree_le": evenements.get("registration"),
"modifie_le": evenements.get("last changed"),
"expire_le": evenements.get("expiration"),
"registraire": _registraire(brut),
"serveurs_de_noms": serveurs,
"entites": entites,
"dnssec": bool(brut.get("secureDNS", {}).get("delegationSigned")),
}
def _registraire(brut: dict[str, Any]) -> str | None:
for entite in brut.get("entities", []) or []:
if "registrar" in (entite.get("roles") or []):
carte = _extraire_vcard(entite)
return carte.get("organisation") or carte.get("nom")
return None
async def consulter(domaine: str) -> dict[str, Any]:
"""Retourne ``{"trouve", "donnees", "erreur", "serveur"}``."""
domaine = domaine.lower().strip().strip(".")
serveurs = await _serveurs_pour(domaine)
if not serveurs:
return {
"trouve": False,
"donnees": {},
"erreur": f"Aucun serveur RDAP connu pour l'extension de {domaine}.",
"serveur": None,
}
dernier_probleme = None
for base in serveurs:
reponse = await fetch_json(f"{base}domain/{domaine}", essais=2)
if reponse.absent:
return {
"trouve": False,
"donnees": {},
"erreur": None,
"serveur": base,
"libre": True,
}
if reponse.ok:
return {
"trouve": True,
"donnees": _normaliser(reponse.data or {}),
"erreur": None,
"serveur": base,
}
dernier_probleme = reponse.erreur
return {
"trouve": False,
"donnees": {},
"erreur": dernier_probleme or "serveur RDAP injoignable",
"serveur": serveurs[0],
}
Whitespace-only changes.
@@ -0,0 +1,47 @@
"""Contrôle des fuites de données via Have I Been Pwned.
L'API « breachedaccount » exige une clé payante depuis 2019 et impose un
en-tête ``hibp-api-key`` ainsi qu'un ``User-Agent`` identifiant l'application.
Sans clé, le module est simplement inactif : on préfère l'absence de résultat à
un résultat faux.
Le débit autorisé dépend du palier souscrit (le plus bas est d'une requête
toutes les 1,5 s). Comme une recherche ne produit qu'un appel, on n'ajoute pas
de limitation côté client : la gestion du 429 dans ``net.http`` suffit.
"""
from __future__ import annotations
from typing import Any
from limier.config import get_settings
from limier.net.http import fetch_json
API = "https://haveibeenpwned.com/api/v3/breachedaccount"
def actif() -> bool:
return bool(get_settings().hibp_api_key.strip())
async def consulter(email: str) -> list[dict[str, Any]]:
"""Retourne les fuites connues. Liste vide si l'adresse n'apparaît nulle part."""
settings = get_settings()
if not actif():
return []
reponse = await fetch_json(
f"{API}/{email}",
headers={
"hibp-api-key": settings.hibp_api_key,
"User-Agent": "Limier-OSINT",
},
params={"truncateResponse": "false"},
essais=2,
)
if reponse.absent:
return [] # 404 = adresse absente de toutes les fuites : c'est un succès
if not reponse.ok:
raise RuntimeError(reponse.erreur or "réponse inattendue")
return reponse.data or []
@@ -0,0 +1,110 @@
"""Qualification du domaine d'une adresse.
Deux questions distinctes, souvent confondues :
- **jetable** : adresse temporaire (yopmail, mailinator…). Un compte rattaché à
une telle adresse ne dit presque rien de son titulaire.
- **grand public** : gmail, outlook, free… Le domaine n'apprend rien, mais
l'adresse reste un identifiant personnel durable.
La liste est volontairement courte et embarquée plutôt que téléchargée : les
listes exhaustives de domaines jetables comptent des dizaines de milliers
d'entrées, changent tous les jours et leur téléchargement au démarrage
introduirait une dépendance réseau pour un gain marginal. Les entrées ci-dessous
couvrent l'immense majorité des cas réellement rencontrés.
Un domaine absent de la liste n'est pas déclaré « non jetable » avec certitude :
c'est un signal, pas un verdict.
"""
from __future__ import annotations
JETABLES = {
"0-mail.com",
"10minutemail.com",
"20minutemail.com",
"33mail.com",
"anonbox.net",
"burnermail.io",
"dispostable.com",
"dropmail.me",
"emailondeck.com",
"fakeinbox.com",
"getairmail.com",
"getnada.com",
"guerrillamail.com",
"guerrillamail.info",
"inboxbear.com",
"jetable.org",
"mail-temporaire.fr",
"mail7.io",
"mailcatch.com",
"maildrop.cc",
"mailinator.com",
"mailnesia.com",
"mintemail.com",
"moakt.com",
"mohmal.com",
"mytemp.email",
"sharklasers.com",
"spam4.me",
"spamgourmet.com",
"temp-mail.org",
"tempail.com",
"tempinbox.com",
"tempmail.net",
"tempmailo.com",
"throwawaymail.com",
"trashmail.com",
"trashmail.de",
"yopmail.com",
"yopmail.fr",
"yopmail.net",
}
GRAND_PUBLIC = {
"aol.com",
"bbox.fr",
"free.fr",
"gmail.com",
"gmx.com",
"gmx.fr",
"hotmail.com",
"hotmail.fr",
"icloud.com",
"laposte.net",
"live.com",
"live.fr",
"mail.com",
"me.com",
"msn.com",
"orange.fr",
"outlook.com",
"outlook.fr",
"protonmail.com",
"proton.me",
"sfr.fr",
"tutanota.com",
"wanadoo.fr",
"yahoo.com",
"yahoo.fr",
"yandex.ru",
"zoho.com",
}
def est_jetable(domaine: str) -> bool:
return domaine.strip().lower() in JETABLES
def est_fournisseur_grand_public(domaine: str) -> bool:
return domaine.strip().lower() in GRAND_PUBLIC
def qualifier(domaine: str) -> str:
d = domaine.strip().lower()
if d in JETABLES:
return "jetable"
if d in GRAND_PUBLIC:
return "grand_public"
return "propre"
+266
View File
@@ -0,0 +1,266 @@
"""Moteur de recherche par adresse e-mail.
Aucun outil libre ne couvre l'e-mail aussi bien que Maigret couvre le
pseudonyme : il faut composer plusieurs sources, chacune apportant une pièce.
Modules exécutés, du plus fiable au plus incertain :
``email.dns``
Le domaine reçoit-il du courrier (MX), avec quelle posture (SPF, DMARC) ?
Factuel, rapide, jamais bloqué. Sert aussi de garde-fou : une adresse sur un
domaine sans MX ne peut pas être un compte actif.
``email.gravatar``
Profil public Gravatar. Quand il existe, c'est la source la plus riche —
nom, localisation, et comptes vérifiés sur d'autres plateformes.
``email.jetable``
Le domaine est-il un fournisseur d'adresses temporaires ? Change
l'interprétation de tous les autres résultats.
``email.fuites``
Have I Been Pwned. Payant, donc désactivé sans clé.
``email.holehe``
Comptes détectés via les formulaires de récupération. Isolé dans un
conteneur séparé (licence GPLv3 + fraîcheur), désactivé par défaut.
``email.pivot``
La partie locale de l'adresse est un candidat pseudonyme sérieux. On la
renvoie comme piste plutôt que de lancer d'office 500 requêtes : c'est
l'utilisateur qui décide de relancer une recherche pseudonyme.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
import structlog
from limier.config import get_settings
from limier.db.models import Confidence, SearchKind
from limier.engines.base import (
Engine,
EngineEvent,
EngineRequest,
FindingEvent,
NoticeEvent,
ProgressEvent,
)
from limier.engines.domain import dns_records
from limier.engines.email import breaches, gravatar, holehe_client
from limier.engines.email.disposable import est_fournisseur_grand_public, est_jetable
from limier.engines.scoring import scorer_compte, scorer_fait_technique
log = structlog.get_logger(__name__)
class MoteurEmail(Engine):
name = "email"
supported_kinds = (SearchKind.EMAIL,)
def __init__(self) -> None:
self.settings = get_settings()
def estimate_total(self, request: EngineRequest) -> int:
total = 3 # dns + gravatar + jetable
if breaches.actif():
total += 1
if holehe_client.actif():
total += 1
return total
async def run(self, request: EngineRequest) -> AsyncIterator[EngineEvent]:
email = request.term.strip().lower()
if "@" not in email:
yield NoticeEvent(module=self.name, level="error", message="Adresse invalide.")
return
partie_locale, _, domaine = email.partition("@")
total = self.estimate_total(request)
faits = 0
# ---------------------------------------------------------- DNS
yield ProgressEvent(module=self.name, done=faits, total=total, label="DNS du domaine")
posture = await dns_records.posture_messagerie(domaine)
faits += 1
if not posture["accepte_courrier"]:
yield NoticeEvent(
module=self.name,
level="warning",
message=(
f"Le domaine {domaine} n'annonce aucun serveur de messagerie (MX). "
"L'adresse ne peut pas recevoir de courrier."
),
)
else:
score, niveau, signaux = scorer_fait_technique(certitude=1.0)
yield FindingEvent(
module=f"{self.name}.dns",
source=domaine,
subject=email,
url=None,
confidence=niveau,
score=score,
signals=signaux,
profile={
"serveurs_mx": posture["mx"],
"spf": posture["spf"],
"dmarc": posture["dmarc"],
"politique_dmarc": posture["politique_dmarc"],
},
tags=["dns", "infrastructure"],
)
yield ProgressEvent(module=self.name, done=faits, total=total, label="DNS du domaine")
# ------------------------------------------------------- jetable
jetable = est_jetable(domaine)
grand_public = est_fournisseur_grand_public(domaine)
faits += 1
if jetable:
yield NoticeEvent(
module=self.name,
level="warning",
message=(
f"{domaine} est un fournisseur d'adresses jetables : tout résultat "
"ci-dessous est à interpréter avec prudence."
),
)
yield ProgressEvent(module=self.name, done=faits, total=total, label="Nature du domaine")
# ------------------------------------------------------ Gravatar
yield ProgressEvent(module=self.name, done=faits, total=total, label="Gravatar")
resultat = await gravatar.consulter(email)
faits += 1
if resultat.get("erreur"):
yield NoticeEvent(
module=f"{self.name}.gravatar",
level="warning",
message=f"Gravatar : {resultat['erreur']}",
)
if resultat["trouve"]:
profil = resultat["profil"]
score, niveau, signaux = scorer_compte(profil=profil, rang_site=100, bonus=0.15)
yield FindingEvent(
module=f"{self.name}.gravatar",
source="Gravatar",
subject=email,
url=profil.get("profile_url"),
confidence=niveau,
score=score,
signals=signaux,
profile=profil,
tags=["profil", "gravatar"],
)
# Les comptes vérifiés déclarés sur Gravatar sont des résultats à
# part entière : ce sont des liens affirmés par le titulaire.
for compte in profil.get("comptes_lies", []) or []:
s, n, sig = scorer_fait_technique(certitude=0.9)
yield FindingEvent(
module=f"{self.name}.gravatar",
source=compte.get("service") or "compte lié",
subject=compte.get("username") or email,
url=compte.get("url"),
confidence=n,
score=s,
signals=sig | {"declare_par_le_titulaire": True},
profile={"via": "Gravatar"},
tags=["compte", "declare"],
)
yield ProgressEvent(module=self.name, done=faits, total=total, label="Gravatar")
# -------------------------------------------------------- fuites
if breaches.actif():
yield ProgressEvent(module=self.name, done=faits, total=total, label="Fuites connues")
try:
fuites = await breaches.consulter(email)
for fuite in fuites:
s, n, sig = scorer_fait_technique(certitude=0.95)
yield FindingEvent(
module=f"{self.name}.fuites",
source=fuite.get("Name") or "fuite",
subject=email,
url=f"https://haveibeenpwned.com/PwnedWebsites#{fuite.get('Name', '')}",
confidence=n,
score=s,
signals=sig,
profile={
"date": fuite.get("BreachDate"),
"comptes_touches": fuite.get("PwnCount"),
"donnees": fuite.get("DataClasses"),
},
tags=["fuite"],
)
except Exception as exc:
yield NoticeEvent(
module=f"{self.name}.fuites",
level="warning",
message=f"Contrôle des fuites indisponible : {exc}",
)
faits += 1
yield ProgressEvent(module=self.name, done=faits, total=total, label="Fuites connues")
# -------------------------------------------------------- holehe
if holehe_client.actif():
yield ProgressEvent(module=self.name, done=faits, total=total, label="Comptes liés")
try:
comptes = await holehe_client.interroger(email)
for compte in comptes:
profil = {
k: v
for k, v in {
"indice_recuperation": compte.get("emailrecovery"),
"indice_telephone": compte.get("phoneNumber"),
"complement": compte.get("others"),
}.items()
if v
}
score, niveau, signaux = scorer_compte(profil=profil, bonus=0.30)
yield FindingEvent(
module=f"{self.name}.holehe",
source=compte.get("name", "?"),
subject=email,
url=None,
confidence=niveau,
score=score,
signals=signaux | {"methode": "formulaire de récupération"},
profile=profil,
tags=["compte"],
)
except holehe_client.HoleheIndisponible as exc:
yield NoticeEvent(
module=f"{self.name}.holehe",
level="warning",
message=f"Module comptes liés indisponible : {exc}",
)
faits += 1
yield ProgressEvent(module=self.name, done=faits, total=total, label="Comptes liés")
else:
yield NoticeEvent(
module=f"{self.name}.holehe",
level="info",
message="Module comptes liés désactivé sur cette instance.",
)
# --------------------------------------------------------- pivot
if not jetable and len(partie_locale) >= 3:
yield FindingEvent(
module=f"{self.name}.pivot",
source="Piste pseudonyme",
subject=partie_locale,
url=None,
confidence=Confidence.WEAK if grand_public else Confidence.PROBABLE,
score=0.35 if grand_public else 0.5,
signals={
"origine": "partie locale de l'adresse",
"fournisseur_grand_public": grand_public,
},
profile={
"action": (
"Lancer une recherche par pseudonyme sur cet identifiant pour "
"élargir la collecte."
)
},
tags=["pivot"],
)
@@ -0,0 +1,89 @@
"""Recherche de profil Gravatar.
Source particulièrement utile en OSINT e-mail : Gravatar est adossé à WordPress
et son profil public porte souvent nom, localisation, biographie et surtout des
comptes liés sur d'autres plateformes — soit exactement le pivot qu'on cherche.
Détail d'implémentation qui casse les intégrations anciennes : Gravatar est
passé de MD5 à **SHA-256**. L'empreinte se calcule sur l'adresse nettoyée
(espaces retirés) et mise en minuscules.
Deux points d'entrée :
- ``https://api.gravatar.com/v3/profiles/{hash}`` — riche, 100 requêtes/heure
sans clé, 1000 avec. 404 si aucun profil.
- ``https://www.gravatar.com/avatar/{hash}?d=404`` — présence d'un avatar,
non décompté du quota.
"""
from __future__ import annotations
import hashlib
from typing import Any
from limier.config import get_settings
from limier.net.http import fetch_json, head_ok
API = "https://api.gravatar.com/v3/profiles"
AVATAR = "https://www.gravatar.com/avatar"
def empreinte(email: str) -> str:
"""SHA-256 de l'adresse nettoyée et minuscule (spécification Gravatar)."""
return hashlib.sha256(email.strip().lower().encode("utf-8")).hexdigest()
async def consulter(email: str) -> dict[str, Any]:
"""Retourne ``{"trouve": bool, "profil": {...}, "erreur": str|None}``."""
settings = get_settings()
h = empreinte(email)
headers = {"Accept": "application/json"}
if settings.gravatar_api_key:
headers["Authorization"] = f"Bearer {settings.gravatar_api_key}"
reponse = await fetch_json(f"{API}/{h}", headers=headers, essais=2)
if reponse.absent:
return {"trouve": False, "profil": {}, "erreur": None, "hash": h}
if not reponse.ok:
avatar = await head_ok(f"{AVATAR}/{h}?d=404")
if avatar.ok:
return {
"trouve": True,
"profil": {"avatar_url": f"{AVATAR}/{h}"},
"erreur": "Profil illisible, seule la présence d'un avatar est confirmée.",
"hash": h,
}
return {"trouve": False, "profil": {}, "erreur": reponse.erreur, "hash": h}
brut = reponse.data or {}
profil = {
"display_name": brut.get("display_name"),
"avatar_url": brut.get("avatar_url"),
"profile_url": brut.get("profile_url"),
"location": brut.get("location"),
"job_title": brut.get("job_title"),
"company": brut.get("company"),
"description": brut.get("description"),
"pronunciation": brut.get("pronunciation"),
"pronouns": brut.get("pronouns"),
}
comptes = []
for compte in brut.get("verified_accounts") or []:
comptes.append(
{
"service": compte.get("service_label") or compte.get("service_type"),
"url": compte.get("url"),
"username": compte.get("username"),
}
)
if comptes:
profil["comptes_lies"] = comptes
return {
"trouve": True,
"profil": {k: v for k, v in profil.items() if v},
"erreur": None,
"hash": h,
}
@@ -0,0 +1,82 @@
"""Client du microservice holehe.
**Pourquoi un service séparé et non une dépendance Python.**
1. Licence. holehe est publié sous GPLv3 alors que Limier est sous licence MIT.
L'importer dans notre code contaminerait l'ensemble du projet. En le gardant
dans un conteneur distinct qui dialogue en HTTP, holehe reste un *programme
séparé* : sa licence s'applique à son image, pas à la nôtre. Le Dockerfile et
le code de ce service vivent dans ``services/holehe/``.
2. Fraîcheur. holehe n'a plus de publication réelle depuis fin 2023 et ses
modules cassent au fil des changements chez les plateformes. L'isoler permet
de le mettre à jour, le remplacer ou l'éteindre sans toucher à Limier.
3. Méthode. holehe s'appuie sur les formulaires « mot de passe oublié » des
plateformes. C'est ce qui le rend efficace mais aussi ce qui le rend
intrusif : chaque contrôle sollicite un service tiers avec l'adresse
recherchée. Le module est donc **désactivé par défaut**
(``LIMIER_HOLEHE_SERVICE_URL`` vide) et doit être activé en conscience.
"""
from __future__ import annotations
from typing import Any
import structlog
from limier.config import get_settings
from limier.net.http import get_client
log = structlog.get_logger(__name__)
class HoleheIndisponible(Exception):
pass
def actif() -> bool:
return bool(get_settings().holehe_service_url.strip())
async def interroger(email: str) -> list[dict[str, Any]]:
"""Retourne la liste des sites où l'adresse est reconnue.
Chaque entrée : ``{"name", "exists", "rateLimit", "emailrecovery",
"phoneNumber", "others"}`` — format natif de holehe, conservé tel quel.
"""
settings = get_settings()
base = settings.holehe_service_url.rstrip("/")
client = await get_client()
try:
reponse = await client.post(
f"{base}/check",
json={"email": email},
timeout=settings.holehe_timeout_seconds,
)
except Exception as exc:
raise HoleheIndisponible(f"service injoignable : {exc}") from exc
if reponse.status_code >= 400:
raise HoleheIndisponible(f"HTTP {reponse.status_code}")
corps = reponse.json()
resultats = corps.get("results", [])
return [r for r in resultats if r.get("exists")]
async def sante() -> dict[str, Any]:
if not actif():
return {"actif": False, "etat": "desactive"}
base = get_settings().holehe_service_url.rstrip("/")
client = await get_client()
try:
reponse = await client.get(f"{base}/sante", timeout=5.0)
return {
"actif": True,
"etat": "ok" if reponse.status_code < 400 else "degrade",
"detail": reponse.json() if reponse.status_code < 400 else None,
}
except Exception as exc:
return {"actif": True, "etat": "injoignable", "detail": str(exc)}
+58
View File
@@ -0,0 +1,58 @@
"""Aiguillage entre types de recherche et moteurs.
Seul endroit du code qui connaît la liste des moteurs. Ajouter un moteur ne
demande de modifier que ce fichier, plus la configuration pour l'activer.
"""
from __future__ import annotations
import structlog
from limier.config import get_settings
from limier.db.models import SearchKind
from limier.engines.base import Engine
log = structlog.get_logger(__name__)
def moteurs_actifs() -> list[Engine]:
"""Instancie les moteurs activés par la configuration.
Les imports sont différés : le moteur pseudonyme tire Maigret et sa base de
3 300 sites, inutile de payer ce coût dans un processus qui ne l'utilise
pas.
"""
settings = get_settings()
moteurs: list[Engine] = []
if settings.engine_username_enabled:
from limier.engines.username.engine import MoteurPseudonyme
moteurs.append(MoteurPseudonyme())
if settings.engine_email_enabled:
from limier.engines.email.engine import MoteurEmail
moteurs.append(MoteurEmail())
if settings.engine_domain_enabled:
from limier.engines.domain.engine import MoteurDomaine
moteurs.append(MoteurDomaine())
return moteurs
def moteur_pour(kind: SearchKind) -> Engine | None:
"""Retourne le moteur compétent pour un type de recherche."""
for moteur in moteurs_actifs():
if moteur.accepts(kind):
return moteur
return None
def types_disponibles() -> list[str]:
types: set[str] = set()
for moteur in moteurs_actifs():
types.update(k.value for k in moteur.supported_kinds)
return sorted(types)
+161
View File
@@ -0,0 +1,161 @@
"""Notation des résultats : la brique anti-faux-positifs.
Un scanner naïf considère « trouvé » tout site qui répond 200. C'est la
principale source de bruit des outils de recherche par pseudonyme : beaucoup de
sites renvoient 200 sur une page « utilisateur inconnu ».
On part donc du verdict de Maigret, mais on le **pondère par des signaux
observables** avant de le présenter comme un compte réel :
========================= ======= =============================================
Signal Poids Lecture
========================= ======= =============================================
profil extrait +0.35 socid_extractor a rendu des champs exploitables
nom affiché +0.15 la page porte un nom d'utilisateur affiché
avatar +0.10 une image de profil est référencée
métriques sociales +0.10 abonnés, publications, karma, date d'entrée
identifiant interne +0.10 id numérique/UUID propre à la plateforme
site bien classé +0.10 rang Alexa < 10 000 : détection mieux éprouvée
liens vers d'autres comptes +0.05 la page pointe vers d'autres profils
recherche « similaire » -0.25 Maigret a élargi la correspondance (miroir)
site marqué imprécis -0.20 tag ``unchecked`` dans la base de sites
========================= ======= =============================================
Le score brut est borné à [0, 1] puis traduit en trois niveaux. Les seuils sont
volontairement dans ce module et nulle part ailleurs : c'est le seul endroit à
toucher pour rendre l'outil plus ou moins strict.
"""
from __future__ import annotations
from typing import Any
from limier.db.models import Confidence
SEUIL_CONFIRME = 0.60
SEUIL_PROBABLE = 0.30
# Champs de socid_extractor qui ne décrivent pas la personne : ils ne doivent pas
# faire monter le score à eux seuls.
CHAMPS_NON_SIGNIFIANTS = {
"_extractor",
"fediverse_server",
"image", # doublon d'avatar_url sur certains extracteurs
}
CHAMPS_NOM = ("fullname", "full_name", "name", "display_name", "realname", "title")
CHAMPS_AVATAR = ("avatar_url", "avatar", "image", "photo", "picture", "profile_image")
CHAMPS_METRIQUES = (
"follower_count",
"followers",
"following_count",
"posts_count",
"karma",
"reputation",
"repos",
"public_repos",
"created_at",
"reg_date",
"registration_date",
"joined",
)
CHAMPS_ID = ("uid", "id", "user_id", "userid", "gaia_id", "vk_id", "steam_id")
CHAMPS_LIENS = ("links", "website", "url", "external_urls", "social")
def _a_champ(profil: dict[str, Any], candidats: tuple[str, ...]) -> bool:
for cle, valeur in profil.items():
if cle in CHAMPS_NON_SIGNIFIANTS:
continue
cle_norm = cle.lower()
if any(c in cle_norm for c in candidats) and valeur not in (None, "", [], {}):
return True
return False
def nettoyer_profil(profil: dict[str, Any] | None) -> dict[str, Any]:
"""Retire les métadonnées internes avant stockage et affichage."""
if not profil:
return {}
return {
k: v
for k, v in profil.items()
if k not in CHAMPS_NON_SIGNIFIANTS and v not in (None, "", [], {})
}
def scorer_compte(
*,
profil: dict[str, Any] | None,
rang_site: int | None = None,
est_similaire: bool = False,
tags_site: list[str] | None = None,
bonus: float = 0.0,
) -> tuple[float, Confidence, dict[str, Any]]:
"""Calcule le score d'un compte trouvé.
Retourne ``(score, niveau, signaux)``. ``signaux`` est stocké tel quel avec
le résultat : c'est ce qui permet d'expliquer à l'utilisateur *pourquoi* un
résultat est marqué confirmé ou faible.
"""
profil = nettoyer_profil(profil)
tags_site = tags_site or []
signaux: dict[str, Any] = {
"profil_extrait": bool(profil),
"nom_affiche": _a_champ(profil, CHAMPS_NOM),
"avatar": _a_champ(profil, CHAMPS_AVATAR),
"metriques": _a_champ(profil, CHAMPS_METRIQUES),
"identifiant_interne": _a_champ(profil, CHAMPS_ID),
"liens_sortants": _a_champ(profil, CHAMPS_LIENS),
"site_bien_classe": bool(rang_site is not None and 0 < rang_site < 10_000),
"correspondance_elargie": bool(est_similaire),
"site_non_verifie": "unchecked" in tags_site,
}
score = 0.0
if signaux["profil_extrait"]:
score += 0.35
if signaux["nom_affiche"]:
score += 0.15
if signaux["avatar"]:
score += 0.10
if signaux["metriques"]:
score += 0.10
if signaux["identifiant_interne"]:
score += 0.10
if signaux["liens_sortants"]:
score += 0.05
if signaux["site_bien_classe"]:
score += 0.10
if signaux["correspondance_elargie"]:
score -= 0.25
if signaux["site_non_verifie"]:
score -= 0.20
score += bonus
score = max(0.0, min(1.0, score))
if score >= SEUIL_CONFIRME:
niveau = Confidence.CONFIRMED
elif score >= SEUIL_PROBABLE:
niveau = Confidence.PROBABLE
else:
niveau = Confidence.WEAK
signaux["score"] = round(score, 3)
return score, niveau, signaux
def scorer_fait_technique(
*, certitude: float, source_faisant_foi: bool = True
) -> tuple[float, Confidence, dict]:
"""Notation des résultats non ambigus (RDAP, DNS, journaux CT).
Ces sources sont factuelles : soit l'enregistrement existe, soit il n'existe
pas. On leur attribue directement un niveau élevé, sans passer par les
signaux de profil qui n'ont pas de sens ici.
"""
score = max(0.0, min(1.0, certitude))
niveau = Confidence.CONFIRMED if source_faisant_foi and score >= 0.8 else Confidence.PROBABLE
return score, niveau, {"source_faisant_foi": source_faisant_foi, "score": round(score, 3)}
Whitespace-only changes.
@@ -0,0 +1,110 @@
"""Chargement et mise en cache de la base de sites Maigret.
La base (3 300+ sites) pèse plusieurs mégaoctets et son analyse prend une à deux
secondes : on la charge une fois par processus. Elle est rechargée
automatiquement quand le fichier sur disque change, ce qui permet au travail
planifié d'auto-contrôle (``jobs.tasks.selfcheck``) de publier une base corrigée
sans redémarrer les workers.
Maigret sait se mettre à jour seul depuis GitHub une fois par 24 h. On garde ce
comportement mais on le rend explicite et coupable via ``LIMIER_`` : dans un
conteneur sans accès sortant direct, l'échec de mise à jour doit être un
avertissement, pas une erreur fatale.
"""
from __future__ import annotations
import os
import threading
from dataclasses import dataclass
import structlog
log = structlog.get_logger(__name__)
_verrou = threading.Lock()
_cache: _BaseCache | None = None
@dataclass
class _BaseCache:
chemin: str
mtime: float
db: object # MaigretDatabase
def _chemin_base_par_defaut() -> str:
import maigret
return os.path.join(os.path.dirname(maigret.__file__), "resources", "data.json")
def chemin_base() -> str:
"""Chemin du fichier de base effectivement utilisé.
``LIMIER_SITES_DB_PATH`` permet de monter une base maintenue localement
(volume Docker) au lieu de celle livrée dans le paquet, dont le conteneur
est en lecture seule.
"""
surcharge = os.environ.get("LIMIER_SITES_DB_PATH", "").strip()
if surcharge and os.path.isfile(surcharge):
return surcharge
return _chemin_base_par_defaut()
def charger_base(forcer: bool = False):
"""Retourne l'objet ``MaigretDatabase``, en le rechargeant si nécessaire."""
from maigret.sites import MaigretDatabase
global _cache
chemin = chemin_base()
try:
mtime = os.path.getmtime(chemin)
except OSError:
mtime = 0.0
with _verrou:
if not forcer and _cache and _cache.chemin == chemin and _cache.mtime == mtime:
return _cache.db
db = MaigretDatabase().load_from_path(chemin)
_cache = _BaseCache(chemin=chemin, mtime=mtime, db=db)
log.info("base_sites_chargee", chemin=chemin, sites=len(db.sites))
return db
def selectionner_sites(
*, top: int, tags: list[str] | None = None, id_type: str = "username"
) -> dict:
"""Sélection ordonnée des sites à interroger.
``ranked_sites_dict`` trie par rang Alexa et ajoute les miroirs des sites
présents dans le haut du classement (un lecteur tiers d'Instagram reste
interrogé même si Instagram est désactivé). ``disabled=False`` écarte les
sites que l'auto-contrôle a marqués hors service.
"""
db = charger_base()
return db.ranked_sites_dict(
top=top,
tags=tags or [],
disabled=False,
id_type=id_type,
)
def statistiques() -> dict:
"""Résumé de la base, exposé par ``/api/catalogue``."""
db = charger_base()
sites = list(db.sites)
actifs = [s for s in sites if not s.disabled]
tags: dict[str, int] = {}
for site in actifs:
for tag in site.tags:
tags[tag] = tags.get(tag, 0) + 1
return {
"total": len(sites),
"actifs": len(actifs),
"desactives": len(sites) - len(actifs),
"chemin": chemin_base(),
"tags": dict(sorted(tags.items(), key=lambda kv: -kv[1])),
}
@@ -0,0 +1,360 @@
"""Moteur de recherche par pseudonyme, bâti sur Maigret.
Fonctionnement :
1. Sélection des sites (``database.selectionner_sites``).
2. Pour chaque variante de pseudonyme, appel de ``maigret.search`` avec notre
notifier (``notifier.NotifierFile``) et un ``output_container``.
3. Consommation en parallèle de la file du notifier pour émettre la progression
et les résultats au fil de l'eau.
4. Notation de chaque compte trouvé par ``engines.scoring``.
5. Recherche récursive facultative sur les pseudonymes découverts dans les
profils (un compte GitHub qui référence un compte X, par exemple).
Point d'attention : ``maigret.search`` est une coroutine unique qui ne rend la
main qu'à la fin. On la lance donc comme tâche et on lit la file en parallèle,
sans quoi la progression n'arriverait qu'une fois tout terminé.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncIterator
from typing import Any
import structlog
from limier.config import get_settings
from limier.db.models import SearchKind
from limier.engines.base import (
Engine,
EngineEvent,
EngineRequest,
FindingEvent,
NoticeEvent,
ProgressEvent,
)
from limier.engines.scoring import nettoyer_profil, scorer_compte
from limier.engines.username import database, permutations
from limier.engines.username.notifier import NotifierFile
from limier.net.proxy_pool import PoolProxies
log = structlog.get_logger(__name__)
# Maigret écrit beaucoup en niveau DEBUG/INFO sur son propre logger ; on lui en
# donne un dédié et silencieux pour ne pas polluer le journal structuré.
_logger_maigret = logging.getLogger("limier.maigret")
_logger_maigret.setLevel(logging.WARNING)
TAILLE_FILE = 2000
class MoteurPseudonyme(Engine):
name = "username"
supported_kinds = (SearchKind.USERNAME, SearchKind.NAME)
def __init__(self, pool_proxies: PoolProxies | None = None) -> None:
self.settings = get_settings()
self.proxies = pool_proxies or PoolProxies()
# ------------------------------------------------------------------ API
def variantes(self, request: EngineRequest) -> list[str]:
"""Liste des pseudonymes effectivement interrogés."""
if request.variants:
return request.variants
if request.kind is SearchKind.NAME:
return permutations.generer(request.term, self.settings.permutation_max_variants)
return [request.term]
def estimate_total(self, request: EngineRequest) -> int:
top = (
self.settings.username_top_sites_deep
if request.deep
else self.settings.username_top_sites
)
sites = database.selectionner_sites(top=top, tags=request.tags)
return max(1, len(sites) * max(1, len(self.variantes(request))))
async def run(self, request: EngineRequest) -> AsyncIterator[EngineEvent]:
top = (
self.settings.username_top_sites_deep
if request.deep
else self.settings.username_top_sites
)
sites = database.selectionner_sites(top=top, tags=request.tags)
if not sites:
yield NoticeEvent(
module=self.name,
level="error",
message="Aucun site ne correspond aux filtres demandés.",
)
return
variantes = self.variantes(request)
if not variantes:
yield NoticeEvent(
module=self.name, level="error", message="Terme de recherche inexploitable."
)
return
if request.kind is SearchKind.NAME:
yield NoticeEvent(
module=self.name,
level="info",
message=(
f"{len(variantes)} variantes déduites du nom : "
+ ", ".join(variantes[:6])
+ ("…" if len(variantes) > 6 else "")
),
)
total = len(sites) * len(variantes)
# 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}
deja_vus: set[tuple[str, str]] = set()
pseudos_decouverts: dict[str, str] = {}
for index, pseudo in enumerate(variantes, start=1):
label = f"{pseudo} ({index}/{len(variantes)})"
async for evenement in self._chercher_un(
pseudo=pseudo,
sites=sites,
compteur=compteur,
label=label,
deja_vus=deja_vus,
pseudos_decouverts=pseudos_decouverts,
):
yield evenement
# ------------------------------------------------ recherche récursive
if not self.settings.username_recursive_search:
return
nouveaux = [
p for p in pseudos_decouverts if p.lower() not in {v.lower() for v in variantes}
]
nouveaux = nouveaux[:3] # garde-fou : la récursion est coûteuse
if not nouveaux:
return
yield NoticeEvent(
module=self.name,
level="info",
message=(
"Recherche récursive sur les pseudonymes découverts dans les profils : "
+ ", ".join(nouveaux)
),
)
# La récursion n'interroge que le premier cercle de sites : elle sert à
# confirmer un lien, pas à relancer un balayage complet.
sites_recursifs = database.selectionner_sites(top=min(100, top), tags=request.tags)
compteur["total"] += len(sites_recursifs) * len(nouveaux)
for pseudo in nouveaux:
async for evenement in self._chercher_un(
pseudo=pseudo,
sites=sites_recursifs,
compteur=compteur,
label=f"récursif : {pseudo}",
deja_vus=deja_vus,
pseudos_decouverts={}, # pas de récursion au second niveau
origine=pseudos_decouverts.get(pseudo),
):
yield evenement
# -------------------------------------------------------------- interne
async def _chercher_un(
self,
*,
pseudo: str,
sites: dict,
compteur: dict[str, int],
label: str,
deja_vus: set[tuple[str, str]],
pseudos_decouverts: dict[str, str],
origine: str | None = None,
):
"""Exécute une recherche Maigret et émet ses évènements au fil de l'eau.
``compteur`` est mutable et partagé avec l'appelant : il porte
l'avancement cumulé sur l'ensemble des variantes.
"""
import maigret
file: asyncio.Queue = asyncio.Queue(maxsize=TAILLE_FILE)
notifier = NotifierFile(file, total=len(sites))
conteneur: dict[str, Any] = {}
proxy = await self.proxies.obtenir()
tache = asyncio.create_task(
maigret.search(
username=pseudo,
site_dict=sites,
logger=_logger_maigret,
query_notify=notifier,
proxy=proxy,
timeout=self.settings.username_timeout_seconds,
is_parsing_enabled=self.settings.username_parse_profiles,
is_enrich_enabled=self.settings.username_parse_profiles,
id_type="username",
max_connections=self.settings.username_max_connections,
retries=self.settings.username_retries,
no_progressbar=True,
output_container=conteneur,
cloudflare_bypass=self._config_cloudflare(),
),
name=f"maigret:{pseudo}",
)
erreurs_reseau = 0
while True:
if tache.done() and file.empty():
break
try:
charge = await asyncio.wait_for(file.get(), timeout=0.5)
except TimeoutError:
continue
genre = charge.get("kind")
if genre == "site":
compteur["faits"] += 1
statut = charge.get("status", "")
if statut.startswith("Unknown"):
erreurs_reseau += 1
if compteur["faits"] % 10 == 0:
yield ProgressEvent(
module=self.name,
done=compteur["faits"],
total=compteur["total"],
label=label,
)
if not statut.startswith("Claimed"):
continue
cle = (charge.get("site", ""), pseudo)
if cle in deja_vus:
continue
deja_vus.add(cle)
evenement = self._construire_resultat(
charge=charge,
pseudo=pseudo,
conteneur=conteneur,
origine=origine,
)
if evenement is not None:
for decouvert, source in self._pseudos_du_profil(charge).items():
pseudos_decouverts.setdefault(decouvert, source)
yield evenement
elif genre == "notice":
yield NoticeEvent(
module=self.name,
level=charge.get("level", "info"),
message=charge.get("message", ""),
)
try:
await tache
except asyncio.CancelledError:
raise
except Exception as exc: # une variante en échec ne doit pas tout arrêter
log.warning("echec_recherche_pseudo", pseudo=pseudo, erreur=str(exc))
await self.proxies.signaler_echec(proxy)
yield NoticeEvent(
module=self.name,
level="warning",
message=f"Recherche interrompue pour « {pseudo} » : {exc}",
)
return
if erreurs_reseau:
taux = erreurs_reseau / max(1, len(sites))
if taux > 0.30:
await self.proxies.signaler_echec(proxy)
yield NoticeEvent(
module=self.name,
level="warning",
message=(
f"{erreurs_reseau} sites sur {len(sites)} n'ont pas pu être "
"vérifiés (blocage ou délai dépassé). Les résultats sont "
"partiels — voir la sortie réseau dans la documentation."
),
)
else:
await self.proxies.signaler_succes(proxy)
else:
await self.proxies.signaler_succes(proxy)
yield ProgressEvent(
module=self.name,
done=compteur["faits"],
total=compteur["total"],
label=label,
)
def _config_cloudflare(self) -> dict[str, Any] | None:
url = self.settings.flaresolverr_url.strip()
if not url:
return None
return {"enabled": True, "backend": "flaresolverr", "url": url}
def _construire_resultat(
self,
*,
charge: dict[str, Any],
pseudo: str,
conteneur: dict[str, Any],
origine: str | None,
) -> FindingEvent | None:
nom_site = charge.get("site", "")
# Maigret suffixe les miroirs : « GitHubGist [GitHub] ». On garde le nom
# court pour l'affichage et on note le parent dans les signaux.
nom_court = nom_site.split(" [")[0].strip()
brut = conteneur.get(nom_court) or conteneur.get(nom_site) or {}
profil = nettoyer_profil(charge.get("ids_data") or brut.get("ids_data") or {})
rang = brut.get("rank")
if isinstance(rang, int) and rang > 1_000_000_000:
rang = None # sys.maxsize = rang inconnu côté Maigret
score, niveau, signaux = scorer_compte(
profil=profil,
rang_site=rang,
est_similaire=bool(charge.get("is_similar")),
tags_site=charge.get("tags") or [],
)
if origine:
signaux["decouvert_via"] = origine
return FindingEvent(
module=self.name,
source=nom_court,
subject=pseudo,
url=charge.get("url") or brut.get("url_user"),
confidence=niveau,
score=score,
signals=signaux,
profile=profil,
tags=charge.get("tags") or [],
)
@staticmethod
def _pseudos_du_profil(charge: dict[str, Any]) -> dict[str, str]:
"""Extrait les pseudonymes d'autres plateformes cités dans un profil."""
trouves: dict[str, str] = {}
ids = charge.get("ids_data") or {}
site = charge.get("site", "")
for cle, valeur in ids.items():
if not isinstance(valeur, str):
continue
cle_norm = cle.lower()
if "username" in cle_norm or "login" in cle_norm or "nickname" in cle_norm:
candidat = valeur.strip().lstrip("@")
if 2 < len(candidat) < 40 and " " not in candidat:
trouves[candidat] = site
return trouves
@@ -0,0 +1,92 @@
"""Pont entre le système de notification de Maigret et notre flux d'évènements.
Maigret n'expose pas de callback moderne : il attend un objet « notifier » à la
``QueryNotifyPrint``, dont il appelle ``start``, ``update``, ``finish``,
``success``, ``warning``, ``info`` et ``enrich`` pendant la recherche.
On fournit donc un objet conforme à cette interface qui, au lieu d'écrire sur la
sortie standard, dépose les évènements dans une ``asyncio.Queue``. C'est ce qui
permet d'afficher la progression site par site au lieu d'attendre la fin des 500
vérifications.
Important : ces méthodes sont appelées depuis la boucle d'évènements de Maigret,
donc dans le même thread. On utilise ``put_nowait`` pour ne jamais bloquer le
moteur si le consommateur prend du retard, et une file bornée pour éviter qu'une
recherche approfondie ne remplisse la mémoire.
"""
from __future__ import annotations
import asyncio
from typing import Any
import structlog
log = structlog.get_logger(__name__)
class NotifierFile:
"""Implémente l'interface attendue par ``maigret.search(query_notify=…)``."""
def __init__(self, file: asyncio.Queue, total: int) -> None:
self.file = file
self.total = total
self.termines = 0
self.result: Any = None # Maigret réaffecte cet attribut, il doit exister
self.abandons = 0
# -- interface Maigret ------------------------------------------------
def start(self, message: str | None = None, id_type: str = "username") -> None:
self._emettre({"kind": "start", "term": message, "id_type": id_type})
def update(self, result: Any, is_similar: bool = False) -> None:
"""Appelé une fois par site vérifié, trouvé ou non."""
self.result = result
self.termines += 1
self._emettre(
{
"kind": "site",
"site": getattr(result, "site_name", ""),
"status": str(getattr(result, "status", "")),
"url": getattr(result, "site_url_user", None),
"ids_data": getattr(result, "ids_data", None) or {},
"tags": list(getattr(result, "tags", []) or []),
"is_similar": bool(is_similar),
"done": self.termines,
"total": self.total,
}
)
def finish(self, message: str | None = None) -> None:
self._emettre({"kind": "finish", "message": message})
def success(self, message: str, symbol: str = "+") -> None:
self._emettre({"kind": "notice", "level": "info", "message": message})
def warning(self, message: str, symbol: str = "-", advice: str | None = None) -> None:
texte = f"{message} — {advice}" if advice else message
self._emettre({"kind": "notice", "level": "warning", "message": texte})
def info(self, message: str, symbol: str = "*") -> None:
self._emettre({"kind": "notice", "level": "info", "message": message})
def enrich(self, message: str, symbol: str = "*", verbose_only: bool = False) -> None:
if not verbose_only:
self._emettre({"kind": "notice", "level": "info", "message": message})
def __str__(self) -> str: # Maigret appelle str() sur le notifier
return f"<NotifierFile {self.termines}/{self.total}>"
# -- interne ----------------------------------------------------------
def _emettre(self, charge: dict[str, Any]) -> None:
try:
self.file.put_nowait(charge)
except asyncio.QueueFull:
# On préfère perdre un évènement de progression que bloquer la
# recherche. Les résultats, eux, sont relus depuis output_container
# à la fin : aucune perte de données réelle.
self.abandons += 1
if self.abandons in (1, 100, 1000):
log.warning("file_progression_saturee", abandons=self.abandons)
@@ -0,0 +1,99 @@
"""Génération de variantes de pseudonyme depuis un nom civil.
Répond au besoin « prénom nom traité comme une seule identité » : au lieu de
lancer une recherche indépendante par mot, on construit les identifiants
plausibles que la personne a pu choisir, puis on les cherche tous.
Maigret embarque un permutateur (``maigret.permutator.Permute``) mais il produit
un volume ingérable pour un service hébergé : avec trois mots et quatre
séparateurs, on dépasse la centaine de variantes, soit autant de fois 500
requêtes. On garde donc notre propre génération, **ordonnée par probabilité**,
et on tronque.
L'ordre retenu suit la fréquence réelle observée dans les conventions de
nommage : ``prenomnom`` et ``pnom`` avant ``nom-prenom``.
"""
from __future__ import annotations
import re
import unicodedata
SEPARATEURS = ("", ".", "_", "-")
_NON_ALNUM = re.compile(r"[^a-z0-9]+")
def normaliser(mot: str) -> str:
"""Minuscule, sans accent, sans caractère spécial.
« Frédéric » -> « frederic », « O'Brien » -> « obrien ».
"""
sans_accent = unicodedata.normalize("NFKD", mot)
sans_accent = "".join(c for c in sans_accent if not unicodedata.combining(c))
return _NON_ALNUM.sub("", sans_accent.lower())
def decouper(terme: str) -> list[str]:
"""Découpe un terme libre en composants normalisés et non vides."""
morceaux = [normaliser(m) for m in re.split(r"[\s,;]+", terme.strip()) if m.strip()]
return [m for m in morceaux if m]
def generer(terme: str, maximum: int = 12) -> list[str]:
"""Retourne les variantes les plus probables, sans doublon, ordonnées.
Un terme d'un seul mot est retourné tel quel : ce n'est pas un nom civil,
c'est déjà un pseudonyme.
"""
mots = decouper(terme)
if not mots:
return []
if len(mots) == 1:
return mots[:maximum]
prenom, nom = mots[0], mots[-1]
intermediaires = mots[1:-1]
candidats: list[str] = []
def ajouter(valeur: str) -> None:
if valeur and valeur not in candidats and len(valeur) >= 3:
candidats.append(valeur)
# 1. Les formes les plus courantes, dans l'ordre de fréquence observée.
for sep in SEPARATEURS:
ajouter(f"{prenom}{sep}{nom}")
ajouter(f"{prenom[0]}{nom}")
for sep in (".", "_", "-"):
ajouter(f"{prenom[0]}{sep}{nom}")
for sep in SEPARATEURS:
ajouter(f"{nom}{sep}{prenom}")
ajouter(f"{nom}{prenom[0]}")
# 2. Initiales complètes (jdc pour Jean De Cornet).
initiales = "".join(m[0] for m in mots)
if len(initiales) >= 3:
ajouter(initiales)
# 3. Deuxième prénom éventuel, uniquement en forme collée.
for milieu in intermediaires:
ajouter(f"{prenom}{milieu}{nom}")
ajouter(f"{prenom}{milieu[0]}{nom}")
# 4. Les composants seuls en dernier recours : très bruyants, donc en queue.
ajouter(nom)
ajouter(prenom)
return candidats[:maximum]
def decrire(terme: str, maximum: int = 12) -> dict:
"""Aperçu renvoyé à l'interface avant lancement, pour que l'utilisateur
voie ce qui va réellement être cherché et puisse ajuster."""
variantes = generer(terme, maximum)
return {
"terme": terme,
"composants": decouper(terme),
"variantes": variantes,
"tronque": len(variantes) >= maximum,
}
Whitespace-only changes.
+24
View File
@@ -0,0 +1,24 @@
"""Point d'entrée du service API : ``limier-api``."""
from __future__ import annotations
def main() -> None:
import uvicorn
from limier.config import get_settings
settings = get_settings()
uvicorn.run(
"limier.api.app:app",
host="0.0.0.0",
port=8000,
log_config=None, # structlog gère déjà la sortie
access_log=not settings.is_prod,
proxy_headers=True,
forwarded_allow_ips="*", # confiance limitée au réseau Docker interne
)
if __name__ == "__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
"""Point d'entrée du worker : ``limier-worker``.
Trois travaux planifiés :
- auto-contrôle des sites, à 3 h — après la fenêtre où Maigret publie sa base
mise à jour, avant les heures d'usage ;
- purge de conservation, toutes les heures ;
- libération des recherches bloquées, toutes les dix minutes.
"""
from __future__ import annotations
from typing import ClassVar
from arq import cron
from arq.connections import RedisSettings
from limier.config import get_settings
from limier.jobs.queue import FILE_RECHERCHES, parametres_redis
from limier.jobs.tasks import (
auto_controle_sites,
executer_recherche,
purger_donnees,
relancer_recherches_bloquees,
)
from limier.logging import configurer as configurer_journal
async def au_demarrage(ctx: dict) -> None:
configurer_journal()
import structlog
structlog.get_logger(__name__).info(
"worker_demarre", concurrence=get_settings().worker_max_jobs
)
async def a_l_arret(ctx: dict) -> None:
from limier.db.session import fermer as fermer_base
from limier.jobs.queue import fermer_redis
from limier.net.http import close_client
await close_client()
await fermer_redis()
await fermer_base()
class WorkerSettings:
# arq lit ces attributs de classe : ils doivent rester des listes mutables
# au niveau de la classe, d'où les annotations ClassVar.
functions: ClassVar[list] = [
executer_recherche,
auto_controle_sites,
purger_donnees,
relancer_recherches_bloquees,
]
cron_jobs: ClassVar[list] = [
cron(auto_controle_sites, hour=3, minute=0, run_at_startup=False),
cron(purger_donnees, minute=7),
cron(relancer_recherches_bloquees, minute={4, 14, 24, 34, 44, 54}),
]
on_startup = au_demarrage
on_shutdown = a_l_arret
queue_name = FILE_RECHERCHES
max_jobs = get_settings().worker_max_jobs
job_timeout = get_settings().job_timeout_seconds
keep_result = get_settings().job_result_ttl_seconds
max_tries = 1 # une recherche échouée n'est pas rejouée : elle consommerait
# un second crédit et frapperait les sites une fois de plus
@staticmethod
def redis_settings() -> RedisSettings:
return parametres_redis()
def main() -> None:
from arq.worker import run_worker
WorkerSettings.redis_settings = parametres_redis()
run_worker(WorkerSettings)
if __name__ == "__main__":
main()
View File
Whitespace-only changes.
+99
View File
@@ -0,0 +1,99 @@
"""Canal de progression entre le worker et le flux SSE.
Le worker exécute la recherche, l'API sert le flux : deux processus distincts.
Le lien passe par Redis, avec deux structures complémentaires :
- une **liste** ``limier:progression:<id>:evenements`` qui conserve tous les
évènements déjà émis. C'est elle qui permet à un navigateur de se reconnecter
en cours de route — ou d'ouvrir la page trois secondes après le lancement —
sans rien perdre ;
- un **canal de publication** ``limier:progression:<id>:canal`` pour réveiller
immédiatement les flux en écoute, sans interrogation en boucle.
Les deux expirent avec ``job_result_ttl_seconds`` : la source de vérité durable,
c'est PostgreSQL, pas Redis.
"""
from __future__ import annotations
import json
from dataclasses import asdict, is_dataclass
from typing import Any
from uuid import UUID
import structlog
from limier.config import get_settings
from limier.jobs.queue import get_redis
log = structlog.get_logger(__name__)
PREFIXE = "limier:progression"
def _cles(recherche_id: UUID | str) -> tuple[str, str]:
base = f"{PREFIXE}:{recherche_id}"
return f"{base}:evenements", f"{base}:canal"
def serialiser(evenement: Any) -> str:
"""Convertit un évènement de moteur en JSON transmissible."""
if is_dataclass(evenement) and not isinstance(evenement, type):
charge = asdict(evenement)
elif isinstance(evenement, dict):
charge = dict(evenement)
else:
charge = {"type": "notice", "message": str(evenement)}
# Les énumérations (Confidence) ne sont pas sérialisables telles quelles.
for cle, valeur in list(charge.items()):
if hasattr(valeur, "value"):
charge[cle] = valeur.value
return json.dumps(charge, ensure_ascii=False, default=str)
async def publier(recherche_id: UUID | str, evenement: Any) -> None:
cle_liste, cle_canal = _cles(recherche_id)
charge = serialiser(evenement)
try:
redis = await get_redis()
tube = redis.pipeline()
tube.rpush(cle_liste, charge)
tube.expire(cle_liste, get_settings().job_result_ttl_seconds)
tube.publish(cle_canal, charge)
await tube.execute()
except Exception as exc:
# La progression est un confort : son échec ne doit jamais faire
# échouer la recherche elle-même.
log.warning("publication_progression_echouee", erreur=str(exc))
async def historique(recherche_id: UUID | str, depuis: int = 0) -> list[str]:
"""Évènements déjà émis, à partir de l'index donné."""
cle_liste, _ = _cles(recherche_id)
redis = await get_redis()
return await redis.lrange(cle_liste, depuis, -1)
async def ecouter(recherche_id: UUID | str):
"""Générateur asynchrone des nouveaux évènements publiés."""
_, cle_canal = _cles(recherche_id)
redis = await get_redis()
abonnement = redis.pubsub()
await abonnement.subscribe(cle_canal)
try:
async for message in abonnement.listen():
if message.get("type") == "message":
yield message["data"]
finally:
await abonnement.unsubscribe(cle_canal)
await abonnement.aclose()
async def purger(recherche_id: UUID | str) -> None:
cle_liste, _ = _cles(recherche_id)
try:
redis = await get_redis()
await redis.delete(cle_liste)
except Exception:
pass
+64
View File
@@ -0,0 +1,64 @@
"""Connexion Redis et configuration de la file de travaux ARQ.
Choix d'ARQ plutôt que Celery : Maigret est asynchrone de bout en bout, ARQ
l'est aussi. Celery aurait imposé un pont entre son modèle de travailleurs
synchrones et une boucle asyncio, pour aucun gain fonctionnel ici.
Redis porte trois usages distincts, séparés par préfixe de clé :
``arq:*`` la file elle-même (géré par ARQ)
``limier:progression:*`` l'état vivant d'une recherche, relu par le flux SSE
``limier:proxy:*`` la santé du pool de proxies
"""
from __future__ import annotations
from typing import Any
import redis.asyncio as aioredis
import structlog
from arq.connections import RedisSettings
from limier.config import get_settings
log = structlog.get_logger(__name__)
_redis: aioredis.Redis | None = None
FILE_RECHERCHES = "limier:recherches"
def parametres_redis() -> RedisSettings:
"""Traduit l'URL de configuration en réglages ARQ."""
return RedisSettings.from_dsn(str(get_settings().redis_url))
async def get_redis() -> aioredis.Redis:
"""Client Redis partagé, en mode texte (decode_responses)."""
global _redis
if _redis is None:
_redis = aioredis.from_url(
str(get_settings().redis_url),
encoding="utf-8",
decode_responses=True,
health_check_interval=30,
)
return _redis
async def fermer_redis() -> None:
global _redis
if _redis is not None:
await _redis.aclose()
_redis = None
async def empiler(fonction: str, *args: Any, **kwargs: Any) -> str | None:
"""Dépose un travail dans la file et retourne son identifiant ARQ."""
from arq import create_pool
pool = await create_pool(parametres_redis())
try:
travail = await pool.enqueue_job(fonction, *args, _queue_name=FILE_RECHERCHES, **kwargs)
return travail.job_id if travail else None
finally:
await pool.aclose()
+289
View File
@@ -0,0 +1,289 @@
"""Travaux exécutés par le worker.
Trois travaux :
``executer_recherche``
Orchestre une recherche : sélectionne le moteur, consomme son flux
d'évènements, publie la progression vers Redis et écrit les résultats en
base au fil de l'eau.
``auto_controle_sites``
Travail planifié nocturne. Vérifie la base de sites de Maigret contre les
sites réels et désactive ceux qui ne répondent plus correctement. C'est ce
qui évite la dérive silencieuse — le défaut principal des scanners de
pseudonymes après quelques mois.
``purger_donnees``
Applique la politique de conservation (chantier RGPD).
Les résultats sont enregistrés **pendant** la recherche, pas à la fin : une
recherche interrompue à 80 % conserve ses 80 % de résultats.
"""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import UUID
import structlog
from sqlalchemy import select, update
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.registry import moteur_pour
from limier.jobs import progress
log = structlog.get_logger(__name__)
INTERVALLE_ECRITURE = 2.0
"""Fréquence de mise à jour du compteur de progression en base, en secondes.
Écrire à chaque site vérifié produirait 500 UPDATE par recherche pour aucun
bénéfice : l'affichage temps réel passe par Redis, la base ne sert qu'à
retrouver l'état après rechargement de la page.
"""
async def executer_recherche(ctx: dict[str, Any], recherche_id: str) -> dict[str, Any]:
"""Point d'entrée ARQ. ``recherche_id`` est l'UUID de la ligne ``searches``."""
identifiant = UUID(recherche_id)
structlog.contextvars.bind_contextvars(recherche=recherche_id)
async with db_session.session() as s:
recherche = await s.get(Search, identifiant)
if recherche is None:
log.warning("recherche_introuvable")
return {"statut": "introuvable"}
if recherche.status is not SearchStatus.QUEUED:
log.info("recherche_deja_traitee", statut=recherche.status.value)
return {"statut": recherche.status.value}
recherche.status = SearchStatus.RUNNING
recherche.started_at = datetime.now(UTC)
kind = recherche.kind
options = dict(recherche.options or {})
terme = options.get("term") or recherche.query_display
if not terme:
# Le terme n'est pas persisté quand la minimisation RGPD est active :
# il voyage alors uniquement dans les options du travail, en mémoire.
await _terminer(identifiant, SearchStatus.FAILED, "Terme de recherche indisponible.")
return {"statut": "echec"}
moteur = moteur_pour(kind)
if moteur is None:
await _terminer(
identifiant, SearchStatus.FAILED, f"Aucun moteur actif pour « {kind.value} »."
)
return {"statut": "echec"}
requete = EngineRequest(
kind=kind,
term=terme,
variants=options.get("variants") or [],
deep=bool(options.get("deep")),
tags=options.get("tags") or [],
options=options,
)
total_estime = await asyncio.to_thread(moteur.estimate_total, requete)
await progress.publier(
identifiant,
{"type": "debut", "module": moteur.name, "total": total_estime},
)
async with db_session.session() as s:
await s.execute(
update(Search).where(Search.id == identifiant).values(progress_total=total_estime)
)
resultats = 0
faits = 0
derniere_ecriture = 0.0
erreur: str | None = None
try:
async for evenement in moteur.run(requete):
await progress.publier(identifiant, evenement)
if isinstance(evenement, FindingEvent):
if await _enregistrer_resultat(identifiant, evenement):
resultats += 1
elif isinstance(evenement, ProgressEvent):
faits = evenement.done
maintenant = asyncio.get_running_loop().time()
if maintenant - derniere_ecriture > INTERVALLE_ECRITURE:
derniere_ecriture = maintenant
await _ecrire_progression(identifiant, faits, resultats)
elif isinstance(evenement, NoticeEvent) and evenement.level == "error":
log.warning("moteur_signale_erreur", message=evenement.message)
except asyncio.CancelledError:
await _terminer(identifiant, SearchStatus.CANCELLED, "Recherche annulée.", faits, resultats)
raise
except Exception as exc:
log.exception("echec_recherche")
erreur = f"{type(exc).__name__}: {exc}"
await _terminer(identifiant, SearchStatus.FAILED, erreur, faits, resultats)
await progress.publier(identifiant, {"type": "fin", "statut": "failed", "erreur": erreur})
return {"statut": "echec", "erreur": erreur}
await _terminer(identifiant, SearchStatus.DONE, None, faits, resultats)
await progress.publier(identifiant, {"type": "fin", "statut": "done", "resultats": resultats})
log.info("recherche_terminee", resultats=resultats, verifications=faits)
return {"statut": "termine", "resultats": resultats}
async def _enregistrer_resultat(recherche_id: UUID, evenement: FindingEvent) -> bool:
"""Écrit un résultat. Retourne False si c'était un doublon.
La contrainte d'unicité ``(search_id, module, source, subject)`` fait le
travail de déduplication côté base : inutile de tenir un ensemble en
mémoire, qui serait faux dès qu'un second worker reprend la recherche.
"""
from sqlalchemy.dialects.postgresql import insert
valeurs = {
"search_id": recherche_id,
"module": evenement.module,
"source": evenement.source[:128],
"subject": evenement.subject[:512],
"url": evenement.url,
"confidence": evenement.confidence,
"score": evenement.score,
"signals": evenement.signals,
"profile": evenement.profile,
"tags": evenement.tags,
}
try:
async with db_session.session() as s:
instruction = (
insert(Finding)
.values(**valeurs)
.on_conflict_do_nothing(constraint="uq_finding_identity")
.returning(Finding.id)
)
resultat = await s.execute(instruction)
return resultat.scalar_one_or_none() is not None
except Exception as exc:
log.warning("ecriture_resultat_echouee", source=evenement.source, erreur=str(exc))
return False
async def _ecrire_progression(recherche_id: UUID, faits: int, resultats: int) -> None:
async with db_session.session() as s:
await s.execute(
update(Search)
.where(Search.id == recherche_id)
.values(progress_done=faits, findings_count=resultats)
)
async def _terminer(
recherche_id: UUID,
statut: SearchStatus,
erreur: str | None = None,
faits: int = 0,
resultats: int = 0,
) -> None:
async with db_session.session() as s:
valeurs: dict[str, Any] = {
"status": statut,
"finished_at": datetime.now(UTC),
"error": erreur,
}
if faits:
valeurs["progress_done"] = faits
if resultats:
valeurs["findings_count"] = resultats
await s.execute(update(Search).where(Search.id == recherche_id).values(**valeurs))
# --------------------------------------------------------------- maintenance
async def auto_controle_sites(ctx: dict[str, Any], auto_desactiver: bool = True) -> dict[str, Any]:
"""Vérifie la base de sites et désactive ceux qui ne répondent plus.
Maigret vérifie chaque site avec un couple « pseudonyme connu / pseudonyme
inexistant » : si le site ne distingue plus les deux, sa détection est
cassée et il produira des faux positifs. C'est exactement le mécanisme qui
manque aux scanners laissés sans entretien.
La base corrigée est écrite dans le volume partagé désigné par
``LIMIER_SITES_DB_PATH``, que les workers rechargent automatiquement.
"""
import logging as stdlog
from maigret.checking import self_check
from limier.engines.username import database
settings = get_settings()
db = database.charger_base(forcer=True)
sites = db.ranked_sites_dict(top=settings.username_top_sites_deep, disabled=True)
log.info("auto_controle_demarre", sites=len(sites))
resultat = await self_check(
db,
sites,
stdlog.getLogger("limier.selfcheck"),
silent=True,
max_connections=10,
auto_disable=auto_desactiver,
no_progressbar=True,
)
if resultat.get("needs_update") and auto_desactiver:
chemin = database.chemin_base()
db.save_to_file(chemin)
database.charger_base(forcer=True)
log.info("base_sites_mise_a_jour", chemin=chemin)
stats = database.statistiques()
return {
"mise_a_jour": bool(resultat.get("needs_update")),
"sites_actifs": stats["actifs"],
"sites_desactives": stats["desactives"],
}
async def purger_donnees(ctx: dict[str, Any]) -> dict[str, Any]:
"""Supprime les recherches arrivées à expiration (voir privacy.retention)."""
from limier.privacy.retention import purger_expirees
supprimees = await purger_expirees()
if supprimees:
log.info("purge_effectuee", recherches=supprimees)
return {"recherches_supprimees": supprimees}
async def relancer_recherches_bloquees(ctx: dict[str, Any]) -> dict[str, Any]:
"""Remet en échec les recherches restées en cours après un arrêt brutal.
Sans ce garde-fou, un worker tué pendant une recherche laisse une ligne en
``running`` pour toujours, et l'interface affiche une barre de progression
qui n'avance plus.
"""
limite = datetime.now(UTC) - timedelta(seconds=get_settings().job_timeout_seconds * 2)
async with db_session.session() as s:
resultat = await s.execute(
select(Search.id).where(
Search.status == SearchStatus.RUNNING, Search.started_at < limite
)
)
identifiants = [r[0] for r in resultat.all()]
if identifiants:
await s.execute(
update(Search)
.where(Search.id.in_(identifiants))
.values(
status=SearchStatus.FAILED,
finished_at=datetime.now(UTC),
error="Interrompue : le worker s'est arrêté pendant l'exécution.",
)
)
return {"recherches_liberees": len(identifiants)}
+83
View File
@@ -0,0 +1,83 @@
"""Journalisation structurée.
En production le journal sort en JSON, directement consommable par Graylog ou
Wazuh sans analyse syntaxique. En développement, rendu lisible en console.
Règle absolue tenue par ce module : **aucun identifiant recherché ne doit
apparaître dans le journal**. Le processeur ``masquer_identifiants`` retire les
clés sensibles de tout évènement, quelle que soit la négligence de l'appelant.
"""
from __future__ import annotations
import logging
import sys
from typing import Any
import structlog
from limier.config import get_settings
CLES_SENSIBLES = {
"term",
"terme",
"email",
"username",
"pseudo",
"query",
"query_display",
"identifier",
"identifiant",
"password",
"secret",
"token",
"client_secret",
"authorization",
"api_key",
}
def masquer_identifiants(logger, methode, evenement: dict[str, Any]) -> dict[str, Any]:
for cle in list(evenement.keys()):
if cle.lower() in CLES_SENSIBLES:
valeur = evenement[cle]
if isinstance(valeur, str) and valeur:
evenement[cle] = f"<masqué:{len(valeur)}>"
else:
evenement[cle] = "<masqué>"
return evenement
def configurer() -> None:
settings = get_settings()
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=getattr(logging, settings.log_level),
)
# Les bibliothèques tierces sont bruyantes en INFO.
for nom in ("httpx", "httpcore", "aiohttp", "asyncio", "arq"):
logging.getLogger(nom).setLevel(logging.WARNING)
processeurs = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt="iso", utc=True),
masquer_identifiants,
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
]
if settings.log_format == "json":
processeurs.append(structlog.processors.JSONRenderer())
else:
processeurs.append(structlog.dev.ConsoleRenderer(colors=True))
structlog.configure(
processors=processeurs,
wrapper_class=structlog.make_filtering_bound_logger(getattr(logging, settings.log_level)),
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
View File
Whitespace-only changes.
+124
View File
@@ -0,0 +1,124 @@
"""Client HTTP partagé pour les moteurs e-mail et domaine.
Un seul client httpx par processus : la réutilisation des connexions divise par
deux le temps des interrogations RDAP et CT, qui frappent les mêmes hôtes en
rafale.
``fetch_json`` centralise ce que chaque appelant referait sinon de son côté :
délai maximal, nombre d'essais, respect du 429, et surtout **jamais d'exception
propagée**. Les moteurs doivent pouvoir continuer quand une source publique est
en panne — c'est le cas courant avec crt.sh.
"""
from __future__ import annotations
import asyncio
from typing import Any
import httpx
import structlog
from limier.config import get_settings
log = structlog.get_logger(__name__)
_client: httpx.AsyncClient | None = None
_verrou = asyncio.Lock()
async def get_client() -> httpx.AsyncClient:
global _client
if _client is None or _client.is_closed:
async with _verrou:
if _client is None or _client.is_closed:
settings = get_settings()
_client = httpx.AsyncClient(
timeout=httpx.Timeout(20.0, connect=10.0),
follow_redirects=True,
headers={"User-Agent": settings.outbound_user_agent},
limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
)
return _client
async def close_client() -> None:
global _client
if _client is not None and not _client.is_closed:
await _client.aclose()
_client = None
class ResultatHttp:
"""Issue d'un appel : succès, absence (404), ou indisponibilité."""
__slots__ = ("data", "erreur", "ok", "status")
def __init__(self, ok: bool, status: int | None, data: Any = None, erreur: str | None = None):
self.ok = ok
self.status = status
self.data = data
self.erreur = erreur
@property
def absent(self) -> bool:
return self.status == 404
def __repr__(self) -> str:
return f"<ResultatHttp ok={self.ok} status={self.status} erreur={self.erreur}>"
async def fetch_json(
url: str,
*,
essais: int = 2,
timeout: float | None = None,
headers: dict[str, str] | None = None,
params: dict[str, Any] | None = None,
) -> ResultatHttp:
"""Récupère un document JSON sans jamais lever d'exception."""
client = await get_client()
dernier = "inconnue"
for tentative in range(1, essais + 1):
try:
reponse = await client.get(
url,
headers=headers,
params=params,
timeout=timeout or 20.0,
)
if reponse.status_code == 404:
return ResultatHttp(False, 404)
if reponse.status_code == 429:
attente = min(5.0 * tentative, 15.0)
log.info("source_limitee", url=url, attente=attente)
if tentative < essais:
await asyncio.sleep(attente)
continue
return ResultatHttp(False, 429, erreur="quota de la source atteint")
if reponse.status_code >= 400:
return ResultatHttp(
False, reponse.status_code, erreur=f"HTTP {reponse.status_code}"
)
return ResultatHttp(True, reponse.status_code, reponse.json())
except (TimeoutError, httpx.TimeoutException):
dernier = "délai dépassé"
except httpx.HTTPError as exc:
dernier = f"erreur réseau ({type(exc).__name__})"
except ValueError:
return ResultatHttp(False, None, erreur="réponse illisible (JSON invalide)")
if tentative < essais:
await asyncio.sleep(1.0 * tentative)
return ResultatHttp(False, None, erreur=dernier)
async def head_ok(url: str, *, timeout: float = 10.0) -> ResultatHttp:
"""Vérifie l'existence d'une ressource sans télécharger son contenu."""
client = await get_client()
try:
reponse = await client.head(url, timeout=timeout)
return ResultatHttp(reponse.status_code < 400, reponse.status_code)
except httpx.HTTPError as exc:
return ResultatHttp(False, None, erreur=str(exc))
+121
View File
@@ -0,0 +1,121 @@
"""Pool de proxies sortants avec mise à l'écart des proxies défaillants.
Pourquoi c'est nécessaire : une recherche par pseudonyme émet 500 requêtes vers
500 domaines en quelques secondes. Depuis une IP résidentielle ou une IP de
centre de données, cela déclenche rapidement des blocages (403, CAPTCHA, 429) et
le taux de sites non vérifiables grimpe. Faire tourner la sortie sur plusieurs
proxies étale la charge et rend le service exploitable dans la durée.
Le pool est volontairement simple et sans dépendance : l'état de santé vit dans
Redis, donc partagé entre tous les workers, avec une mise à l'écart temporaire
au bout de N échecs consécutifs.
Sans proxy configuré (``LIMIER_PROXY_STRATEGY=none``), toutes les méthodes sont
des non-opérations et le trafic sort en direct.
"""
from __future__ import annotations
import random
import structlog
from limier.config import get_settings
log = structlog.get_logger(__name__)
PREFIXE = "limier:proxy"
class PoolProxies:
def __init__(self, redis=None) -> None:
self.settings = get_settings()
self._redis = redis
self._compteur = 0
@property
def actif(self) -> bool:
return self.settings.proxy_strategy != "none" and bool(self.settings.proxy_list)
async def _client(self):
if self._redis is None:
from limier.jobs.queue import get_redis
self._redis = await get_redis()
return self._redis
async def _en_quarantaine(self, proxy: str) -> bool:
try:
client = await self._client()
return bool(await client.exists(f"{PREFIXE}:quarantaine:{proxy}"))
except Exception:
return False # Redis indisponible : on n'empêche pas la recherche
async def obtenir(self) -> str | None:
"""Retourne l'URL du prochain proxy à utiliser, ou None pour le direct."""
if not self.actif:
return None
candidats = []
for proxy in self.settings.proxy_list:
if not await self._en_quarantaine(proxy):
candidats.append(proxy)
if not candidats:
log.warning("tous_proxies_en_quarantaine", total=len(self.settings.proxy_list))
return None
if self.settings.proxy_strategy == "random":
return random.choice(candidats)
self._compteur += 1
return candidats[self._compteur % len(candidats)]
async def signaler_echec(self, proxy: str | None) -> None:
if not proxy or not self.actif:
return
try:
client = await self._client()
cle = f"{PREFIXE}:echecs:{proxy}"
echecs = await client.incr(cle)
await client.expire(cle, self.settings.proxy_cooldown_seconds)
if echecs >= self.settings.proxy_failure_threshold:
await client.setex(
f"{PREFIXE}:quarantaine:{proxy}",
self.settings.proxy_cooldown_seconds,
"1",
)
await client.delete(cle)
log.warning("proxy_mis_en_quarantaine", proxy=_masquer(proxy), echecs=echecs)
except Exception as exc:
log.debug("suivi_proxy_indisponible", erreur=str(exc))
async def signaler_succes(self, proxy: str | None) -> None:
if not proxy or not self.actif:
return
try:
client = await self._client()
await client.delete(f"{PREFIXE}:echecs:{proxy}")
except Exception:
pass
async def etat(self) -> list[dict]:
"""Vue de l'état du pool, exposée par /api/admin/proxies."""
resultat = []
for proxy in self.settings.proxy_list:
resultat.append(
{
"proxy": _masquer(proxy),
"quarantaine": await self._en_quarantaine(proxy),
}
)
return resultat
def _masquer(proxy: str) -> str:
"""Retire les identifiants de l'URL avant tout affichage ou journalisation."""
if "@" not in proxy:
return proxy
schema, reste = proxy.split("://", 1) if "://" in proxy else ("", proxy)
hote = reste.split("@", 1)[1]
return f"{schema}://***@{hote}" if schema else f"***@{hote}"
Whitespace-only changes.
@@ -0,0 +1,79 @@
"""Métriques Prometheus.
Exposées sur ``/api/metriques``. Le Prometheus du homelab peut les collecter
directement ; aucune donnée personnelle n'y figure, seulement des compteurs.
Les libellés sont volontairement à faible cardinalité : ``kind`` et ``status``
prennent quatre ou cinq valeurs. Un libellé par site ou par utilisateur ferait
exploser le nombre de séries.
"""
from __future__ import annotations
from prometheus_client import CollectorRegistry, Counter, Gauge, Histogram, generate_latest
REGISTRE = CollectorRegistry()
recherches_creees = Counter(
"limier_recherches_creees_total",
"Recherches acceptées par l'API",
["kind"],
registry=REGISTRE,
)
recherches_terminees = Counter(
"limier_recherches_terminees_total",
"Recherches achevées, par état final",
["kind", "status"],
registry=REGISTRE,
)
duree_recherche = Histogram(
"limier_duree_recherche_secondes",
"Durée d'exécution d'une recherche",
["kind"],
buckets=(5, 15, 30, 60, 120, 300, 600),
registry=REGISTRE,
)
resultats_emis = Counter(
"limier_resultats_total",
"Résultats enregistrés, par niveau de confiance",
["module", "confidence"],
registry=REGISTRE,
)
sites_desactives = Gauge(
"limier_sites_desactives",
"Sites marqués hors service dans la base Maigret",
registry=REGISTRE,
)
sites_actifs = Gauge(
"limier_sites_actifs",
"Sites interrogeables dans la base Maigret",
registry=REGISTRE,
)
refus = Counter(
"limier_refus_total",
"Requêtes refusées, par motif",
["motif"],
registry=REGISTRE,
)
def rendu() -> bytes:
"""Actualise les jauges puis sérialise le registre."""
try:
from limier.config import get_settings
if get_settings().engine_username_enabled:
from limier.engines.username import database
stats = database.statistiques()
sites_actifs.set(stats["actifs"])
sites_desactives.set(stats["desactives"])
except Exception:
pass
return generate_latest(REGISTRE)
Whitespace-only changes.
+47
View File
@@ -0,0 +1,47 @@
"""Normalisation et hachage des identifiants recherchés.
Deux besoins distincts :
1. **Déduplication et cache.** Deux recherches du même terme doivent produire la
même empreinte, quelle que soit la casse ou les espaces.
2. **Minimisation RGPD.** Quand ``LIMIER_STORE_RAW_IDENTIFIERS`` vaut False, la
base ne contient plus le terme en clair : seule l'empreinte est conservée.
Une demande d'effacement reste possible (on rehache le terme fourni pour
retrouver les enregistrements), mais la base seule ne permet plus de
reconstituer la liste des personnes recherchées.
Le hachage est salé par un poivre applicatif (``LIMIER_IDENTIFIER_HASH_PEPPER``)
et non par un sel par enregistrement : sans cela, une même recherche produirait
des empreintes différentes et la déduplication comme l'effacement deviendraient
impossibles. Le poivre doit donc vivre dans Vault, pas dans la base.
"""
from __future__ import annotations
import hashlib
import re
from limier.config import get_settings
_ESPACES = re.compile(r"\s+")
def normaliser(terme: str, kind: str = "") -> str:
"""Forme canonique d'un terme, indépendante de la casse et des espaces."""
valeur = _ESPACES.sub(" ", terme.strip()).lower()
if kind in ("domain", "email"):
valeur = valeur.strip(".")
return valeur
def empreinte(terme: str, kind: str = "") -> str:
"""SHA-256 du terme normalisé, préfixé par le poivre applicatif."""
settings = get_settings()
canon = normaliser(terme, kind)
base = f"{settings.identifier_hash_pepper}:{kind}:{canon}".encode()
return hashlib.sha256(base).hexdigest()
def valeur_stockable(terme: str) -> str | None:
"""Terme à écrire en base, ou None si la minimisation est active."""
return terme if get_settings().store_raw_identifiers else None
+135
View File
@@ -0,0 +1,135 @@
"""Politique de conservation et droit à l'effacement.
Ce que le service conserve, et pourquoi :
===================== ========================== ===============================
Donnée Durée Justification
===================== ========================== ===============================
Recherches + résultats ``LIMIER_RETENTION_DAYS`` Permettre à l'utilisateur de
(30 jours par défaut) relire son travail.
Compte utilisateur Tant que le compte existe Gestion de l'accès et du quota.
Compteurs de quota 13 mois Contrôle de la facturation.
Journal d'exploitation 90 jours Détection d'abus. Ne contient
jamais l'identifiant en clair.
Demandes d'effacement Permanente Preuve du traitement de la
demande (obligation de
l'article 17).
===================== ========================== ===============================
La purge est un travail planifié : ``expires_at`` est posé à la création de
chaque recherche, ce qui rend la suppression indépendante d'un recalcul et
permet de prolonger une recherche précise sans toucher à la politique globale.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import delete, func, select
from limier.config import get_settings
from limier.db import session as db_session
from limier.db.models import (
AuditEvent,
BlockedIdentifier,
QuotaLedger,
Search,
SuppressionRequest,
)
from limier.jobs import progress
log = structlog.get_logger(__name__)
RETENTION_AUDIT_JOURS = 90
RETENTION_QUOTA_MOIS = 13
def date_expiration() -> datetime:
return datetime.now(UTC) + timedelta(days=get_settings().retention_days)
async def purger_expirees() -> int:
"""Supprime les recherches échues. Les résultats suivent en cascade."""
maintenant = datetime.now(UTC)
async with db_session.session() as s:
resultat = await s.execute(select(Search.id).where(Search.expires_at < maintenant))
identifiants = [r[0] for r in resultat.all()]
if identifiants:
await s.execute(delete(Search).where(Search.id.in_(identifiants)))
limite_audit = maintenant - timedelta(days=RETENTION_AUDIT_JOURS)
await s.execute(delete(AuditEvent).where(AuditEvent.created_at < limite_audit))
limite_quota = (maintenant - timedelta(days=30 * RETENTION_QUOTA_MOIS)).strftime("%Y-%m")
await s.execute(delete(QuotaLedger).where(QuotaLedger.period < limite_quota))
for identifiant in identifiants:
await progress.purger(identifiant)
return len(identifiants)
async def effacer_identifiant(empreinte_terme: str, bloquer: bool = True) -> int:
"""Supprime toutes les recherches portant sur un identifiant donné.
Appelée au traitement d'une demande d'effacement. Quand ``bloquer`` est vrai,
l'identifiant est ajouté à la liste d'exclusion : les recherches futures sur
ce terme seront refusées, sans quoi l'effacement serait sans effet dès la
requête suivante.
"""
async with db_session.session() as s:
resultat = await s.execute(select(Search.id).where(Search.query_hash == empreinte_terme))
identifiants = [r[0] for r in resultat.all()]
if identifiants:
await s.execute(delete(Search).where(Search.id.in_(identifiants)))
if bloquer:
existe = await s.execute(
select(BlockedIdentifier.id).where(
BlockedIdentifier.identifier_hash == empreinte_terme
)
)
if existe.scalar_one_or_none() is None:
s.add(
BlockedIdentifier(
identifier_hash=empreinte_terme,
note="Demande d'effacement (RGPD art. 17)",
)
)
for identifiant in identifiants:
await progress.purger(identifiant)
log.info("effacement_applique", recherches=len(identifiants), bloque=bloquer)
return len(identifiants)
async def est_bloque(empreinte_terme: str) -> bool:
async with db_session.session() as s:
resultat = await s.execute(
select(BlockedIdentifier.id).where(BlockedIdentifier.identifier_hash == empreinte_terme)
)
return resultat.scalar_one_or_none() is not None
async def resume() -> dict:
"""Indicateurs affichés dans l'espace d'administration."""
async with db_session.session() as s:
recherches = await s.scalar(select(func.count()).select_from(Search))
bloques = await s.scalar(select(func.count()).select_from(BlockedIdentifier))
demandes = await s.scalar(
select(func.count())
.select_from(SuppressionRequest)
.where(SuppressionRequest.handled.is_(False))
)
plus_ancienne = await s.scalar(select(func.min(Search.created_at)))
return {
"recherches_conservees": recherches or 0,
"identifiants_bloques": bloques or 0,
"demandes_en_attente": demandes or 0,
"plus_ancienne": plus_ancienne.isoformat() if plus_ancienne else None,
"retention_jours": get_settings().retention_days,
"identifiants_en_clair": get_settings().store_raw_identifiers,
}
Whitespace-only changes.
+150
View File
@@ -0,0 +1,150 @@
"""Quotas mensuels et limitation de débit.
Deux garde-fous complémentaires :
- **Quota mensuel** : nombre de recherches par utilisateur et par mois, selon
son plan. Compté en base (``QuotaLedger``) parce qu'il doit survivre à une
purge de Redis et rester auditable.
- **Limitation de débit** : nombre de recherches par heure, compté dans Redis
avec une fenêtre glissante simple. Protège l'infrastructure et les sites
tiers ; sa perte en cas de redémarrage de Redis est sans conséquence.
Le plan ``interne`` (quota -1) désigne les membres du groupe Authentik
``GL-Limier`` : aucune limite mensuelle, mais la limitation horaire s'applique
quand même — elle protège les sites interrogés, pas le budget.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from uuid import UUID
import structlog
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from limier.config import get_settings
from limier.db.models import QuotaLedger, User
from limier.jobs.queue import get_redis
log = structlog.get_logger(__name__)
@dataclass(slots=True)
class EtatQuota:
plan: str
limite: int # -1 = illimité
consomme: int
periode: str
@property
def illimite(self) -> bool:
return self.limite < 0
@property
def restant(self) -> int | None:
return None if self.illimite else max(0, self.limite - self.consomme)
@property
def depasse(self) -> bool:
return not self.illimite and self.consomme >= self.limite
def periode_courante() -> str:
maintenant = datetime.now(UTC)
return f"{maintenant.year:04d}-{maintenant.month:02d}"
async def etat(s: AsyncSession, utilisateur: User) -> EtatQuota:
settings = get_settings()
periode = periode_courante()
resultat = await s.execute(
select(QuotaLedger.used).where(
QuotaLedger.user_id == utilisateur.id, QuotaLedger.period == periode
)
)
consomme = resultat.scalar_one_or_none() or 0
return EtatQuota(
plan=utilisateur.plan,
limite=settings.quota_for_plan(utilisateur.plan),
consomme=consomme,
periode=periode,
)
async def consommer(s: AsyncSession, utilisateur: User, cout: int = 1) -> EtatQuota:
"""Incrémente le compteur du mois. À appeler après acceptation du travail.
``ON CONFLICT DO UPDATE`` rend l'opération atomique : deux requêtes
concurrentes du même utilisateur ne peuvent pas se marcher dessus.
"""
periode = periode_courante()
instruction = (
insert(QuotaLedger)
.values(user_id=utilisateur.id, period=periode, used=cout)
.on_conflict_do_update(
constraint="uq_quota_user_period",
set_={"used": QuotaLedger.used + cout, "updated_at": datetime.now(UTC)},
)
.returning(QuotaLedger.used)
)
resultat = await s.execute(instruction)
consomme = resultat.scalar_one()
settings = get_settings()
return EtatQuota(
plan=utilisateur.plan,
limite=settings.quota_for_plan(utilisateur.plan),
consomme=consomme,
periode=periode,
)
async def rembourser(s: AsyncSession, utilisateur: User, cout: int = 1) -> None:
"""Rend le crédit quand le travail n'a pas pu être mis en file."""
periode = periode_courante()
resultat = await s.execute(
select(QuotaLedger).where(
QuotaLedger.user_id == utilisateur.id, QuotaLedger.period == periode
)
)
ligne = resultat.scalar_one_or_none()
if ligne is not None:
ligne.used = max(0, ligne.used - cout)
# ------------------------------------------------------- limitation de débit
async def debit_autorise(cle: str, limite: int, fenetre_secondes: int = 3600) -> tuple[bool, int]:
"""Compteur à fenêtre fixe. Retourne ``(autorisé, restant)``.
Une fenêtre fixe suffit ici : on protège des rafales, pas d'un adversaire
qui chercherait à exploiter la frontière entre deux fenêtres.
"""
try:
redis = await get_redis()
except Exception as exc:
log.warning("limitation_debit_indisponible", erreur=str(exc))
return True, limite # Redis absent : on n'empêche pas le service
cle_redis = f"limier:debit:{cle}"
tube = redis.pipeline()
tube.incr(cle_redis)
tube.expire(cle_redis, fenetre_secondes, nx=True)
resultats = await tube.execute()
compte = int(resultats[0])
return compte <= limite, max(0, limite - compte)
async def verifier_debit_utilisateur(utilisateur_id: UUID | str) -> tuple[bool, int]:
settings = get_settings()
return await debit_autorise(
f"utilisateur:{utilisateur_id}", settings.rate_limit_searches_per_hour
)
async def verifier_debit_ip(ip: str) -> tuple[bool, int]:
settings = get_settings()
return await debit_autorise(f"ip:{ip}", settings.rate_limit_anonymous_per_hour)
+25
View File
@@ -0,0 +1,25 @@
"""Fixtures de test.
Les variables d'environnement sont posées avant tout import de ``limier`` :
``get_settings`` est mis en cache, une valeur lue trop tôt resterait figée.
"""
from __future__ import annotations
import os
os.environ.setdefault("LIMIER_ENVIRONMENT", "dev")
os.environ.setdefault("LIMIER_SESSION_SECRET", "secret-de-test-suffisamment-long-pour-passer")
os.environ.setdefault("LIMIER_IDENTIFIER_HASH_PEPPER", "poivre-de-test")
os.environ.setdefault("LIMIER_DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test")
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(scope="session")
def client() -> TestClient:
from limier.api.app import creer_application
with TestClient(creer_application(), raise_server_exceptions=False) as c:
yield c
+57
View File
@@ -0,0 +1,57 @@
"""Contrat public de l'API."""
from __future__ import annotations
def test_sonde_de_sante(client):
reponse = client.get("/api/sante")
assert reponse.status_code == 200
assert reponse.json()["statut"] == "ok"
def test_non_connecte_voit_un_etat_vide(client):
corps = client.get("/api/auth/moi").json()
assert corps["authenticated"] is False
def test_recherche_exige_une_connexion(client):
reponse = client.post("/api/recherches", json={"kind": "username", "term": "soxoj"})
assert reponse.status_code == 401
def test_apercu_des_variantes_est_public(client):
"""Accessible sans connexion : l'utilisateur doit pouvoir juger de ce qui
sera cherché avant de dépenser un crédit."""
reponse = client.post("/api/recherches/variantes", json={"term": "Hubert Cornet"})
assert reponse.status_code == 200
assert "hubertcornet" in reponse.json()["variantes"]
def test_route_statistiques_non_captee_par_identifiant(client):
"""``/recherches/statistiques/mensuelles`` doit être déclarée avant
``/recherches/{id}``, sinon FastAPI tente d'y lire un UUID (422)."""
reponse = client.get("/api/recherches/statistiques/mensuelles")
assert reponse.status_code == 401 # et surtout pas 422
def test_validation_domaine(client):
reponse = client.post("/api/recherches", json={"kind": "domain", "term": "pas valide"})
assert reponse.status_code in (401, 422)
def test_politique_de_confidentialite_publique(client):
corps = client.get("/api/confidentialite").json()
assert "conservation" in corps
assert "droits" in corps
def test_openapi_expose_toutes_les_routes(client):
chemins = client.get("/api/openapi.json").json()["paths"]
for attendu in (
"/api/recherches",
"/api/recherches/{recherche_id}/flux",
"/api/auth/connexion",
"/api/confidentialite/effacement",
"/api/admin/tableau-de-bord",
):
assert attendu in chemins
+43
View File
@@ -0,0 +1,43 @@
"""Garde-fous de configuration."""
from __future__ import annotations
from limier.config import Settings
def test_production_refuse_une_configuration_incomplete():
settings = Settings(environment="prod", session_secret="", oidc_client_id="")
problemes = settings.check_production_readiness()
assert any("SESSION_SECRET" in p for p in problemes)
assert any("OIDC_CLIENT_ID" in p for p in problemes)
def test_production_exige_le_poivre_si_minimisation_active():
settings = Settings(
environment="prod",
session_secret="x" * 40,
oidc_client_id="id",
oidc_client_secret="secret",
store_raw_identifiers=False,
identifier_hash_pepper="",
)
assert any("PEPPER" in p for p in settings.check_production_readiness())
def test_developpement_ne_bloque_rien():
assert Settings(environment="dev").check_production_readiness() == []
def test_uri_de_redirection_construite_depuis_url_publique():
settings = Settings(public_url="https://limier.tips-of-mine.com/")
assert settings.oidc_redirect_uri == "https://limier.tips-of-mine.com/api/auth/retour"
def test_liste_de_proxies_ignore_les_vides():
settings = Settings(proxy_urls="http://a:1, ,http://b:2 ")
assert settings.proxy_list == ["http://a:1", "http://b:2"]
def test_quota_inconnu_retombe_sur_le_plan_par_defaut():
settings = Settings()
assert settings.quota_for_plan("plan-inexistant") == settings.quota_for_plan("gratuit")
+30
View File
@@ -0,0 +1,30 @@
"""Normalisation et hachage des identifiants."""
from __future__ import annotations
from limier.privacy import identifiers
def test_normalisation_insensible_casse_et_espaces():
assert identifiers.normaliser(" Hubert CORNET ") == "hubert cornet"
def test_empreinte_stable_pour_un_meme_terme():
a = identifiers.empreinte("Test User", "name")
b = identifiers.empreinte(" test user ", "name")
assert a == b
assert len(a) == 64
def test_empreinte_depend_du_type():
"""Le même texte cherché comme pseudo ou comme domaine ne doit pas
produire la même empreinte : un effacement ciblé resterait sinon flou."""
assert identifiers.empreinte("exemple", "username") != identifiers.empreinte(
"exemple", "domain"
)
def test_domaine_insensible_au_point_final():
assert identifiers.empreinte("exemple.fr.", "domain") == identifiers.empreinte(
"exemple.fr", "domain"
)
+40
View File
@@ -0,0 +1,40 @@
"""Génération des variantes de pseudonyme depuis un nom civil."""
from __future__ import annotations
from limier.engines.username import permutations
def test_normalisation_accents_et_ponctuation():
assert permutations.normaliser("Frédéric") == "frederic"
assert permutations.normaliser("O'Brien") == "obrien"
assert permutations.normaliser("Jean-Luc") == "jeanluc"
def test_prenom_nom_produit_les_formes_courantes():
variantes = permutations.generer("Hubert Cornet")
# Les formes les plus probables doivent arriver en tête : c'est l'ordre qui
# détermine ce qui est réellement cherché quand la liste est tronquée.
assert variantes[0] == "hubertcornet"
for attendu in ("hubert.cornet", "hcornet", "h.cornet", "cornethubert"):
assert attendu in variantes
def test_un_seul_mot_reste_tel_quel():
# Un mot unique est un pseudonyme, pas un nom civil : aucune permutation.
assert permutations.generer("soxoj") == ["soxoj"]
def test_troncature_respectee():
variantes = permutations.generer("Jean Claude Van Damme", maximum=5)
assert len(variantes) == 5
def test_aucun_doublon():
variantes = permutations.generer("Marie Claire Dupont")
assert len(variantes) == len(set(variantes))
def test_terme_vide():
assert permutations.generer(" ") == []
assert permutations.generer("!!!") == []
+73
View File
@@ -0,0 +1,73 @@
"""Notation des résultats : la barrière anti-faux-positifs."""
from __future__ import annotations
from limier.db.models import Confidence
from limier.engines.scoring import nettoyer_profil, scorer_compte, scorer_fait_technique
def test_page_sans_aucun_signal_est_faible():
"""Le cas que tout scanner naïf classerait « trouvé » : un simple 200."""
score, niveau, _ = scorer_compte(profil={}, rang_site=None)
assert niveau is Confidence.WEAK
assert score < 0.30
def test_profil_riche_sur_site_majeur_est_confirme():
score, niveau, signaux = scorer_compte(
profil={
"fullname": "Jason Draper",
"avatar_url": "https://exemple/avatar.png",
"follower_count": 214,
"uid": "12345",
},
rang_site=10,
)
assert niveau is Confidence.CONFIRMED
assert signaux["nom_affiche"] and signaux["avatar"] and signaux["metriques"]
assert score >= 0.60
def test_correspondance_elargie_est_penalisee():
"""Un miroir doit descendre d'un cran malgré un profil identique."""
profil = {"fullname": "X", "avatar_url": "u", "uid": "1"}
direct, _, _ = scorer_compte(profil=profil, rang_site=10)
miroir, _, _ = scorer_compte(profil=profil, rang_site=10, est_similaire=True)
assert miroir < direct
def test_site_non_verifie_est_penalise():
profil = {"fullname": "X"}
normal, _, _ = scorer_compte(profil=profil)
suspect, _, _ = scorer_compte(profil=profil, tags_site=["unchecked"])
assert suspect < normal
def test_score_borne_entre_zero_et_un():
haut, _, _ = scorer_compte(
profil={
"fullname": "a",
"avatar_url": "b",
"uid": "c",
"follower_count": 1,
"links": ["x"],
},
rang_site=1,
bonus=5.0,
)
bas, _, _ = scorer_compte(profil={}, est_similaire=True, tags_site=["unchecked"], bonus=-5.0)
assert haut == 1.0
assert bas == 0.0
def test_metadonnees_extracteur_ignorees():
"""``_extractor`` est une métadonnée de socid_extractor, pas une donnée
sur la personne : elle ne doit pas à elle seule faire monter le score."""
assert nettoyer_profil({"_extractor": "GitHub", "image": ""}) == {}
_score, niveau, _ = scorer_compte(profil={"_extractor": "GitHub"})
assert niveau is Confidence.WEAK
def test_fait_technique_est_confirme():
_, niveau, _ = scorer_fait_technique(certitude=1.0)
assert niveau is Confidence.CONFIRMED
+68
View File
@@ -0,0 +1,68 @@
# ---------------------------------------------------------------------------
# Limier — variables de déploiement.
# Copier en .env, renseigner, puis : docker compose up -d
#
# Les secrets marqués « Vault » ont vocation à sortir de ce fichier pour aller
# dans ton HashiCorp Vault / OpenBao. Voir docs/02-deploiement.md.
# ---------------------------------------------------------------------------
REGISTRY=registry.tips-of-mine.com
TAG=1.0.0
LIMIER_HOST=limier.tips-of-mine.com
LIMIER_PUBLIC_URL=https://limier.tips-of-mine.com
LIMIER_ENVIRONMENT=prod
LIMIER_LOG_LEVEL=INFO
# --- Base de données -------------------------------------------------------
# Vault. Générer : openssl rand -base64 32
POSTGRES_PASSWORD=
# --- Authentification Authentik --------------------------------------------
# Créer une application « Limier » (slug limier) avec un fournisseur OAuth2
# confidentiel. Voir docs/03-authentik.md pour la procédure complète.
LIMIER_OIDC_ISSUER=https://authentik.tips-of-mine.com/application/o/limier/
LIMIER_OIDC_CLIENT_ID=
LIMIER_OIDC_CLIENT_SECRET=
LIMIER_OIDC_ADMIN_GROUP=GL-Limier-Admin
LIMIER_OIDC_INTERNAL_GROUP=GL-Limier
# --- Secrets applicatifs ---------------------------------------------------
# Vault. Générer chacun : openssl rand -hex 32
# Attention : changer le poivre rend inopérants tous les blocages d'effacement
# déjà enregistrés (les empreintes ne correspondront plus). Ne le faire qu'en
# connaissance de cause.
LIMIER_SESSION_SECRET=
LIMIER_IDENTIFIER_HASH_PEPPER=
# --- Confidentialité -------------------------------------------------------
# false = les termes recherchés ne sont pas conservés en clair (recommandé pour
# une instance ouverte au public).
LIMIER_STORE_RAW_IDENTIFIERS=false
LIMIER_RETENTION_DAYS=30
# --- Sortie réseau ---------------------------------------------------------
# Sans proxy, une recherche de 500 sites depuis une IP unique déclenche des
# blocages. Voir docs/06-exploitation.md.
# LIMIER_PROXY_STRATEGY=round_robin
# LIMIER_PROXY_URLS=http://user:pass@proxy1:8000,http://user:pass@proxy2:8000
LIMIER_PROXY_STRATEGY=none
LIMIER_PROXY_URLS=
# Contournement Cloudflare (profil flaresolverr)
# LIMIER_FLARESOLVERR_URL=http://limier-flaresolverr:8191
LIMIER_FLARESOLVERR_URL=
# --- Modules optionnels ----------------------------------------------------
# holehe : profil « holehe ». Lire services/holehe/app.py avant d'activer.
# LIMIER_HOLEHE_SERVICE_URL=http://limier-holehe:8080
LIMIER_HOLEHE_SERVICE_URL=
# Gravatar : sans clé, 100 requêtes/heure. Avec clé, 1000.
LIMIER_GRAVATAR_API_KEY=
# Have I Been Pwned : payant. Vide = module désactivé.
LIMIER_HIBP_API_KEY=
# --- Divers ----------------------------------------------------------------
LIMIER_DNS_RESOLVERS=1.1.1.1,9.9.9.9
LIMIER_WORKER_MAX_JOBS=4
+181
View File
@@ -0,0 +1,181 @@
# Limier — pile de production pour SLDOKP03.
#
# Conventions reprises des autres piles du homelab :
# - réseau externe traefik_front_network pour l'exposition
# - réseau interne back_network_limier pour les échanges privés
# - relais courriel msmtpd vers 10.0.4.52:587
# - étiquettes homepage et Watchtower
#
# Les middlewares cloudflarewarp@file et my-crowdsec-bouncer-traefik-plugin@file
# sont déjà attachés à l'entryPoint https de ton Traefik : inutile de les
# redéclarer ici, ils s'appliquent automatiquement.
name: limier
x-limier-env: &limier-env
LIMIER_ENVIRONMENT: ${LIMIER_ENVIRONMENT:-prod}
LIMIER_LOG_LEVEL: ${LIMIER_LOG_LEVEL:-INFO}
LIMIER_LOG_FORMAT: json
LIMIER_PUBLIC_URL: ${LIMIER_PUBLIC_URL:-https://limier.tips-of-mine.com}
LIMIER_DATABASE_URL: postgresql+asyncpg://limier:${POSTGRES_PASSWORD:?mot de passe requis}@limier-postgres:5432/limier
LIMIER_REDIS_URL: redis://limier-redis:6379/0
LIMIER_OIDC_ISSUER: ${LIMIER_OIDC_ISSUER:-https://authentik.tips-of-mine.com/application/o/limier/}
LIMIER_OIDC_CLIENT_ID: ${LIMIER_OIDC_CLIENT_ID:?identifiant client requis}
LIMIER_OIDC_CLIENT_SECRET: ${LIMIER_OIDC_CLIENT_SECRET:?secret client requis}
LIMIER_OIDC_ADMIN_GROUP: ${LIMIER_OIDC_ADMIN_GROUP:-GL-Limier-Admin}
LIMIER_OIDC_INTERNAL_GROUP: ${LIMIER_OIDC_INTERNAL_GROUP:-GL-Limier}
LIMIER_SESSION_SECRET: ${LIMIER_SESSION_SECRET:?secret de session requis}
LIMIER_IDENTIFIER_HASH_PEPPER: ${LIMIER_IDENTIFIER_HASH_PEPPER:?poivre requis}
LIMIER_STORE_RAW_IDENTIFIERS: ${LIMIER_STORE_RAW_IDENTIFIERS:-false}
LIMIER_RETENTION_DAYS: ${LIMIER_RETENTION_DAYS:-30}
LIMIER_SITES_DB_PATH: /data/sites/data.json
LIMIER_PROXY_URLS: ${LIMIER_PROXY_URLS:-}
LIMIER_PROXY_STRATEGY: ${LIMIER_PROXY_STRATEGY:-none}
LIMIER_FLARESOLVERR_URL: ${LIMIER_FLARESOLVERR_URL:-}
LIMIER_HOLEHE_SERVICE_URL: ${LIMIER_HOLEHE_SERVICE_URL:-}
LIMIER_GRAVATAR_API_KEY: ${LIMIER_GRAVATAR_API_KEY:-}
LIMIER_HIBP_API_KEY: ${LIMIER_HIBP_API_KEY:-}
LIMIER_DNS_RESOLVERS: ${LIMIER_DNS_RESOLVERS:-1.1.1.1,9.9.9.9}
TZ: Europe/Paris
services:
limier-api:
image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-backend:${TAG:-1.0.0}
container_name: limier-api
restart: unless-stopped
command: ["limier-api"]
environment: *limier-env
volumes:
- ./data/sites:/data/sites
- /etc/localtime:/etc/localtime:ro
depends_on:
limier-postgres: { condition: service_healthy }
limier-redis: { condition: service_healthy }
networks: [front_network, back_network_limier]
labels:
traefik.enable: "true"
traefik.docker.network: traefik_front_network
traefik.http.routers.limier-api.rule: Host(`${LIMIER_HOST:-limier.tips-of-mine.com}`) && PathPrefix(`/api`)
traefik.http.routers.limier-api.entrypoints: https
traefik.http.routers.limier-api.tls: "true"
traefik.http.routers.limier-api.priority: "20"
traefik.http.routers.limier-api.service: limier-api
traefik.http.services.limier-api.loadbalancer.server.port: "8000"
# Le flux SSE doit traverser sans mise en tampon : Traefik ne bufferise
# pas par défaut, mais on le rend explicite pour éviter une régression.
traefik.http.services.limier-api.loadbalancer.responseforwarding.flushinterval: "100ms"
com.centurylinklabs.watchtower.enable: "true"
homepage.group: Security
homepage.name: Limier
homepage.icon: si-searxng
homepage.href: https://${LIMIER_HOST:-limier.tips-of-mine.com}
homepage.description: Plateforme OSINT
limier-worker:
image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-backend:${TAG:-1.0.0}
container_name: limier-worker
restart: unless-stopped
command: ["limier-worker"]
environment:
<<: *limier-env
LIMIER_WORKER_MAX_JOBS: ${LIMIER_WORKER_MAX_JOBS:-4}
volumes:
- ./data/sites:/data/sites
- /etc/localtime:/etc/localtime:ro
depends_on:
limier-postgres: { condition: service_healthy }
limier-redis: { condition: service_healthy }
networks: [back_network_limier]
labels:
com.centurylinklabs.watchtower.enable: "true"
limier-web:
image: ${REGISTRY:-registry.tips-of-mine.com}/limier/limier-frontend:${TAG:-1.0.0}
container_name: limier-web
restart: unless-stopped
volumes:
- /etc/localtime:/etc/localtime:ro
networks: [front_network]
labels:
traefik.enable: "true"
traefik.docker.network: traefik_front_network
traefik.http.routers.limier-web.rule: Host(`${LIMIER_HOST:-limier.tips-of-mine.com}`)
traefik.http.routers.limier-web.entrypoints: https
traefik.http.routers.limier-web.tls: "true"
traefik.http.routers.limier-web.priority: "10"
traefik.http.routers.limier-web.service: limier-web
traefik.http.routers.limier-web.middlewares: limier-securite@file
traefik.http.services.limier-web.loadbalancer.server.port: "8080"
com.centurylinklabs.watchtower.enable: "true"
limier-postgres:
image: postgres:16.10-alpine
container_name: limier-postgres
restart: unless-stopped
environment:
POSTGRES_USER: limier
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?mot de passe requis}
POSTGRES_DB: limier
PGDATA: /var/lib/postgresql/data/limier
TZ: Europe/Paris
volumes:
- ./data/postgres:/var/lib/postgresql/data
- /etc/localtime:/etc/localtime:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U limier -d limier"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
networks: [back_network_limier]
limier-redis:
image: redis:7.4-alpine
container_name: limier-redis
restart: unless-stopped
# Redis ne porte que la file et la progression : les données durables sont
# dans PostgreSQL. On limite donc la mémoire et on évite la persistance.
command: ["redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
volumes:
- /etc/localtime:/etc/localtime:ro
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
networks: [back_network_limier]
# ------------------------------------------------------------------ options
# Deux services facultatifs, activés par profil. Ils ne démarrent pas sans
# ``--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.0}
container_name: limier-holehe
restart: unless-stopped
profiles: ["holehe"]
environment:
TZ: Europe/Paris
volumes:
- /etc/localtime:/etc/localtime:ro
# Aucune étiquette Traefik : ce service ne doit jamais être joignable
# depuis l'extérieur (aucune authentification).
networks: [back_network_limier]
limier-flaresolverr:
image: ghcr.io/flaresolverr/flaresolverr:v3.4.2
container_name: limier-flaresolverr
restart: unless-stopped
profiles: ["flaresolverr"]
environment:
LOG_LEVEL: warning
TZ: Europe/Paris
networks: [back_network_limier]
networks:
front_network:
external: true
name: traefik_front_network
back_network_limier:
driver: bridge
+38
View File
@@ -0,0 +1,38 @@
# À déposer dans /opt/traefik/configs/dynamic/limier.yml
#
# Un seul middleware, appliqué à l'interface (pas à l'API) : les en-têtes de
# sécurité du navigateur. Tout le reste — restauration de l'IP réelle derrière
# le tunnel Cloudflare, bouncer CrowdSec — est déjà attaché à l'entryPoint
# https de ta configuration statique et s'applique donc automatiquement.
#
# La CSP est stricte : l'interface est une application compilée, sans script en
# ligne ni ressource externe. Elle charge en revanche des avatars depuis des
# domaines tiers (profils trouvés), d'où le img-src permissif — c'est la seule
# concession, et elle ne permet d'exécuter aucun code.
http:
middlewares:
limier-securite:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
forceSTSHeader: true
contentTypeNosniff: true
browserXssFilter: true
referrerPolicy: strict-origin-when-cross-origin
frameDeny: true
customResponseHeaders:
X-Robots-Tag: "noindex, nofollow"
Permissions-Policy: "geolocation=(), microphone=(), camera=(), interest-cohort=()"
Content-Security-Policy: >-
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self';
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none'
+103
View File
@@ -0,0 +1,103 @@
# Architecture
## Vue d'ensemble
```
tunnel Cloudflare
│
Traefik (https)
│
┌───────────────────┴───────────────────┐
│ priorité 20 : /api priorité 10 : / │
▼ ▼
limier-api limier-web
(FastAPI) (nginx + React compilé)
│
├──── PostgreSQL ──── recherches, résultats, comptes, quotas
│
└──── Redis ────┬──── file de travaux (arq)
└──── progression en direct (SSE)
▲
│
limier-worker
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
moteur pseudonyme moteur e-mail moteur domaine
(Maigret) │
│ │
limier-holehe RDAP, DNS, CT
(optionnel, GPL)
```
## Pourquoi cette découpe
**API et worker dans la même image, deux commandes.** Même code, même base de
sites, aucune divergence possible à la mise à jour. Deux images finiraient par
se désynchroniser à la première publication oubliée.
**Interface servie par un conteneur nginx distinct.** L'API ne sert aucun
fichier statique. Reconstruire l'interface ne touche pas au backend, et
inversement. Traefik arbitre par priorité de routeur : `/api` (priorité 20)
l'emporte sur `/` (priorité 10).
**Une recherche est un travail, pas une requête.** Un balayage de 500 sites
prend 30 à 120 secondes. Le faire dans une requête HTTP produirait des délais
dépassés côté proxy et bloquerait un worker uvicorn. L'API accepte, met en file
et rend un identifiant ; le worker exécute.
**arq plutôt que Celery.** Maigret est asynchrone de bout en bout, arq aussi.
Celery aurait imposé un pont entre son modèle de travailleurs synchrones et une
boucle asyncio, sans aucun gain.
**SSE plutôt que WebSocket.** Le flux est unidirectionnel, SSE se reconnecte
seul, passe partout en HTTP/1.1 et ne demande aucune configuration Traefik
particulière.
## Le contrat des moteurs
Tout part de `engines/base.py`. Un moteur reçoit une `EngineRequest` et **émet
un flux d'évènements** au lieu de rendre un résultat final :
| Évènement | Rôle |
| --------------- | ------------------------------------------------------- |
| `ProgressEvent` | avancement, alimente la barre de progression |
| `FindingEvent` | un résultat, déjà scoré |
| `NoticeEvent` | information non bloquante (module désactivé, source KO) |
C'est ce qui permet l'affichage en direct, et c'est aussi ce qui rend
l'ajout d'un moteur trivial : créer un module dans `engines/<nom>/engine.py`,
l'enregistrer dans `engines/registry.py`. Rien d'autre ne change.
Les résultats sont écrits **pendant** la recherche, pas à la fin : une recherche
interrompue à 80 % conserve ses 80 %.
## Chemin d'un évènement
```
moteur ──► worker ──► Redis ──┬──► liste (historique rejouable)
└──► canal (réveil immédiat)
│
▼
API /flux (SSE) ──► navigateur
```
La liste permet à un navigateur qui se connecte en retard, ou qui se reconnecte,
de ne rien perdre. `EventSource` renvoie `Last-Event-ID`, le backend ne rejoue
que le manquant.
## Où chercher quoi
| Question | Fichier |
| ----------------------------------------- | ------------------------------------------ |
| Une variable d'environnement | `backend/src/limier/config.py` |
| Le schéma de la base | `backend/src/limier/db/models.py` |
| Comment un résultat est jugé fiable | `backend/src/limier/engines/scoring.py` |
| L'appel à Maigret | `engines/username/engine.py` |
| Les variantes « prénom nom » | `engines/username/permutations.py` |
| Ce que fait le worker | `backend/src/limier/jobs/tasks.py` |
| Les règles d'acceptation d'une recherche | `api/routes/searches.py` |
| L'authentification Authentik | `backend/src/limier/auth/oidc.py` |
| Les quotas et la limitation de débit | `backend/src/limier/quotas/service.py` |
| La conservation et l'effacement | `backend/src/limier/privacy/retention.py` |
| La sortie réseau et les proxies | `backend/src/limier/net/proxy_pool.py` |
+116
View File
@@ -0,0 +1,116 @@
# Déploiement sur SLDOKP03
## Avant de commencer
- Un projet Harbor nommé `limier` avec un compte robot en écriture.
- Une application Authentik configurée (voir `03-authentik.md`).
- Le réseau Docker externe `traefik_front_network` existe déjà.
## 1. Dépôts Gitea
Un seul dépôt suffit : `Tips-Of-Mine/limier`. Secrets et variables à déclarer
dans ses réglages Actions :
| Nom | Type | Valeur |
| ----------------- | -------- | ----------------------------- |
| `HARBOR_REGISTRY` | variable | `registry.tips-of-mine.com` |
| `HARBOR_USERNAME` | secret | compte robot Harbor |
| `HARBOR_PASSWORD` | secret | jeton du compte robot |
## 2. Première publication
```bash
git tag v1.0.0
git push origin v1.0.0
```
Le workflow `release.yml` vérifie d'abord que l'étiquette, `pyproject.toml` et
`package.json` annoncent la même version — une divergence produirait des images
dont `/api/sante` ment sur ce qui tourne — puis publie trois images vers Harbor.
## 3. Installation
```bash
mkdir -p /opt/limier && cd /opt/limier
# déposer deploy/docker-compose.yml et deploy/.env.example
cp .env.example .env
```
Générer les trois secrets :
```bash
echo "POSTGRES_PASSWORD=$(openssl rand -base64 32)"
echo "LIMIER_SESSION_SECRET=$(openssl rand -hex 32)"
echo "LIMIER_IDENTIFIER_HASH_PEPPER=$(openssl rand -hex 32)"
```
> **Le poivre ne se change pas à la légère.** Toutes les empreintes
> d'identifiants en dépendent. Le modifier rend inopérants les blocages
> d'effacement déjà enregistrés : les empreintes ne correspondront plus.
> Sa place est dans ton Vault / OpenBao, pas dans un fichier.
Config dynamique Traefik :
```bash
cp deploy/traefik/limier.yml /opt/traefik/configs/dynamic/limier.yml
```
Elle ne déclare qu'un middleware, les en-têtes de sécurité du navigateur.
`cloudflarewarp@file` et `my-crowdsec-bouncer-traefik-plugin@file` sont déjà
attachés à ton entryPoint `https` et s'appliquent donc automatiquement.
## 4. Migrations puis démarrage
```bash
docker compose run --rm limier-api alembic upgrade head
docker compose up -d
docker compose ps
curl -s https://limier.tips-of-mine.com/api/pret | jq
```
`/api/pret` vérifie réellement PostgreSQL, Redis, la base de sites et la
complétude de la configuration. C'est la sonde à regarder quand « ça ne marche
pas ». `/api/sante` est la sonde du healthcheck Docker, volontairement triviale.
## 5. Modules optionnels
```bash
# holehe — lire services/holehe/app.py avant d'activer
docker compose --profile holehe up -d
# puis dans .env : LIMIER_HOLEHE_SERVICE_URL=http://limier-holehe:8080
# contournement Cloudflare
docker compose --profile flaresolverr up -d
# puis dans .env : LIMIER_FLARESOLVERR_URL=http://limier-flaresolverr:8191
```
Sans `--profile`, ces services ne démarrent pas : l'installation par défaut
reste minimale.
## 6. Mise à jour
```bash
cd /opt/limier
TAG=1.1.0 docker compose pull && TAG=1.1.0 docker compose up -d
docker compose run --rm limier-api alembic upgrade head
```
Watchtower est activé sur les conteneurs, mais **le `TAG` est épinglé** dans le
compose : il ne suivra donc pas `latest` tout seul. C'est voulu — une montée de
version doit passer par une étiquette et une migration explicites.
## Volumes
| Chemin | Contenu |
| ------------------- | -------------------------------------------------- |
| `./data/postgres` | base de données |
| `./data/sites` | base de sites corrigée par l'auto-contrôle nocturne|
Redis n'a pas de volume : il ne porte que la file et la progression. Les données
durables sont dans PostgreSQL. Le perdre coûte au pire les recherches en cours.
## Sauvegarde
```bash
docker compose exec -T limier-postgres pg_dump -U limier limier | gzip > limier-$(date +%F).sql.gz
```
+92
View File
@@ -0,0 +1,92 @@
# Configuration Authentik
Procédure pour l'instance `authentik.tips-of-mine.com`, en suivant les
conventions déjà en place sur tes autres applications.
## 1. Groupes
Répertoire → Groupes. Créer, selon la convention `GL-<app>` / `GL-<app>-Admin` :
| Groupe | Effet dans Limier |
| ------------------ | -------------------------------------------------- |
| `GL-Limier` | plan `interne` — aucun quota mensuel |
| `GL-Limier-Admin` | plan `interne` + accès à l'espace d'administration |
Les visiteurs qui s'inscrivent sans appartenir à ces groupes reçoivent le plan
`gratuit` et son quota. L'appartenance est **relue à chaque connexion** : retirer
quelqu'un d'un groupe lui retire ses droits à sa prochaine ouverture de session,
sans intervention en base.
## 2. Fournisseur OAuth2
Applications → Fournisseurs → Créer → OAuth2/OpenID.
| Champ | Valeur |
| ------------------------ | ----------------------------------------------------------- |
| Nom | `Tips-Of-Mine-Limier` |
| Type de client | **Confidentiel** |
| Flux d'autorisation | `default-provider-authorization-implicit-consent` |
| **URIs de redirection** | `https://limier.tips-of-mine.com/api/auth/retour` |
| Clé de signature | `authentik Token Signing` |
> **Le piège rencontré sur Enclume.** L'URI va dans « **URIs de redirection** »,
> pas dans « URI de déconnexion ». Saisie au mauvais endroit, la première
> connexion échoue sur `Redirect URI Error` et le champ correct reste vide.
Relever l'identifiant et le secret du client pour le `.env`.
## 3. Application
Applications → Applications → Créer.
| Champ | Valeur |
| ------------- | ------------------------------- |
| Nom | `Limier` |
| **Slug** | `limier` |
| Fournisseur | `Tips-Of-Mine-Limier` |
Le slug détermine l'émetteur :
`https://authentik.tips-of-mine.com/application/o/limier/`. Il doit
correspondre **exactement** à `LIMIER_OIDC_ISSUER`.
> Sur l'intégration Cloudflare Access, un slug erroné avait produit un 404 sur
> `/jwks/` — l'intégration n'avait jamais abouti sans que rien ne le signale.
> Vérifier : `curl -s https://authentik.tips-of-mine.com/application/o/limier/.well-known/openid-configuration | jq .issuer`
Lier ensuite les deux groupes à l'application (onglet Liaisons), pour que
l'application reste cloisonnée comme les sept autres.
## 4. La revendication `groups`
Depuis la 2026.8, les correspondances par défaut exposent bien les groupes — le
test de Cloudflare Access l'a confirmé, la revendication arrivait au niveau
racine sans correspondance personnalisée.
Si l'instance est plus ancienne, rattacher la correspondance de portée
« OAuth Mapping: OpenID groups » déjà créée pour Grafana, Gitea et Harbor.
Rappel : elle doit utiliser `request.user.groups` et non `request.user.ak_groups`,
relation dépréciée depuis la 2026.8.
Limier prévoit les deux cas : si la revendication est absente du jeton
d'identité, il interroge `userinfo`.
## 5. Vérification
Après `docker compose up -d` :
1. Ouvrir `https://limier.tips-of-mine.com` → « Se connecter ».
2. Redirection vers Authentik, authentification, retour sur l'interface.
3. Le nom et le quota apparaissent en haut à droite.
4. Membre de `GL-Limier-Admin` : `curl` sur `/api/admin/tableau-de-bord`
renvoie 200 ; sinon, 403.
En cas d'échec : `docker compose logs limier-api | grep -i auth`. Les messages
sont explicites (`decouverte_oidc_impossible`, `etat_oidc_incoherent`,
`echec_validation_identite`) et ne contiennent jamais de jeton.
## Ce que Limier ne fait pas
Aucune gestion de mot de passe, aucun compte local, aucune réinitialisation.
Authentik est la seule source d'identité. Le cookie de session ne porte que
l'identifiant interne de l'utilisateur : plan et droits sont relus en base à
chaque requête.
+116
View File
@@ -0,0 +1,116 @@
# Les moteurs
## Moteur pseudonyme — `engines/username/`
Bâti sur **Maigret 0.6.5** (MIT, commercial autorisé sans restriction).
La base embarquée compte **3 302 sites**, dont 3 277 en `id_type=username`.
Par défaut on interroge les 500 mieux classés ; la recherche approfondie monte
à 3 000. `ranked_sites_dict` ajoute les miroirs des plateformes bien classées
— un lecteur tiers d'Instagram reste interrogé même si Instagram est désactivé.
### Ce qui a été ajouté au-dessus de Maigret
**Progression en direct.** Maigret attend un objet « notifier » à la
`QueryNotifyPrint`. On lui en fournit un qui dépose les évènements dans une file
asyncio au lieu d'écrire sur la sortie standard. L'interface affiche donc
l'avancement site par site.
**Variantes de nom.** Maigret possède `--permute`, mais il produit un volume
ingérable : trois mots et quatre séparateurs dépassent la centaine de variantes,
soit autant de fois 500 requêtes. On génère les nôtres, **ordonnées par
probabilité** et tronquées. « Hubert Cornet » donne, dans l'ordre :
`hubertcornet`, `hubert.cornet`, `hubert_cornet`, `hubert-cornet`, `hcornet`,
`h.cornet`, … L'utilisateur voit la liste avant de lancer.
**Notation.** Voir plus bas.
**Recherche récursive bornée.** Les pseudonymes découverts dans les profils
(un compte GitHub qui cite un compte X) relancent une recherche, limitée à
3 pseudonymes et aux 100 premiers sites : elle sert à confirmer un lien, pas à
relancer un balayage complet.
## Moteur e-mail — `engines/email/`
Maigret ne fait **que** le pseudonyme. Aucun `id_type` e-mail n'existe dans sa
base. Ce moteur est donc entièrement composé, du plus fiable au plus incertain :
| Module | Source | Fiabilité | Actif par défaut |
| ---------------- | -------------------------- | --------- | ---------------- |
| `email.dns` | MX, SPF, DMARC | factuelle | oui |
| `email.gravatar` | api.gravatar.com/v3 | élevée | oui |
| `email.jetable` | liste embarquée | indicative| oui |
| `email.fuites` | Have I Been Pwned | élevée | non (clé payante)|
| `email.holehe` | formulaires de récupération| variable | non |
| `email.pivot` | partie locale de l'adresse | piste | oui |
**Gravatar : attention à l'empreinte.** Le service est passé de MD5 à
**SHA-256**, sur l'adresse nettoyée et mise en minuscules. Toute intégration
écrite avant ce changement est cassée. Sans clé : 100 requêtes par heure ; avec
clé : 1 000. Les comptes vérifiés déclarés sur un profil Gravatar sont traités
comme des résultats à part entière — ce sont des liens affirmés par le titulaire.
**holehe est isolé dans un conteneur.** Deux raisons, toutes deux sérieuses.
Licence : holehe est sous GPLv3, Limier sous MIT ; l'importer contaminerait tout
le projet. Fraîcheur : plus de publication réelle depuis décembre 2023, de
nombreuses demandes de fusion en attente, des modules qui cassent au fil des
changements chez les plateformes. Méthode, enfin : holehe sollicite les
formulaires « mot de passe oublié » avec l'adresse recherchée. La cible n'est pas
notifiée, mais chaque contrôle interpelle un service tiers. D'où la désactivation
par défaut.
## Moteur domaine — `engines/domain/`
Le plus simple : toutes les sources sont publiques par conception et aucune ne
bloque.
**RDAP plutôt que WHOIS.** Réponse JSON normalisée (RFC 9083) au lieu d'un texte
libre à analyser par expressions régulières.
**Bootstrap IANA plutôt que rdap.org.** Le service rdap.org est pratique mais
limité à 10 requêtes par tranche de 10 secondes derrière Cloudflare, et sa
documentation invite les clients réguliers à consommer les registres de
bootstrap. On télécharge donc `data.iana.org/rdap/dns.json` (RFC 9224) une fois
par 24 h et on interroge directement le serveur du registre. Aucun tiers sur le
chemin.
Les coordonnées du titulaire sont presque toujours masquées depuis le RGPD :
c'est le comportement normal, l'interface le signale explicitement plutôt que
d'afficher un vide déroutant.
**Journaux de transparence des certificats.** crt.sh en source primaire —
le plus complet, gratuit, sans clé. Son défaut est connu : PostgreSQL partagé
qui sature régulièrement, indisponibilités fréquentes. Bascule automatique sur
**Certspotter** en cas d'échec. Chaque nom découvert est vérifié en DNS pour
séparer ce qui résout encore de ce qui est historique — c'est cette distinction
qui rend la liste exploitable.
## La notation — `engines/scoring.py`
Le vrai différenciateur face à un scanner naïf, qui considère « trouvé » tout
site répondant 200 alors que beaucoup rendent 200 sur une page « utilisateur
inconnu ».
| Signal | Poids |
| ----------------------------- | ------ |
| profil extrait | +0,35 |
| nom affiché | +0,15 |
| avatar | +0,10 |
| métriques sociales | +0,10 |
| identifiant interne | +0,10 |
| site bien classé (< 10 000) | +0,10 |
| liens vers d'autres comptes | +0,05 |
| correspondance élargie (miroir)| −0,25 |
| site marqué `unchecked` | −0,20 |
Seuils : **confirmé** ≥ 0,60, **probable** ≥ 0,30, **faible** en dessous.
Les seuils et les poids sont dans ce fichier et nulle part ailleurs : c'est le
seul endroit à toucher pour rendre l'outil plus ou moins strict.
Les signaux sont **stockés avec chaque résultat** et affichés dans l'interface.
L'utilisateur voit pourquoi un résultat est confirmé, au lieu de faire confiance
à un score opaque.
Les résultats factuels (RDAP, DNS, CT) passent par `scorer_fait_technique` :
l'enregistrement existe ou non, les signaux de profil n'y ont pas de sens.
+87
View File
@@ -0,0 +1,87 @@
# Conformité RGPD
Un service public de recherche sur des personnes fait de toi un responsable de
traitement. Ce document décrit ce qui est effectivement implémenté — pas des
intentions.
## Ce qui est conservé, et pourquoi
| Donnée | Durée | Justification |
| ----------------------- | ------------------------- | ----------------------------------- |
| Recherches et résultats | `RETENTION_DAYS` (30 j) | permettre à l'utilisateur de relire |
| Compte | jusqu'à suppression | accès et quota |
| Compteurs de quota | 13 mois | contrôle de la facturation |
| Journal d'exploitation | 90 jours | détection d'abus |
| Demandes d'effacement | permanente | preuve du traitement (art. 17) |
`expires_at` est posé à la création de chaque recherche. La purge horaire
s'appuie dessus, avec un index dédié — sans lui elle balaierait toute la table.
## Minimisation : le terme n'est pas conservé en clair
Avec `LIMIER_STORE_RAW_IDENTIFIERS=false` (recommandé pour une instance
ouverte), la base ne contient plus que l'empreinte SHA-256 du terme recherché,
salée par un poivre applicatif.
Conséquence concrète : **la base seule ne permet plus de reconstituer la liste
des personnes recherchées**. Une demande d'effacement reste possible — on
rehache le terme fourni pour retrouver les enregistrements.
Le poivre est applicatif et non par enregistrement : sinon la même recherche
produirait des empreintes différentes, et déduplication comme effacement
deviendraient impossibles. Sa place est dans Vault.
Le journal d'exploitation ne porte que les 16 premiers caractères de
l'empreinte : corrélation possible, réidentification non.
## Le journal n'expose jamais un identifiant
`logging.py` installe un processeur `masquer_identifiants` qui retire les clés
sensibles (`term`, `email`, `username`, `query`, `token`, `secret`…) de **tout**
évènement, quelle que soit la négligence de l'appelant. Le gestionnaire d'erreurs
de validation de FastAPI est également remplacé : par défaut il renvoie la valeur
fautive dans la réponse, ce qu'on ne veut pas s'agissant d'identifiants.
## Droit d'accès et portabilité (art. 15 et 20)
`GET /api/confidentialite/export` rend à l'utilisateur connecté l'intégralité de
ses données en JSON téléchargeable.
## Droit à l'effacement (art. 17)
`POST /api/confidentialite/effacement` enregistre une demande. Elle **n'est pas
appliquée immédiatement** : sans quoi n'importe qui pourrait supprimer les
recherches d'autrui en devinant un terme.
Un administrateur l'applique depuis `/api/admin/effacements/{id}/appliquer`.
Trois effets :
1. toutes les recherches portant sur cet identifiant sont supprimées, résultats
compris (cascade) ;
2. l'identifiant entre dans la liste d'exclusion — toute recherche ultérieure
est refusée avec un **HTTP 451** ;
3. le terme en clair de la demande est effacé, seule l'empreinte subsiste.
Sans le point 2, l'effacement serait sans effet dès la requête suivante.
## Information (art. 13)
`GET /api/confidentialite` publie la politique sous forme lisible par machine
autant que par humain, en reflétant la **configuration réelle** de l'instance
(durée de conservation, minimisation active ou non). La page publique s'en sert
comme source unique : aucune divergence possible entre ce qui est annoncé et ce
qui est appliqué.
## Ce qui reste à ta charge
Le code ne peut pas décider à ta place :
- **Base légale.** L'intérêt légitime (art. 6-1-f) est retenu par défaut dans la
politique publiée. Si tu ouvres au public, cette qualification mérite un
examen — ce n'est pas une question technique.
- **Mentions légales.** Identité du responsable de traitement, contact, droit de
réclamation auprès de la CNIL.
- **Registre des traitements** (art. 30) si tu dépasses le seuil.
- **Modération.** Le service ne distingue pas un usage légitime d'un
harcèlement. La liste d'exclusion, la limitation de débit et le journal sont
les outils fournis ; leur usage relève de toi.
+94
View File
@@ -0,0 +1,94 @@
# Exploitation
## La question qui détermine tout : la sortie réseau
Une recherche standard émet **500 requêtes vers 500 domaines en quelques
secondes**. Depuis une IP unique — résidentielle ou de centre de données — cela
déclenche des blocages : 403, CAPTCHA, 429.
Le moteur mesure ce taux. Au-delà de **30 % de sites non vérifiables**, il
émet un avertissement visible par l'utilisateur plutôt que de présenter des
résultats partiels comme complets.
### Sans proxy (`LIMIER_PROXY_STRATEGY=none`)
Viable pour un usage privé, quelques recherches par jour. Pour une ouverture
publique, le taux de sites non vérifiables montera et la qualité se dégradera
rapidement. C'est la raison pour laquelle Maigret lui-même et les services
commerciaux comparables sont sponsorisés par des fournisseurs de proxies
résidentiels.
### Avec proxies
```bash
LIMIER_PROXY_STRATEGY=round_robin
LIMIER_PROXY_URLS=http://user:pass@p1:8000,http://user:pass@p2:8000
```
Le pool suit la santé de chaque proxy dans Redis, partagée entre workers. Au bout
de 5 échecs consécutifs, mise en quarantaine 15 minutes. L'état est visible sur
`/api/admin/proxies`, identifiants masqués.
C'est le poste de coût principal d'une instance publique. À chiffrer avant
d'ouvrir, pas après.
## Entretien de la base de sites
**C'est ce qui distingue un outil vivant d'un outil qui dérive.** Les plateformes
changent leur HTML, la détection casse, et le scanner se met à produire des faux
positifs sans que rien ne le signale.
Le worker exécute `auto_controle_sites` **chaque nuit à 3 h**. Maigret vérifie
chaque site avec un couple « pseudonyme connu / pseudonyme inexistant » : si le
site ne distingue plus les deux, sa détection est cassée et il est désactivé. La
base corrigée est écrite dans `/data/sites/data.json`, que les workers rechargent
automatiquement.
**Indicateur à surveiller : le taux de sites désactivés**, sur
`/api/admin/sites`. Au-delà de **15 %**, la couverture se dégrade — relancer
l'auto-contrôle ou mettre la base à jour.
Le workflow `maintenance.yml` fait le même contrôle chaque lundi dans la CI,
mais **sans désactivation** : depuis un exécuteur de CI, blocages géographiques
et IP de centre de données font échouer des sites parfaitement sains.
## Supervision
`/api/metriques` expose des compteurs Prometheus, sans aucune donnée
personnelle. Les libellés sont à faible cardinalité (`kind`, `status`,
`confidence`) : un libellé par site ferait exploser le nombre de séries.
Métriques utiles : `limier_duree_recherche_secondes`,
`limier_sites_desactives`, `limier_refus_total{motif}`.
Le journal sort en JSON, directement consommable par ton Graylog ou Wazuh.
## Contraintes de dépendances à connaître
**`arq` impose `redis[hiredis]>=4.2,<6`.** Monter redis en 6.x ou 8.x casse
l'installation avec un `ResolutionImpossible`. La version retenue est 5.3.1.
Documenté dans `pyproject.toml`.
**`maigret` tire `networkx<3` et `flask`.** Sans conséquence ici, mais cela
explique les avertissements de résolution si tu installes dans un environnement
partagé.
## Pannes courantes
| Symptôme | Cause probable |
| ------------------------------------- | ------------------------------------------------------ |
| Barre de progression figée | worker arrêté — `relancer_recherches_bloquees` libère après 2× le délai |
| Aucune progression, résultats d'un coup | mise en tampon SSE quelque part sur le chemin |
| « Redirect URI Error » à la connexion | URI saisie dans « URI de déconnexion » — voir `03-authentik.md` |
| Beaucoup de sites non vérifiés | blocage de l'IP sortante — voir plus haut |
| crt.sh indisponible | normal, bascule automatique sur Certspotter |
| 451 sur une recherche | identifiant en liste d'exclusion (effacement appliqué) |
## Vérifications de routine
```bash
curl -s https://limier.tips-of-mine.com/api/pret | jq
docker compose logs --since 24h limier-worker | grep -c echec_recherche
curl -s .../api/admin/sites | jq .taux_desactives # < 15
curl -s .../api/admin/tableau-de-bord | jq .conformite.demandes_en_attente
```
+99
View File
@@ -0,0 +1,99 @@
# API
Documentation interactive : `https://limier.tips-of-mine.com/api/docs`.
Schéma OpenAPI : `/api/openapi.json`.
Authentification par cookie de session `HttpOnly`, posé par le parcours OIDC.
Aucun jeton n'est accessible au JavaScript.
## Routes
### Exploitation
| Méthode | Chemin | Auth | Rôle |
| ------- | --------------- | ---- | ------------------------------------- |
| GET | `/api/sante` | non | sonde du healthcheck, triviale |
| GET | `/api/pret` | non | vérifie PostgreSQL, Redis, base sites |
| GET | `/api/metriques`| non | compteurs Prometheus |
| GET | `/api/catalogue`| non | types, sites, modules actifs |
### Authentification
| Méthode | Chemin | Rôle |
| ------- | ------------------------ | ------------------------------------- |
| GET | `/api/auth/connexion` | redirige vers Authentik (PKCE) |
| GET | `/api/auth/retour` | rappel OIDC |
| POST | `/api/auth/deconnexion` | ferme la session |
| GET | `/api/auth/moi` | état du compte et quota |
### Recherches
| Méthode | Chemin | Auth | Rôle |
| ------- | ---------------------------------------- | ---- | --------------------------- |
| POST | `/api/recherches` | oui | lance une recherche |
| POST | `/api/recherches/variantes` | non | aperçu des variantes |
| GET | `/api/recherches` | oui | historique |
| GET | `/api/recherches/statistiques/mensuelles`| oui | répartition par type |
| GET | `/api/recherches/{id}` | oui | détail et résultats |
| GET | `/api/recherches/{id}/flux` | oui | progression en direct (SSE) |
| DELETE | `/api/recherches/{id}` | oui | suppression |
> `/statistiques/mensuelles` est déclarée **avant** `/{recherche_id}` :
> FastAPI teste les routes dans l'ordre de déclaration, et « statistiques »
> serait sinon lu comme un UUID. Un test couvre cette non-régression.
### Confidentialité
| Méthode | Chemin | Auth | Rôle |
| ------- | ----------------------------------- | ---- | ------------------------ |
| GET | `/api/confidentialite` | non | politique appliquée |
| POST | `/api/confidentialite/effacement` | non | demande d'effacement |
| GET | `/api/confidentialite/export` | oui | export complet (art. 20) |
### Administration — groupe `GL-Limier-Admin`
`/api/admin/tableau-de-bord`, `/sites`, `/sites/auto-controle`, `/proxies`,
`/modules`, `/effacements`, `/effacements/{id}/appliquer`, `/purge`,
`/utilisateurs`, `/utilisateurs/{id}/plan`, `/utilisateurs/{id}/blocage`.
Un administrateur **ne lit pas** les recherches des autres : ce serait
contredire la politique de minimisation annoncée aux visiteurs.
## Lancer une recherche
```bash
curl -X POST https://limier.tips-of-mine.com/api/recherches \
-H "Content-Type: application/json" -b cookies.txt \
-d '{"kind":"name","term":"Hubert Cornet","deep":false}'
```
`kind` : `username`, `name`, `email`, `domain`.
Réponse **202** avec l'identifiant. S'abonner ensuite au flux.
## Le flux SSE
```javascript
const source = new EventSource(`/api/recherches/${id}/flux`, { withCredentials: true });
source.onmessage = (m) => {
const e = JSON.parse(m.data); // debut | progress | finding | notice | fin
if (e.type === "fin") source.close();
};
```
Les évènements déjà émis sont rejoués à la connexion : arriver en retard ou se
reconnecter ne fait rien perdre. `EventSource` renvoie `Last-Event-ID`, le
backend ne rejoue que le manquant. Un battement (`: ping`) toutes les 20 s
empêche le tunnel Cloudflare de fermer une connexion silencieuse.
## Codes de refus
| HTTP | `X-Limier-Code` | Sens |
| ---- | ----------------------- | ------------------------------------- |
| 401 | `authentification_requise` | connexion nécessaire |
| 402 | `quota_depasse` | quota mensuel épuisé |
| 403 | `droits_insuffisants` | réservé aux administrateurs |
| 422 | `requete_invalide` | terme mal formé |
| 429 | `debit_depasse` | limite horaire atteinte |
| 451 | `identifiant_bloque` | effacement appliqué sur cet identifiant |
| 503 | `moteur_indisponible` | moteur désactivé sur l'instance |
+36
View File
@@ -0,0 +1,36 @@
# Étape 1 : compilation de l'interface
FROM node:22.22-alpine AS build
WORKDIR /app
# package-lock.json d'abord : la couche npm ci ne se refait que si les
# dépendances changent, pas à chaque modification de code.
COPY package.json package-lock.json* ./
RUN npm ci --no-audit --no-fund
COPY tsconfig*.json vite.config.ts index.html ./
COPY src ./src
RUN npm run build
# Étape 2 : service des fichiers statiques
FROM nginx:1.27-alpine
ENV TZ=Europe/Paris
RUN apk add --no-cache tzdata curl \
&& ln -snf /usr/share/zoneinfo/$TZ /etc/localtime
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
# nginx:alpine tourne en root pour lier le port 80 ; on écoute sur 8080 et on
# bascule sur l'utilisateur nginx, non privilégié.
RUN sed -i 's|^user .*|# user directive retiree : execution non privilegiee|' /etc/nginx/nginx.conf \
&& sed -i 's|/var/run/nginx.pid|/tmp/nginx.pid|' /etc/nginx/nginx.conf \
&& chown -R nginx:nginx /usr/share/nginx/html /var/cache/nginx
USER nginx
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/ >/dev/null || exit 1
CMD ["nginx", "-g", "daemon off;"]
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<meta name="description" content="Limier — recherche d'informations publiques par pseudonyme, nom, adresse ou domaine." />
<title>Limier</title>
</head>
<body>
<div id="racine"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
# nginx sert uniquement les fichiers compilés de l'interface.
# L'API est routée par Traefik (routeur de priorité supérieure sur /api) :
# ce conteneur n'a donc aucun mandataire à configurer.
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# En-têtes de sécurité : la CSP complète vient du middleware Traefik
# (deploy/traefik/limier.yml). On garde ici le strict minimum utile même si
# le conteneur était joint directement.
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
# Les fichiers portent une empreinte dans leur nom : cache long et sûr.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# index.html ne doit jamais être mis en cache, sinon un déploiement laisse
# les navigateurs sur l'ancienne version qui référence des fichiers disparus.
location = /index.html {
add_header Cache-Control "no-store, must-revalidate";
expires 0;
}
# Application monopage : toute route inconnue rend index.html.
location / {
try_files $uri $uri/ /index.html;
}
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
access_log off;
}
+922
View File
@@ -0,0 +1,922 @@
{
"name": "limier-frontend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "limier-frontend",
"version": "1.0.0",
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@types/node": "22.19.1",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.1",
"typescript": "5.9.3",
"vite": "8.3.0"
}
},
"node_modules/@oxc-project/types": {
"version": "0.149.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz",
"integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/oxc-project"
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz",
"integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz",
"integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz",
"integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz",
"integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz",
"integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz",
"integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz",
"integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz",
"integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz",
"integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz",
"integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz",
"integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz",
"integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz",
"integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz",
"integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz",
"integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz",
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/react": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.3.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
"integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"oxc-transform-react": "^0.145.0",
"vite": "^8.0.0"
},
"peerDependenciesMeta": {
"@rolldown/plugin-babel": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"oxc-transform-react": {
"optional": true
}
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.33.0",
"lightningcss-darwin-arm64": "1.33.0",
"lightningcss-darwin-x64": "1.33.0",
"lightningcss-freebsd-x64": "1.33.0",
"lightningcss-linux-arm-gnueabihf": "1.33.0",
"lightningcss-linux-arm64-gnu": "1.33.0",
"lightningcss-linux-arm64-musl": "1.33.0",
"lightningcss-linux-x64-gnu": "1.33.0",
"lightningcss-linux-x64-musl": "1.33.0",
"lightningcss-win32-arm64-msvc": "1.33.0",
"lightningcss-win32-x64-msvc": "1.33.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/nanoid": {
"version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.18",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.28.0"
},
"peerDependencies": {
"react": "^19.3.0"
}
},
"node_modules/rolldown": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz",
"integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.149.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.8",
"@rolldown/binding-android-arm64": "1.2.8",
"@rolldown/binding-darwin-arm64": "1.2.8",
"@rolldown/binding-darwin-x64": "1.2.8",
"@rolldown/binding-freebsd-x64": "1.2.8",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.8",
"@rolldown/binding-linux-arm64-gnu": "1.2.8",
"@rolldown/binding-linux-arm64-musl": "1.2.8",
"@rolldown/binding-linux-ppc64-gnu": "1.2.8",
"@rolldown/binding-linux-s390x-gnu": "1.2.8",
"@rolldown/binding-linux-x64-gnu": "1.2.8",
"@rolldown/binding-linux-x64-musl": "1.2.8",
"@rolldown/binding-openharmony-arm64": "1.2.8",
"@rolldown/binding-win32-arm64-msvc": "1.2.8",
"@rolldown/binding-win32-x64-msvc": "1.2.8"
}
},
"node_modules/scheduler": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",
"postcss": "^8.5.28",
"rolldown": "~1.2.6",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.7.1",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "limier-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "tsc --noEmit"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@types/node": "22.19.1",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.1",
"typescript": "5.9.3",
"vite": "8.3.0"
}
}
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useState } from "react";
import { api, type Catalogue, type EtatCompte } from "./lib/api";
import { PageRecherche } from "./pages/PageRecherche";
import { PageHistorique } from "./pages/PageHistorique";
import { PageConfidentialite } from "./pages/PageConfidentialite";
type Vue = "recherche" | "historique" | "confidentialite";
/**
* Coquille de l'application.
*
* Navigation à état plutôt que routeur : trois vues, aucune URL profonde à
* partager (les résultats sont privés). Ajouter react-router pour cela serait
* une dépendance sans contrepartie.
*/
export default function App() {
const [vue, setVue] = useState<Vue>("recherche");
const [compte, setCompte] = useState<EtatCompte | null>(null);
const [catalogue, setCatalogue] = useState<Catalogue | null>(null);
useEffect(() => {
api.compte().then(setCompte).catch(() => setCompte({ authenticated: false, is_admin: false }));
api.catalogue().then(setCatalogue).catch(() => setCatalogue(null));
}, []);
async function seDeconnecter() {
const r = await api.deconnexion().catch(() => null);
// Le backend renvoie l'URL de fermeture de session Authentik quand elle
// existe : sans elle, l'utilisateur resterait connecté côté fournisseur.
if (r?.message) window.location.href = r.message;
else window.location.reload();
}
return (
<div className="enveloppe">
<header className="entete">
<span className="marque">
Li<span>mier</span>
</span>
<nav>
<button
className="onglet"
aria-current={vue === "recherche" ? "page" : undefined}
onClick={() => setVue("recherche")}
>
Rechercher
</button>
{compte?.authenticated && (
<button
className="onglet"
aria-current={vue === "historique" ? "page" : undefined}
onClick={() => setVue("historique")}
>
Historique
</button>
)}
<button
className="onglet"
aria-current={vue === "confidentialite" ? "page" : undefined}
onClick={() => setVue("confidentialite")}
>
Confidentialité
</button>
</nav>
<div className="entete-fin">
{compte?.authenticated ? (
<>
{compte.quota_limit != null ? (
<span className="quota">
<strong>{compte.quota_remaining}</strong> / {compte.quota_limit} ce mois
</span>
) : (
<span className="quota">
<strong>{compte.plan}</strong>
</span>
)}
<span className="muet">{compte.display_name ?? compte.email}</span>
<button className="discret" onClick={seDeconnecter}>
Se déconnecter
</button>
</>
) : (
<a className="discret" href="/api/auth/connexion" style={{ textDecoration: "none" }}>
Se connecter
</a>
)}
</div>
</header>
<main className="contenu">
{vue === "recherche" && <PageRecherche catalogue={catalogue} />}
{vue === "historique" && <PageHistorique />}
{vue === "confidentialite" && <PageConfidentialite />}
</main>
<footer className="pied">
<span>Limier 1.0.0</span>
{catalogue && <span>{catalogue.sites_actifs} sites actifs</span>}
<a href="/api/docs" target="_blank" rel="noopener noreferrer">
API
</a>
<span>Recherche sur sources publiques — usage licite uniquement.</span>
</footer>
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
import type { Resultat } from "../lib/api";
/**
* Affichage d'un résultat.
*
* Le parti pris repris d'Opsis : ne pas se contenter d'une pastille verte. On
* montre les données de profil réellement extraites, et surtout les *signaux*
* ayant produit le niveau de confiance — c'est ce qui permet à l'utilisateur de
* juger par lui-même au lieu de faire confiance à un score opaque.
*/
const ETIQUETTES: Record<string, string> = {
confirmed: "confirmé",
probable: "probable",
weak: "faible",
};
/** Traduction des clés techniques renvoyées par les extracteurs. */
const NOMS_CHAMPS: Record<string, string> = {
fullname: "Nom",
full_name: "Nom",
name: "Nom",
display_name: "Nom affiché",
username: "Identifiant",
bio: "Biographie",
description: "Description",
location: "Localisation",
follower_count: "Abonnés",
followers: "Abonnés",
following_count: "Abonnements",
posts_count: "Publications",
karma: "Karma",
public_repos: "Dépôts",
repos: "Dépôts",
created_at: "Inscription",
reg_date: "Inscription",
registration_date: "Inscription",
uid: "Identifiant interne",
id: "Identifiant interne",
job_title: "Fonction",
company: "Société",
serveurs_mx: "Serveurs MX",
spf: "SPF",
dmarc: "DMARC",
politique_dmarc: "Politique DMARC",
registraire: "Registraire",
cree_le: "Créé le",
expire_le: "Expire le",
serveurs_de_noms: "Serveurs de noms",
statuts: "Statuts",
dnssec: "DNSSEC",
sous_domaines_actifs: "Sous-domaines actifs",
sous_domaines_historiques: "Sous-domaines historiques",
comptes_lies: "Comptes liés",
action: "Suite possible",
};
const NOMS_SIGNAUX: Record<string, string> = {
profil_extrait: "profil extrait",
nom_affiche: "nom affiché",
avatar: "avatar",
metriques: "métriques sociales",
identifiant_interne: "identifiant interne",
liens_sortants: "liens sortants",
site_bien_classe: "site majeur",
correspondance_elargie: "correspondance élargie",
site_non_verifie: "site non vérifié",
source_faisant_foi: "source faisant foi",
declare_par_le_titulaire: "déclaré par le titulaire",
resolution_dns: "résout en DNS",
};
const CLES_AVATAR = ["avatar_url", "avatar", "profile_image", "photo", "picture"];
function formater(valeur: unknown): string {
if (valeur === null || valeur === undefined) return "—";
if (Array.isArray(valeur)) {
if (valeur.length === 0) return "—";
if (valeur.length > 8) return `${valeur.slice(0, 8).join(", ")} … (+${valeur.length - 8})`;
return valeur.map((v) => (typeof v === "object" ? JSON.stringify(v) : String(v))).join(", ");
}
if (typeof valeur === "object") return JSON.stringify(valeur);
if (typeof valeur === "boolean") return valeur ? "oui" : "non";
const texte = String(valeur);
return texte.length > 320 ? `${texte.slice(0, 320)}…` : texte;
}
export function CarteResultat({ resultat }: { resultat: Resultat }) {
const profil = resultat.profile ?? {};
const avatar = CLES_AVATAR.map((c) => profil[c]).find(
(v): v is string => typeof v === "string" && v.startsWith("http"),
);
const champs = Object.entries(profil).filter(
([cle]) => !CLES_AVATAR.includes(cle) && !cle.startsWith("_"),
);
// On n'affiche que les signaux positifs et les pénalités réellement
// déclenchées : lister tous les signaux à faux serait du bruit.
const signauxActifs = Object.entries(resultat.signals ?? {}).filter(
([cle, valeur]) => valeur === true && cle in NOMS_SIGNAUX,
);
return (
<article className={`resultat ${resultat.confidence}`}>
{avatar && <img className="avatar" src={avatar} alt="" loading="lazy" />}
<div className="resultat-corps">
<div className="resultat-tete">
<span className="resultat-source">
{resultat.url ? (
<a href={resultat.url} target="_blank" rel="noopener noreferrer nofollow">
{resultat.source}
</a>
) : (
resultat.source
)}
</span>
<span className="resultat-sujet">{resultat.subject}</span>
<span className={`etiquette ${resultat.confidence}`}>
{ETIQUETTES[resultat.confidence] ?? resultat.confidence}
</span>
<span className="faible">{Math.round(resultat.score * 100)} %</span>
</div>
{champs.length > 0 && (
<dl className="profil">
{champs.map(([cle, valeur]) => (
<div key={cle} style={{ display: "contents" }}>
<dt>{NOMS_CHAMPS[cle] ?? cle}</dt>
<dd>{formater(valeur)}</dd>
</div>
))}
</dl>
)}
{signauxActifs.length > 0 && (
<div className="signaux">
{signauxActifs.map(([cle]) => (
<span key={cle} className="signal">
{NOMS_SIGNAUX[cle]}
</span>
))}
</div>
)}
</div>
</article>
);
}
+214
View File
@@ -0,0 +1,214 @@
/**
* Client de l'API Limier.
*
* Toutes les requêtes portent `credentials: "include"` : l'authentification
* repose sur un cookie de session HttpOnly, jamais sur un jeton stocké dans le
* navigateur. Aucun secret n'est donc accessible au JavaScript.
*/
export type TypeRecherche = "username" | "name" | "email" | "domain";
export type Confiance = "confirmed" | "probable" | "weak";
export type StatutRecherche = "queued" | "running" | "done" | "failed" | "cancelled";
export interface EtatCompte {
authenticated: boolean;
subject?: string | null;
display_name?: string | null;
email?: string | null;
plan?: string | null;
is_admin: boolean;
quota_limit?: number | null;
quota_used?: number | null;
quota_remaining?: number | null;
quota_period?: string | null;
}
export interface Catalogue {
types: string[];
sites_total: number;
sites_actifs: number;
tags: Record<string, number>;
moteurs: Record<string, boolean>;
profondeur: { standard: number; approfondie: number };
modules_optionnels: Record<string, unknown>;
}
export interface Resultat {
id: string;
module: string;
source: string;
subject: string;
url: string | null;
confidence: Confiance;
score: number;
signals: Record<string, unknown>;
profile: Record<string, unknown>;
tags: string[];
}
export interface ResumeRecherche {
id: string;
kind: TypeRecherche;
status: StatutRecherche;
query: string | null;
created_at: string;
finished_at: string | null;
progress_done: number;
progress_total: number;
findings_count: number;
duration_seconds: number | null;
error: string | null;
}
export interface DetailRecherche extends ResumeRecherche {
findings: Resultat[];
counts: Record<string, number>;
}
export interface Variantes {
terme: string;
composants: string[];
variantes: string[];
tronque: boolean;
}
/** Erreur d'API portant le code métier renvoyé par le backend. */
export class ErreurApi extends Error {
constructor(
message: string,
readonly statut: number,
readonly code?: string,
) {
super(message);
this.name = "ErreurApi";
}
}
const BASE = "/api";
async function appeler<T>(chemin: string, options: RequestInit = {}): Promise<T> {
const reponse = await fetch(`${BASE}${chemin}`, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(options.headers ?? {}) },
...options,
});
if (!reponse.ok) {
let message = `Erreur ${reponse.status}`;
let code: string | undefined;
try {
const corps = await reponse.json();
message = corps.detail ?? message;
code = corps.code ?? reponse.headers.get("X-Limier-Code") ?? undefined;
} catch {
/* réponse non JSON : on garde le message générique */
}
throw new ErreurApi(message, reponse.status, code);
}
if (reponse.status === 204) return undefined as T;
return (await reponse.json()) as T;
}
export const api = {
compte: () => appeler<EtatCompte>("/auth/moi"),
catalogue: () => appeler<Catalogue>("/catalogue"),
deconnexion: () => appeler<{ ok: boolean; message?: string }>("/auth/deconnexion", {
method: "POST",
}),
variantes: (term: string) =>
appeler<Variantes>("/recherches/variantes", {
method: "POST",
body: JSON.stringify({ term }),
}),
lancer: (payload: {
kind: TypeRecherche;
term: string;
deep?: boolean;
tags?: string[];
variants?: string[];
}) =>
appeler<ResumeRecherche>("/recherches", {
method: "POST",
body: JSON.stringify(payload),
}),
historique: () => appeler<ResumeRecherche[]>("/recherches"),
detail: (id: string) => appeler<DetailRecherche>(`/recherches/${id}`),
supprimer: (id: string) => appeler<{ ok: boolean }>(`/recherches/${id}`, { method: "DELETE" }),
demanderEffacement: (payload: {
term: string;
kind: TypeRecherche;
contact_email?: string;
reason?: string;
}) =>
appeler<{ ok: boolean; message?: string }>("/confidentialite/effacement", {
method: "POST",
body: JSON.stringify(payload),
}),
};
/** Évènement reçu sur le flux de progression. */
export interface EvenementFlux {
type: "debut" | "progress" | "finding" | "notice" | "fin";
module?: string;
done?: number;
total?: number;
label?: string;
level?: "info" | "warning" | "error";
message?: string;
statut?: string;
source?: string;
subject?: string;
url?: string | null;
confidence?: Confiance;
score?: number;
signals?: Record<string, unknown>;
profile?: Record<string, unknown>;
tags?: string[];
erreur?: string;
resultats?: number;
}
/**
* S'abonne au flux d'avancement d'une recherche.
*
* EventSource gère seul la reconnexion et renvoie `Last-Event-ID`, ce que le
* backend exploite pour ne rejouer que les évènements manquants.
*
* @returns une fonction de désabonnement.
*/
export function suivre(
id: string,
surEvenement: (e: EvenementFlux) => void,
surFin?: () => void,
): () => void {
const source = new EventSource(`${BASE}/recherches/${id}/flux`, {
withCredentials: true,
});
source.onmessage = (message) => {
try {
const evenement = JSON.parse(message.data) as EvenementFlux;
surEvenement(evenement);
if (evenement.type === "fin") {
source.close();
surFin?.();
}
} catch {
/* trame illisible : on ignore plutôt que de casser le flux */
}
};
source.onerror = () => {
// EventSource retente automatiquement tant que la connexion n'est pas
// fermée. On ne ferme que si le serveur a explicitement coupé.
if (source.readyState === EventSource.CLOSED) surFin?.();
};
return () => source.close();
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles/global.css";
const racine = document.getElementById("racine");
if (!racine) throw new Error("Élément racine introuvable dans index.html");
createRoot(racine).render(
<StrictMode>
<App />
</StrictMode>,
);
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more