first sync
This commit is contained in:
1 parent
e563fa87d1
commit
78fb13b0e5
112 files changed
+10921
-413
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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"}
|
||||
@@ -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}")
|
||||
@@ -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 = []
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Limier — plateforme OSINT auto-hébergée."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
Whitespace-only changes.
@@ -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()
|
||||
@@ -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.
@@ -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.")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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"'},
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
Whitespace-only changes.
@@ -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)}"
|
||||
@@ -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="/")
|
||||
@@ -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()
|
||||
Whitespace-only changes.
@@ -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)
|
||||
@@ -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.
@@ -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.
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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)}
|
||||
@@ -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)
|
||||
@@ -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.
@@ -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()
|
||||
@@ -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()
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)}
|
||||
@@ -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,
|
||||
)
|
||||
Whitespace-only changes.
@@ -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))
|
||||
@@ -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.
@@ -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
|
||||
@@ -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.
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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("!!!") == []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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` |
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 |
|
||||
@@ -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;"]
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+922
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user