feat(dashboard): rebuild SOC dashboard + fix ClickHouse SQL

Complete rewrite of the SOC dashboard using FastAPI + Jinja2 + htmx + Chart.js + Tailwind CSS.
Replaces the old React/Vite frontend with server-rendered templates.

Dashboard pages:
- Overview: KPIs, timeline chart, threat distribution, top IPs
- Detections: paginated/filterable anomaly table
- Scores: ml_all_scores with AE error & XGB prob columns
- Traffic: HTTP logs with method/host filters
- IP Investigation: full deep-dive (scores, features, HTTP logs, classify)
- Classification: SOC feedback form + history
- Features: AI + thesis feature stats
- Models: scoring stats + model metadata

API: 9 JSON endpoints with parameterized queries, sort whitelists

SQL fixes:
- 05_aggregation_tables: add deduplicate_merge_projection_mode
- 11_views: fix nested aggregate (argMax inside sum)
- 12_thesis_features: remove invalid 'let' bindings, fix groupArrayIf type

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
toto
2026-04-08 03:21:05 +02:00
parent 228ad7026a
commit b735bab5a5
120 changed files with 1444 additions and 24933 deletions

View File

@ -1,10 +0,0 @@
# dashboard configuration — DO NOT COMMIT real values
CLICKHOUSE_HOST=clickhouse
CLICKHOUSE_PORT=8123
CLICKHOUSE_DB=ja4_processing
CLICKHOUSE_DB_LOGS=ja4_logs
CLICKHOUSE_DB_PROCESSING=ja4_processing
CLICKHOUSE_USER=analyst
CLICKHOUSE_PASSWORD=
API_HOST=0.0.0.0
CORS_ORIGINS=["http://localhost:3000"]

View File

@ -1,114 +0,0 @@
# Copilot Instructions — Bot Detector Dashboard
## Architecture Overview
This is a **SOC (Security Operations Center) dashboard** for visualizing bot detections from an upstream `bot_detector_ai` service. It is a **single-service, full-stack app**: the FastAPI backend serves the built React frontend as static files *and* exposes a REST API, all on port 8000. There is no separate frontend server in production and **no authentication**.
**Data source:** ClickHouse database (`ja4_processing`), primarily the `ml_detected_anomalies` table and the `view_dashboard_entities` view.
```
dashboard/
├── backend/ # Python 3.11 + FastAPI — REST API + static file serving
│ ├── main.py # App entry point: CORS, router registration, SPA catch-all
│ ├── config.py # pydantic-settings Settings, reads .env
│ ├── database.py # ClickHouseClient singleton (db)
│ ├── models.py # All Pydantic v2 response models
│ ├── routes/ # One module per domain: metrics, detections, variability,
│ │ # attributes, analysis, entities, incidents, audit, reputation
│ └── services/
│ └── reputation_ip.py # Async httpx → ip-api.com + ipinfo.io (no API keys)
└── frontend/ # React 18 + TypeScript 5 + Vite 5 + Tailwind CSS 3
└── src/
├── App.tsx # BrowserRouter + Sidebar + TopHeader + all Routes
├── ThemeContext.tsx # dark/light/auto, persisted to localStorage (key: soc_theme)
├── api/client.ts # Axios instance (baseURL: /api) + all TS interfaces
├── components/ # One component per route view + shared panels + ui/
├── hooks/ # useMetrics, useDetections, useVariability (polling wrappers)
└── utils/STIXExporter.ts
```
## Dev Commands
```bash
# Backend (run from repo root)
pip install -r requirements.txt
python -m uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
# Frontend (separate terminal)
cd frontend && npm install
npm run dev # :3000 with HMR, proxies /api → localhost:8000
npm run build # tsc type-check + vite build → frontend/dist/
npm run preview # preview the production build
# Docker (production)
docker compose up -d dashboard_web
docker compose build dashboard_web && docker compose up -d dashboard_web
docker compose logs -f dashboard_web
```
There is no test suite or linter configured (no pytest, vitest, ESLint, Black, etc.).
```bash
# Manual smoke tests
curl http://localhost:8000/health
curl http://localhost:8000/api/metrics | jq '.summary'
curl "http://localhost:8000/api/detections?page=1&page_size=5" | jq '.items | length'
```
## Key Conventions
### Backend
- **All routes are raw SQL** — no ORM. Results are accessed by positional index: `result.result_rows[0][n]`. Column order is determined by the `SELECT` statement.
- **Query parameters** use `%(name)s` dict syntax: `db.query(sql, {"param": value})`.
- **Every router module** defines `router = APIRouter(prefix="/api/<domain>", tags=["..."])` and is registered in `main.py` via `app.include_router(...)`.
- **SPA catch-all** (`/{full_path:path}`) **must remain the last registered route** in `main.py`. New routers must be added with `app.include_router()` before it.
- **IPv4 IPs** are stored as IPv6-mapped (`::ffff:x.x.x.x`) in `src_ip`; queries normalize with `replaceRegexpAll(toString(src_ip), '^::ffff:', '')`.
- **NULL guards** — all row fields are coalesced: `row[n] or ""`, `row[n] or 0`, `row[n] or "LOW"`.
- **`anomaly_score`** can be negative in the DB; always normalize with `abs()` for display.
- **`analysis.py`** stores SOC classifications in a `classifications` ClickHouse table. The `audit_logs` table is optional — routes silently return empty results if absent.
### Frontend
- **API calls** use the axios instance from `src/api/client.ts` (baseURL `/api`) or direct `fetch('/api/...')`. There is **no global state manager** — components use `useState`/`useEffect` or custom hooks directly.
- **TypeScript interfaces** in `client.ts` mirror the Pydantic models in `backend/models.py`. Both must be kept in sync when changing data shapes.
- **Tailwind uses semantic CSS-variable tokens** — always use `bg-background`, `bg-background-secondary`, `bg-background-card`, `text-text-primary`, `text-text-secondary`, `text-text-disabled`, `bg-accent-primary`, `threat-critical/high/medium/low` rather than raw Tailwind color classes (e.g., `slate-800`). This ensures dark/light theme compatibility.
- **Threat level taxonomy**: `CRITICAL` > `HIGH` > `MEDIUM` > `LOW` — always uppercase strings; colors: red / orange / yellow / green.
- **URL encoding**: entity values with special characters (JA4 fingerprints, subnets) are `encodeURIComponent`-encoded. Subnets use `_24` in place of `/24` (e.g., `/entities/subnet/141.98.11.0_24`).
- **Recent investigations** are stored in `localStorage` under `soc_recent_investigations` (max 8). Tracked by `RouteTracker` component. Only types `ip`, `ja4`, `subnet` are tracked.
- **Auto-refresh**: metrics every 30 s, incidents every 60 s.
- **French UI text** — all user-facing strings and log messages are in French; code identifiers are in English.
### Frontend → Backend in Dev vs Production
- **Dev**: Vite dev server on `:3000` proxies `/api/*` to `http://localhost:8000` (see `vite.config.ts`).
- **Production**: React SPA is served by FastAPI from `frontend/dist/`. API calls hit the same origin at `:8000` — no proxy needed.
### Docker
- Single service using `network_mode: "host"` — no port mapping; the container shares the host network stack.
- Multi-stage Dockerfile: `node:20-alpine` builds the frontend → `python:3.11-slim` installs deps → final image copies both.
## Environment Variables (`.env`)
| Variable | Default | Description |
|---|---|---|
| `CLICKHOUSE_HOST` | `clickhouse` | ClickHouse hostname |
| `CLICKHOUSE_PORT` | `8123` | ClickHouse HTTP port (set in code) |
| `CLICKHOUSE_DB` | `ja4_processing` | Database name |
| `CLICKHOUSE_USER` | `admin` | |
| `CLICKHOUSE_PASSWORD` | `` | |
| `API_HOST` | `0.0.0.0` | Uvicorn bind host |
| `API_PORT` | `8000` | Uvicorn bind port |
| `CORS_ORIGINS` | `["http://localhost:3000", ...]` | Allowed origins |
> ⚠️ The `.env` file contains real credentials — never commit it to public repos.
## ClickHouse Tables
| Table / View | Used by |
|---|---|
| `ml_detected_anomalies` | Primary source for detections, metrics, variability, analysis |
| `view_dashboard_entities` | User agents, client headers, paths, query params (entities routes) |
| `classifications` | SOC analyst classifications (created by `analysis.py`) |
| `ja4_processing.audit_logs` | Audit trail (optional — missing table is handled silently) |

View File

@ -1,86 +0,0 @@
# ═══════════════════════════════════════════════════════════════════════════════
# GITIGNORE - Bot Detector Dashboard
# ═══════════════════════════════════════════════════════════════════════════════
# ───────────────────────────────────────────────────────────────────────────────
# SÉCURITÉ - Ne jamais committer
# ───────────────────────────────────────────────────────────────────────────────
.env
.env.local
.env.production
*.pem
*.key
secrets/
credentials/
# ───────────────────────────────────────────────────────────────────────────────
# Python
# ───────────────────────────────────────────────────────────────────────────────
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.pytest_cache/
.coverage
htmlcov/
*.manifest
*.spec
# ───────────────────────────────────────────────────────────────────────────────
# Node.js / Frontend
# ───────────────────────────────────────────────────────────────────────────────
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
frontend/node_modules/
frontend/dist/
frontend/build/
package-lock.json
yarn.lock
# ───────────────────────────────────────────────────────────────────────────────
# IDE / Éditeurs
# ───────────────────────────────────────────────────────────────────────────────
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
# ───────────────────────────────────────────────────────────────────────────────
# Logs
# ───────────────────────────────────────────────────────────────────────────────
*.log
logs/
test_output.log
# ───────────────────────────────────────────────────────────────────────────────
# Docker
# ───────────────────────────────────────────────────────────────────────────────
docker-compose.override.yml
*.tar
# ───────────────────────────────────────────────────────────────────────────────
# Documentation temporaire
# ───────────────────────────────────────────────────────────────────────────────
# *.md.tmp
# *.md.bak

View File

@ -1,203 +0,0 @@
# Audit SOC du dashboard
## Résumé exécutif
Le dashboard est riche fonctionnellement (incidents, investigation IP/JA4, threat intel), mais **pas prêt pour un usage SOC en production** sans durcissement.
Points majeurs :
- **Sécurité daccès insuffisante** : pas dauthentification/RBAC.
- **Navigation incohérente** : plusieurs liens pointent vers des routes inexistantes.
- **Traçabilité/audit partielle** : journalisation contournable et parfois “success” même en échec.
- **Organisation UX perfectible** pour un triage SOC rapide (priorisation, workflow, “next actions”).
## Périmètre audité
- Frontend React (`frontend/src/App.tsx` + composants de navigation et investigation).
- Backend FastAPI (`backend/main.py` + routes `incidents`, `audit`, `entities`, `analysis`, `detections`, `reputation`).
- Documentation projet (`README.md`).
## Cartographie des pages et navigation
### Routes front déclarées
- `/``IncidentsView`
- `/threat-intel``ThreatIntelView`
- `/detections``DetectionsList`
- `/detections/:type/:value``DetailsView`
- `/investigation/:ip``InvestigationView`
- `/investigation/ja4/:ja4``JA4InvestigationView`
- `/entities/subnet/:subnet``SubnetInvestigation`
- `/entities/:type/:value``EntityInvestigationView`
- `/tools/correlation-graph/:ip``CorrelationGraph`
- `/tools/timeline/:ip?``InteractiveTimeline`
### Graphe de navigation (pages)
```mermaid
flowchart LR
A["/ (Incidents)"] --> B["/investigation/:ip"]
A --> C["/entities/subnet/:subnet"]
A --> X["/bulk-classify?ips=... (route absente)"]
A --> T["/threat-intel"]
D["/detections"] --> E["/detections/:type/:value"]
D --> B
E --> B
E --> F["/investigation/ja4/:ja4"]
C --> B
C --> G["/entities/ip/:ip"]
G --> B
G --> F
F --> B
B --> H["/tools/correlation-graph/:ip"]
B --> I["/tools/timeline/:ip?"]
Q["QuickSearch (global + local)"] --> Y["/investigate/... (route absente)"]
Q --> Z["/incidents?threat_level=CRITICAL (route absente)"]
```
### Incohérences de navigation identifiées
- `QuickSearch` navigue vers `/investigate/...` et `/incidents...` mais ces routes nexistent pas.
- `IncidentsView` envoie vers `/bulk-classify?...` sans route déclarée.
- `DetectionsList` utilise `window.location.href` (rechargement complet) au lieu du router.
- Navigation top-level limitée à 2 entrées (“Incidents”, “Threat Intel”), alors que “Détections” est une vue centrale SOC.
- Usage de `window.location.pathname` dans `App.tsx` pour récupérer `:ip` sur certaines routes outils (fragile, non idiomatique React Router).
## Constat sécurité / robustesse (usage SOC)
## Critique
- **Absence dauthentification et de RBAC** (confirmé aussi dans le README “usage local”).
- Impact SOC : impossible dattribuer correctement les actions analyste, risque daccès non maîtrisé.
- **Injection potentielle dans `entities.py`** :
- Construction dun `IN (...)` SQL par concaténation de valeurs (`ip_values`), non paramétrée.
- Impact : surface dinjection côté backend.
- **Audit log non fiable** :
- `/api/audit/logs` accepte un `user` fourni par la requête (default `soc_user`).
- En cas déchec dinsert audit, le code retourne quand même `status: success`.
- Impact : non-répudiation faible, traçabilité compromise.
## Élevé
- **Rate limiting non appliqué** :
- Variable `RATE_LIMIT_PER_MINUTE` existe mais pas de middleware effectif.
- Impact : exposition aux abus/DoS et scraping massif.
- **Fuite derreurs internes** :
- Plusieurs endpoints retournent `detail=f"Erreur: {str(e)}"`.
- Impact : divulgation dinformations techniques.
## Moyen
- **Dépendance externe réputation IP** (`ip-api` en HTTP + `ipinfo`) sans contrôle de résilience avancé (fallback opérationnel limité).
- **Composants avec `console.error`/`console.log`** en production front.
- **Endpoints incidents partiellement “mockés”** (`Implementation en cours`) pouvant tromper lanalyste.
## Format des pages : ce quil faut améliorer
## 1) Priorisation SOC visuelle
- Uniformiser les conventions de sévérité (couleur, wording, position).
- Ajouter un bandeau “Incidents nécessitant action immédiate” en haut de `/`.
- Afficher systématiquement : **niveau, confiance, impact, dernière activité, action recommandée**.
## 2) Densité et lisibilité
- Réduire lusage demojis non essentiels dans les zones de décision.
- Passer les tableaux volumineux en mode “triage” :
- colonnes par défaut minimales,
- tri par criticité/recence,
- tags compacts avec tooltip.
## 3) Workflow analyste explicite
- Introduire des CTA standardisés :
- `Investiguer`, `Escalader`, `Classer`, `Créer IOC`, `Exporter`.
- Ajouter une timeline dactions SOC (qui a fait quoi, quand, pourquoi) directement sur les vues incident/investigation.
## 4) Accessibilité opérationnelle
- Raccourcis clavier cohérents (navigation, filtres, next incident).
- État vide explicite + actions suggérées.
- Breadcrumb homogène entre toutes les vues.
## Organisation de linformation : recommandations
## IA) Repenser lIA de navigation (menu)
Proposition de structure :
- **Triage**
- Incidents (par défaut)
- Détections
- **Investigation**
- Recherche entité
- Vue IP
- Vue JA4
- Subnet
- **Knowledge**
- Threat Intel
- Tags/Patterns
- **Administration**
- Audit logs
- Santé plateforme
## IB) Normaliser les routes
- Remplacer les routes mortes (`/investigate`, `/incidents`, `/bulk-classify` non déclaré) par des routes existantes ou les implémenter.
- Éviter `window.location.*` dans les composants routés.
- Centraliser les chemins dans un module unique (ex: `routes.ts`) pour éviter les divergences.
## IC) Standardiser le modèle de page
Chaque page SOC devrait avoir la même ossature :
1. Contexte (titre + périmètre + horodatage).
2. KPIs critiques.
3. Tableau principal de triage.
4. Panneau actions.
5. Journal dactivité lié à la page.
## Plan damélioration priorisé
## Phase 1 (bloquant prod SOC)
- Ajouter auth SSO/OIDC + RBAC (viewer/analyst/admin).
- Corriger routes mortes et navigation cassée.
- Corriger requête SQL non paramétrée dans `entities.py`.
- Fiabiliser audit log (identité dérivée de lauth, échec explicite si log non écrit).
## Phase 2 (fiabilité)
- Mettre en place rate limiting effectif.
- Assainir gestion derreurs (messages utilisateurs + logs serveurs structurés).
- Retirer `window.location.href` et unifier navigation SPA.
## Phase 3 (UX SOC)
- Refonte “triage-first” des écrans (priorité, next action, temps de traitement).
- Uniformiser design tokens et hiérarchie visuelle.
- Ajouter vues “queue analyste” et “handover” (passation de quart).
## Verdict
Le socle est prometteur pour linvestigation technique, mais pour un SOC opérationnel il faut dabord :
1. **Sécuriser laccès et la traçabilité**.
2. **Fiabiliser la navigation et les routes**.
3. **Recentrer les pages sur le flux de triage SOC**.
Sans ces corrections, le risque principal est une **dette opérationnelle** (temps perdu en triage) et une **dette de conformité** (auditabilité insuffisante).

View File

@ -1,22 +1,10 @@
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY services/dashboard/frontend/package*.json ./
RUN npm install
COPY services/dashboard/frontend/ ./
RUN npm run build
FROM python:3.11-slim AS backend
WORKDIR /app
COPY shared/python/ja4_common/ /app/shared/ja4_common/
RUN pip install --no-cache-dir /app/shared/ja4_common/
COPY services/dashboard/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY services/dashboard/backend/ ./backend/
FROM python:3.11-slim
WORKDIR /app
COPY --from=backend /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=backend /app/backend ./backend
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
COPY services/dashboard/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY services/dashboard/backend/ ./backend/
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@ -1,10 +0,0 @@
FROM python:3.11-slim
WORKDIR /app
COPY shared/python/ja4_common/ /app/shared/ja4_common/
RUN pip install --no-cache-dir /app/shared/ja4_common/
COPY services/dashboard/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir pytest pytest-mock httpx
COPY services/dashboard/backend/ ./backend/
COPY services/dashboard/backend/tests/ ./backend/tests/
CMD ["pytest", "backend/tests/", "-v"]

View File

@ -1,242 +0,0 @@
# Rapport Final — SOC Bot Detector Dashboard
**Date :** 2026-03-16
**Commits :** `8032eba` (corrections bugs), `d4c3512` (améliorations)
---
## 1. Corrections de bugs (commit 8032eba)
| Bug | Cause | Correction |
|-----|-------|-----------|
| Brute Force > Attaquants : IPs affichées en `::ffff:x.x.x.x` | Pas de normalisation IPv6 dans la requête SQL | `replaceRegexpAll(toString(src_ip), '^::ffff:', '')` ajouté |
| Brute Force > Cibles : lien "Voir détails" → page inexistante | Navigation vers `/investigation/{host}` (hostname) au lieu d'une IP | Remplacement par composant `TargetRow` avec expansion inline des attaquants par host |
| Header Fingerprint : tableau de détail toujours vide | Frontend lisait `data.ips` au lieu de `data.items` | Correction de la clé |
| Heatmap Temporelle : "Top hosts ciblés" vide | Frontend lisait `data.hosts` + erreur de type TypeScript `{ hosts: TopHost[] }` | Correction clé `data.items` + type annotation |
| Botnets Distribués : clic sur ligne n'affiche rien | Frontend lisait `data.countries` au lieu de `data.items` | Correction de la clé |
| Rotation & Persistance : IPs en `::ffff:` + historique toujours vide | Pas de normalisation + frontend lisait `data.history` au lieu de `data.ja4_history` | Normalisation SQL + correction de la clé |
| TCP Spoofing : spoofings détectés sans corrélation TTL | Filtre Python-side sur données déjà filtrées TTL=3031 | Filtre SQL `spoof_only` déplacé côté ClickHouse |
---
## 2. Améliorations implémentées (commit d4c3512)
### J — Synthèse IP multi-sources
- **Endpoint :** `GET /api/investigation/{ip}/summary`
- **Widget :** `IPActivitySummary` en haut de toute page d'investigation IP
- **Données :** ML + bruteforce + TCP spoofing + JA4 rotation + persistance + timeline 24h
- **Score de risque :** 0100 (jauge SVG colorée)
- **Résultat :** Contexte immédiat en un coup d'œil, sans naviguer entre 6 pages
### I — Comparaison baseline 24h/hier
- **Endpoint :** `GET /api/metrics/baseline`
- **Widget :** 3 cartes (Détections 24h, IPs uniques, CRITICAL) avec variation ▲▼ en %
- **Impact :** Détecte immédiatement les pics anormaux (ex: +246% détections observé)
### M-4 — Score de sophistication adversaire
- **Endpoint :** `GET /api/rotation/sophistication`
- **Calcul :** JOIN 3 tables (rotation JA4 × 10 + récurrence × 20 + log(bruteforce+1) × 5)
- **Tiers :** APT-like / Advanced / Automated / Basic
- **Résultat :** Prioritisation des enquêtes les plus urgentes
### M-7 — Chasse proactive (low-and-slow)
- **Endpoint :** `GET /api/rotation/proactive-hunt`
- **Logique :** IPs récurrentes avec `abs(anomaly_score) < 0.5` — volent sous le radar ML
- **Évaluation :** "Évadeur potentiel" (ratio récurrence/score > 10) ou "Persistant modéré"
- **Impact :** Détecte les botnets slow-and-low que le modèle ML sous-score
### M-2 — Badge réputation ASN inline
- **Modification :** LEFT JOIN `asn_reputation` dans la requête des détections
- **Badge :** Rouge (malicious/bot/scanner), orange (proxy/vpn), vert (human)
- **Limitation :** La table `asn_reputation` contient 36 ASN français (ISPs légitimes) — les ASNs malveillants connus ne sont pas encore catalogués
---
## 3. Tests exhaustifs Playwright
| Page | Résultat | Notes |
|------|----------|-------|
| Dashboard principal | ✅ | Baseline ▲ +246.5% détections, ▲ +11.6% IPs, = CRITICAL |
| Détections | ✅ | Badge ASN affiché (null pour ASNs hors table reputation) |
| Investigation IP (162.55.94.175) | ✅ | Score 38, TCP Spoof TTL 59, JA4 Rotation 9 sig |
| Rotation > Sophistication | ✅ | APT-like: 162.55.94.175 (score 100), 46.4.81.149 (score 100) |
| Rotation > Chasse proactive | ✅ | IPs avec scores négatifs sous le radar ML |
| Brute Force > Attaquants | ✅ | IPs propres (sans `::ffff:`) |
| Brute Force > Cibles | ✅ | Expansion inline des attaquants par host |
| Header Fingerprint | ✅ | Tableau détail rempli au clic |
| Heatmap Temporelle | ✅ | Top hosts ciblés affiché |
| Botnets Distribués | ✅ | Détail pays au clic |
| TCP Spoofing | ✅ | Filtre `spoof_only` fonctionnel |
---
## 4. Points problématiques et axes d'amélioration
### 🔴 Critiques
1. **Table `asn_reputation` incomplète** — 36 entrées uniquement (ISPs français). Pour être utile, elle devrait contenir les ASNs des datacenters, VPS, proxies connus (OVH, DigitalOcean, AWS, Linode, etc.). Source suggérée : AbuseIPDB ASN database, IPInfo, Maxmind.
2. **Chasse proactive — scores négatifs**`view_ip_recurrence.worst_score` stocke le score brut (peut être négatif). La condition `abs(score) < 0.5` capture des IPs HIGH avec score -0.18 qui sont déjà détectées par ML. Il faudrait filtrer par niveau de menace (`worst_threat_level NOT IN ('HIGH', 'CRITICAL')`) pour vraiment identifier les cas sous le radar.
3. **Pas de persistance des classifications SOC** — Les classifications manuelles (`/api/analysis/classify`) ne persistent que pendant la session si la table `classifications` n'est pas créée. Un script d'init DB serait utile.
### 🟡 Moyens
4. **Score de sophistication biaised** — Les IPs avec forte rotation JA4 mais `recurrence=0` dans `view_ip_recurrence` (non présentes) atteignent quand même score 100. Les données des deux vues ne sont pas toujours cohérentes sur la même période temporelle.
5. **Timeline 24h dans la synthèse IP** — Utilise `window_start >= now() - INTERVAL 24 HOUR` sur `agg_host_ip_ja4_1h`. Si les données ont moins de 24h d'historique, le graphique sera partiel/vide. Adapter la fenêtre dynamiquement selon les données disponibles.
6. **Heatmap Temporelle** — Les données de `agg_host_ip_ja4_1h` ne sont agrégées que pour les dernières 24h dans l'endpoint. Un sélecteur de plage temporelle (7j, 30j) permettrait de détecter les patterns de vagues cycliques (botnets hebdomadaires).
7. **Pas d'export des résultats** — Les analystes SOC ne peuvent pas exporter les listes d'IPs malveillantes (CSV, STIX). Un endpoint `GET /api/rotation/sophistication?format=csv` serait utile pour l'IOC sharing.
### 🟢 Mineurs
8. **"Investiguer" dans le RotationView ne transmet pas le contexte** — Un clic sur "Investiguer" depuis l'onglet Sophistication navigue vers `/investigation/{ip}` sans pré-charger le contexte de l'onglet source. Un `?source=sophistication&score=100` dans l'URL permettrait d'afficher un bandeau contextuel.
9. **Onglets non présents dans la sidebar** — Les 7 dashboards d'analyse avancée ne sont pas organisés en sous-menus. Avec l'ajout des onglets Sophistication et Chasse proactive dans Rotation, la sidebar commence à être longue.
10. **Badge ASN ne trie pas les détections** — Il n'y a pas encore de filtre "Afficher seulement les ASNs malveillants" dans les détections.
---
## 5. Architecture — points de vigilance
- Le **SPA catch-all** (`/{full_path:path}`) doit rester **le dernier router** dans `main.py`
- L'endpoint `/api/investigation/{ip}/summary` utilise le préfixe `/api/investigation` — compatible avec la route SPA `/investigation/:ip` (distinct)
- Les **scores négatifs** dans `anomaly_score` et `worst_score` sont normaux — toujours utiliser `abs()` pour l'affichage
- Les **IPv6-mapped** (`::ffff:x.x.x.x`) sont présentes dans toutes les vues agrégées — systématiquement utiliser `replaceRegexpAll(toString(src_ip), '^::ffff:', '')`
---
# Rapport — v2.0.0 : TCP Fingerprinting Multi-Signal + Clustering IPs
**Date :** 2026-03-19
**Commit :** `e2db8ca`
---
## 1. TCP Fingerprinting OS amélioré
### Problème initial
L'ancien `tcp_spoofing.py` utilisait uniquement le TTL avec 3 plages grossières (≤64 = Linux, ≤128 = Windows, sinon = Network). Résultat : faux positifs, aucune détection de bots scanners.
### Solution implémentée
**`backend/services/tcp_fingerprint.py`** — 20 signatures OS, scoring multi-signal :
| Signal | Poids | Source ClickHouse |
|--------|-------|------------------|
| TTL initial (estimé) | 40% | `tcp_ttl_raw` |
| MSS | 30% | `tcp_mss_raw` |
| Fenêtre TCP | 20% | `tcp_win_raw` |
| Scale factor | 10% | `tcp_scale_raw` |
**Détections validées en production :**
- **Masscan** : `win=5808, mss=1452, scale=4, TTL 4857` → confiance **97%**
- **Googlebot** : stack Windows détecté avec UA Android → **spoof confirmé**
- **Bot-tool** : `risk_score += 30` (vs +15 pour spoof simple)
**MSS → chemin réseau :**
- 1460 → Ethernet standard
- 1452 → PPPoE / DSL (Masscan pattern)
- 14201452 → VPN probable
- < 1420 Tunnel / double-encapsulation
**Fichiers modifiés :**
- `backend/services/tcp_fingerprint.py` (nouveau)
- `backend/routes/tcp_spoofing.py` (réécriture complète queries `agg_host_ip_ja4_1h`)
- `backend/routes/investigation_summary.py` (utilise le service tcp_fingerprint)
- `frontend/src/components/TcpSpoofingView.tsx` (nouvelles colonnes MSS/scale/confiance, graphique distribution MSS)
---
## 2. Clustering IPs multi-métriques
### Problème initial
La première version du clustering utilisait uniquement des règles sur les propriétés TCP. L'utilisateur a demandé d'utiliser **l'ensemble des métriques disponibles**.
### Solution implémentée
**`backend/services/clustering_engine.py`** K-means++ pur Python (sans dépendances ML) :
**21 features normalisées [0,1] :**
| Catégorie | Features |
|-----------|----------|
| Stack TCP (4) | TTL initial, MSS, scale, fenêtre |
| Anomalie ML (6) | score, vélocité, fuzzing, headless, POST ratio, IP-ID zéro |
| TLS/Protocole (5) | ALPN mismatch, ALPN absent, efficacité H2, ordre headers, UA-CH mismatch |
| Navigateur (1) | score navigateur moderne (normalisé /50) |
| Temporel (3) | entropie, diversité JA4 (log1p), UA rotatif |
| Comportement (2) | ratio assets, ratio accès direct |
**Algorithme :**
```
K-means++ : init O(k·n), n_init=3, meilleure inertie retenue
Power iter : X^T(Xv) trick, O(n·d) par iter — pas de matrice n×n
Déflation : Hotelling pour PC2 après extraction PC1
```
**Stratégie d'échantillonnage :** `ORDER BY avg(abs(anomaly_score)) DESC` les bots (score élevé) sont inclus en priorité, même si leurs hits individuels sont faibles (cas Masscan).
**Résultats en production (k=14, 3000 IPs) :**
- **289 bots confirmés** : clusters UA rotatif + UA-CH mismatch (cloud providers : Microsoft, Google, Akamai)
- **655 IPs suspects** : anomalie ML modérée ou UA-CH incohérent
- **ASN dominants** : MICROSOFT-CORP-MSN-AS-BLOCK, GOOGLE-CLOUD-PLATFORM, OVH, AMAZON
- **Temps de calcul** : ~59 secondes (Python pur, 3000 points × 21 features)
---
## 3. Visualisation clustering redesignée
### Problème initial
La première version utilisait des bulles ReactFlow positionnées par PCA. L'utilisateur a signalé : **"l'affichage du graphe est illisible"**.
### Solution implémentée
**Deux vues distinctes, accessibles par onglets :**
#### ⊞ Tableau de bord (défaut — toujours lisible)
- Grille de cartes groupées par niveau de risque
- **Bots & Menaces confirmées** (rouge) **Suspects** (orange) **Légitimes** (vert)
- Chaque carte : label + IP count + hits + badge CRITIQUE/ÉLEVÉ/MODÉRÉ/SAIN + 4 mini-barres + stack TCP + pays + ASN
#### ⬡ Graphe de relations
- Nœuds-cartes ReactFlow (220px texte entièrement lisible)
- **Colonnes par niveau de menace** (disposition déterministe, pas PCA)
- Arêtes colorées : orange=similaire, gris=distant, animé=très fort
- Légende intégrée, minimap, contrôles zoom
#### Sidebar de détail
- RadarChart comportemental (10 axes)
- Toutes les métriques avec barres de progression
- Liste des IPs avec badges menace/pays
- Export **Copier IPs** + ** CSV**
- Intégrée dans le flux flex (ne bloque plus la barre de contrôle)
**Fichiers modifiés :**
- `backend/routes/clustering.py` (réécriture complète)
- `backend/services/clustering_engine.py` (nouveau seuils calibrés sur données réelles)
- `frontend/src/components/ClusteringView.tsx` (réécriture complète)
- `frontend/src/App.tsx` (route `/clustering` + nav "🔬 Clustering IPs")
---
## 4. Points d'attention
### Performances
- K-means++ sur 3000 × 21 : **59s** (acceptable pas de cache implémenté)
- Le cache mémoire du drill-down (`_cache["cluster_ips"]`) est volatile : rechargement = recalcul
- Pour améliorer : cache Redis ou TTL 5 min avec `functools.lru_cache`
### Calibration des seuils
Les seuils de `name_cluster()` et `risk_score_from_centroid()` sont calibrés sur les données observées :
- `anomaly_score` en production : plage 0.20.35 (pas 01 comme attendu)
- Score normalisé affiché : `min(1, score / 0.5)` pour étirer la plage utile
- UA-CH mismatch = 1.0 sur les clusters bot = signal **très fort** (cloud providers simulant un navigateur)
### Données manquantes dans le LEFT JOIN
Certaines IPs n'apparaissent pas dans `ml_detected_anomalies` (score=0, fuzz=0). Ce sont les IPs légitimes non détectées par le modèle ML. Elles forment naturellement les clusters "Trafic Légitime".
### Fuzzing_index = 100% dans beaucoup de clusters
Après analyse : le `fuzzing_index` log-normalisé dépasse souvent le seuil de 100% car les valeurs brutes sont très variables (0 à 229+). Ce n'est pas un bug c'est la nature du trafic web moderne (beaucoup de requêtes avec des paths variés).

View File

@ -1,672 +0,0 @@
# 🛡️ Bot Detector Dashboard
Dashboard web interactif pour visualiser et investiguer les décisions de classification du Bot Detector IA.
**Version:** 2.0.0 - TCP Fingerprinting Multi-Signal + Clustering IPs Multi-Métriques
## 🚀 Démarrage Rapide
### Prérequis
- Docker et Docker Compose
- Le service `clickhouse` déjà déployé
- Des données dans la table `ml_detected_anomalies`
- Des données dans la table `http_logs` (pour les user-agents)
> **Note:** Le dashboard peut fonctionner indépendamment de `bot_detector_ai`. Il lit les données déjà détectées dans ClickHouse.
### Lancement
```bash
# 1. Vérifier que .env existe
cp .env.example .env # Si ce n'est pas déjà fait
# 2. Lancer le dashboard (avec Docker Compose v2)
docker compose up -d dashboard_web
# Ou avec l'ancienne syntaxe
docker-compose up -d dashboard_web
# 3. Ouvrir le dashboard
# http://localhost:3000
```
### Arrêt
```bash
docker compose stop dashboard_web
```
### Vérifier le statut
```bash
# Voir les services en cours d'exécution
docker compose ps
# Voir les logs en temps réel
docker compose logs -f dashboard_web
```
## 📊 Fonctionnalités
### Dashboard Principal
- **Métriques en temps réel** : Total détections, menaces, bots connus, IPs uniques
- **Comparaison baseline J-1** : variation ▲▼ vs hier (détections, IPs uniques, CRITICAL)
- **Répartition par menace** : Visualisation CRITICAL/HIGH/MEDIUM/LOW
- **Évolution temporelle** : Graphique des détections sur 24h
- **Incidents clusterisés** : Regroupement automatique par subnet /24
- **Top Menaces Actives** : Top 10 des IPs les plus dangereuses
### 🧬 TCP Spoofing & Fingerprinting OS (amélioré v2.0)
- **Détection multi-signal** : TTL initial + MSS + scale + fenêtre TCP (p0f-style)
- **20 signatures OS** : Linux, Windows, macOS, Android, iOS, Masscan, ZMap, Shodan, Googlebot…
- **Estimation hop-count** : différence TTL initial (arrondi) TTL observé
- **Détection réseau** : MSS → Ethernet (1460) / PPPoE (1452) / VPN (1420) / Tunnel (<1420)
- **Confiance 0100%** : score pondéré (TTL 40% + MSS 30% + fenêtre 20% + scale 10%)
- **Badge bot-tool** : Masscan détecté à 97% (win=5808, mss=1452, scale=4)
- **Distribution MSS** : histogramme des MSS observés par cluster
### 🔬 Clustering IPs Multi-Métriques (nouveau v2.0)
- **URL:** `/clustering`
- **Algorithme :** K-means++ (Arthur & Vassilvitskii, 2007), initialisé avec k-means++, 3 runs
- **21 features normalisées [0,1] :**
- Stack TCP : TTL initial, MSS, scale, fenêtre TCP
- Anomalie ML : score, vélocité, fuzzing, headless, POST ratio, IP-ID zéro
- TLS/Protocole : ALPN mismatch, ALPN absent, efficacité H2 (multiplexing)
- Navigateur : score navigateur moderne, ordre headers, UA-CH mismatch
- Temporel : entropie, diversité JA4, UA rotatif
- **Positionnement 2D :** PCA par puissance itérative (Hotelling) + déflation
- **Nommage automatique :** Masscan / Bot UA Rotatif / Bot Fuzzer / Anomalie ML / Linux / Windows / VPN
**Vue Tableau de bord (défaut) :**
- Grille de cartes groupées : Bots confirmés Suspects Légitimes
- Chaque carte : label, IP count, hits, badge CRITIQUE/ÉLEVÉ/MODÉRÉ/SAIN
- 4 mini-barres : anomalie, UA-CH mismatch, fuzzing, UA rotatif
- Stack TCP (TTL, MSS, Scale), top pays, ASN
**Vue Graphe de relations :**
- Nœuds-cartes ReactFlow (220px, texte lisible)
- Colonnes par niveau de menace : Bots | Suspects | Légitimes
- Arêtes colorées par similarité (orange=fort, animé=très fort)
- Légende intégrée, minimap, contrôles zoom
**Sidebar de détail :**
- RadarChart comportemental (10 axes : anomalie, UA-CH, fuzzing, headless…)
- Toutes les métriques avec barres de progression colorées
- Liste des IPs avec badges menace/pays/ASN
- Export **Copier IPs** + ** CSV**
### Investigation Subnet /24
- **URL:** `/entities/subnet/x.x.x.x_24`
- Stats globales, tableau des IPs, actions par IP
### Investigation IP + Réputation
- **URL:** `/investigation/:ip`
- Synthèse multi-sources (ML + bruteforce + TCP + JA4 + timeline)
- Score de risque 0100, réputation IP-API + IPinfo
### Investigation (Variabilité)
- User-Agents, JA4 fingerprints, pays, ASN, hosts, niveaux de menace
- Insights automatiques, navigation enchaînable
## 🏗️ Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Docker Compose │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ ClickHouse │ │ bot_detector│ │ dashboard_web │ │
│ │ :8123 │ │ (existant) │ │ :8000 (web+API)│ │
│ │ :9000 │ │ │ │ network=host │ │
│ └──────┬──────┘ └──────┬──────┘ └────────┬────────┘ │
│ └────────────────┴───────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
> Le container utilise `network_mode: "host"` — le frontend buildé est servi par FastAPI
> sur le **port 8000 uniquement** (pas de port 3000 en production).
### Composants
| Composant | Technologie | Description |
|-----------|-------------|-------------|
| **Frontend** | React 18 + TypeScript 5 + Vite 5 + Tailwind CSS 3 | Interface utilisateur (SPA) |
| **Backend API** | FastAPI 0.111 + Python 3.11 | API REST + serveur statique SPA |
| **Database** | ClickHouse (existant) port 8123 | Base de données principale |
| **Clustering** | K-means++ pur Python + PCA puissance itérative | Algorithmes embarqués, sans dépendance ML |
## 📁 Structure
```
dashboard/
├── Dockerfile # Multi-stage: node:20-alpine → python:3.11-slim
├── docker-compose.yaml
├── requirements.txt
├── backend/
│ ├── main.py # FastAPI: CORS, routers, SPA catch-all (doit être DERNIER)
│ ├── config.py # pydantic-settings, lit .env
│ ├── database.py # ClickHouseClient singleton (db)
│ ├── models.py # Modèles Pydantic v2
│ ├── routes/
│ │ ├── metrics.py # GET /api/metrics, /api/metrics/baseline
│ │ ├── detections.py # GET /api/detections
│ │ ├── variability.py # GET /api/variability
│ │ ├── attributes.py # GET /api/attributes
│ │ ├── incidents.py # GET /api/incidents/clusters
│ │ ├── entities.py # GET /api/entities
│ │ ├── analysis.py # GET/POST /api/analysis — classifications SOC
│ │ ├── reputation.py # GET /api/reputation/ip/{ip}
│ │ ├── tcp_spoofing.py # GET /api/tcp-spoofing — fingerprinting OS multi-signal
│ │ ├── clustering.py # GET /api/clustering/clusters + /cluster/{id}/ips
│ │ └── investigation_summary.py # GET /api/investigation/{ip}/summary
│ └── services/
│ ├── tcp_fingerprint.py # 20 signatures OS, scoring, hop-count, réseau path
│ ├── clustering_engine.py # K-means++, PCA-2D, nommage, score risque (pur Python)
│ └── reputation_ip.py # httpx → ip-api.com + ipinfo.io (async, sans API key)
└── frontend/
├── package.json
├── vite.config.ts # Proxy /api → :8000 en dev
└── src/
├── App.tsx # BrowserRouter + Sidebar + TopHeader + Routes
├── ThemeContext.tsx # dark/light/auto, localStorage: soc_theme
├── api/client.ts # Axios baseURL=/api + toutes les interfaces TypeScript
├── components/
│ ├── ClusteringView.tsx # K-means++ clustering — 2 vues
│ ├── TcpSpoofingView.tsx # TCP fingerprinting OS
│ ├── InvestigationView.tsx # Investigation IP complète
│ └── ... # Autres vues
├── hooks/ # useMetrics, useDetections, useVariability (polling)
└── utils/STIXExporter.ts
```
## 🔌 API
### Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/metrics` | Métriques globales |
| GET | `/api/metrics/baseline` | Comparaison J-1 (détections, IPs, CRITICAL) |
| GET | `/api/metrics/threats` | Distribution par menace |
| GET | `/api/detections` | Liste des détections paginée |
| GET | `/api/detections/{id}` | Détails d'une détection |
| GET | `/api/variability/{type}/{value}` | Variabilité d'un attribut |
| GET | `/api/attributes/{type}` | Valeurs uniques d'un attribut |
| GET | `/api/incidents/clusters` | Incidents clusterisés par subnet /24 |
| GET | `/api/entities/subnet/{subnet}` | Investigation subnet (ex: `141.98.11.0_24`) |
| GET | `/api/entities/{type}/{value}` | Investigation entité (IP, JA4, UA…) |
| GET | `/api/reputation/ip/{ip}` | Réputation IP (IP-API + IPinfo) |
| GET | `/api/investigation/{ip}/summary` | Synthèse IP multi-sources (ML + TCP + JA4) |
| GET | `/api/analysis/{ip}/subnet` | Analyse subnet / ASN |
| GET | `/api/analysis/{ip}/recommendation` | Recommandation de classification |
| POST | `/api/analysis/classifications` | Sauvegarder classification SOC |
| GET | `/api/tcp-spoofing/overview` | Vue d'ensemble TCP spoofing + OS |
| GET | `/api/tcp-spoofing/list` | Liste des détections TCP spoofing |
| GET | `/api/tcp-spoofing/matrix` | Matrice OS déclaré vs OS réel |
| GET | `/api/clustering/clusters` | Clustering K-means++ (`?k=14&n_samples=3000`) |
| GET | `/api/clustering/cluster/{id}/ips` | IPs d'un cluster (drill-down) |
| GET | `/health` | Health check |
### Exemples
```bash
# Health check
curl http://localhost:8000/health
# Métriques globales + baseline
curl http://localhost:8000/api/metrics | jq '.summary'
curl http://localhost:8000/api/metrics/baseline | jq
# Détections CRITICAL
curl "http://localhost:8000/api/detections?threat_level=CRITICAL&page=1" | jq '.items | length'
# TCP Spoofing — vue d'ensemble
curl http://localhost:8000/api/tcp-spoofing/overview | jq
# Clustering IPs (14 clusters sur 3000 échantillons)
curl "http://localhost:8000/api/clustering/clusters?k=14&n_samples=3000" | jq '.stats'
# Drill-down d'un cluster
curl "http://localhost:8000/api/clustering/cluster/c0_k14/ips?limit=20" | jq '.ips[].ip'
# Réputation IP
curl http://localhost:8000/api/reputation/ip/162.55.94.175 | jq
```
## ⚙️ Configuration
### Variables d'Environnement
| Variable | Défaut | Description |
|----------|--------|-------------|
| `CLICKHOUSE_HOST` | `clickhouse` | Hôte ClickHouse |
| `CLICKHOUSE_PORT` | `8123` | Port HTTP ClickHouse |
| `CLICKHOUSE_DB` | `ja4_processing` | Base de données |
| `CLICKHOUSE_USER` | `admin` | Utilisateur |
| `CLICKHOUSE_PASSWORD` | `` | Mot de passe |
| `API_HOST` | `0.0.0.0` | Bind Uvicorn |
| `API_PORT` | `8000` | Port API + frontend |
| `CORS_ORIGINS` | `["http://localhost:3000", ...]` | Origines CORS autorisées |
Ces variables sont lues depuis le fichier `.env` à la racine du projet.
> ⚠️ Le fichier `.env` contient les credentials réels — ne jamais le committer.
## 🔍 Workflows d'Investigation
### Exemple 1 : Identifier un bot Masscan
1. **🔬 Clustering IPs** → Cluster "🤖 Masscan / Scanner IP" visible en rouge
2. **Clic sur la carte** → Sidebar : TTL=52, MSS=1452, Scale=4 — pattern Masscan
3. **Copier les IPs** → Liste prête pour le blocage
4. **Export CSV** → Import dans le SIEM ou firewall
### Exemple 2 : Analyser des bots UA-rotatifs (cloud)
1. **Clustering** → Cluster "🤖 Bot UA Rotatif + CH Mismatch" (risque 50%)
2. **RadarChart** → UA-CH=100%, UA rotatif=100%, anomalie=59%
3. **Top ASN** → Microsoft, Google, Akamai — cloud providers
4. **🧬 TCP Spoofing** → Confirmer : ces IPs déclarent Windows UA mais ont TTL Linux
5. **Investigation IP** → Détail complet avec timeline 24h
### Exemple 3 : Détecter le spoofing d'OS
1. **🧬 TCP Spoofing** → Liste des IPs avec mismatch OS
2. **Matrice UA×OS** → User-Agent Android mais stack TCP Windows = spoof
3. **Confiance 85%** → MSS=1460 (Ethernet), scale=7, TTL≈64 → Linux réel
4. **Action** → Classer comme bot avec IP proxy
### Exemple 4 : Investiguer une IP suspecte
1. **🎯 Détections** → IP classifiée 🔴 CRITICAL
2. **Clic sur l'IP** → Synthèse : ML + TCP + JA4 + bruteforce + timeline
3. **Score de risque** : 85/100
4. **User-Agents** → 3 UA différents en 24h (rotation)
5. **TCP** → TTL initial 128 (Windows) mais UA Linux → spoof
6. **Action** → Blacklist immédiate
## 🧬 Services techniques (v2.0)
### `backend/services/tcp_fingerprint.py`
Détection multi-signal de l'OS réel basée sur la stack TCP :
```python
from backend.services.tcp_fingerprint import fingerprint_os, detect_spoof
result = fingerprint_os(ttl=52, win=5808, scale=4, mss=1452)
# → OSFingerprint(os_family="Masscan/Scanner", confidence=0.97, is_bot_tool=True)
spoof = detect_spoof(declared_ua="Chrome/Windows", fingerprint=result)
# → SpoofResult(is_spoof=True, reason="UA Windows mais stack Masscan", risk_score=30)
```
**Poids du scoring :** TTL initial 40% + MSS 30% + fenêtre 20% + scale 10%
**Estimation hop-count :**
- TTL observé 52 → TTL initial arrondi = 64 → hops = 64 52 = **12**
- TTL observé 119 → TTL initial = 128 → hops = 9
**MSS → chemin réseau :**
| MSS | Réseau détecté |
|-----|---------------|
| 1460 | Ethernet standard |
| 1452 | PPPoE / DSL |
| 14201452 | VPN probable |
| < 1420 | Tunnel / double-encap |
### `backend/services/clustering_engine.py`
K-means++ + PCA-2D embarqués en pur Python (sans numpy/sklearn) :
```
K-means++ init : O(k·n) distances, n_init=3 runs → meilleure inertie
Power iteration : X^T(Xv) trick → O(n·d) par itération, pas de matrice n×n
Déflation Hotelling : retire PC1 de X avant de calculer PC2
```
**21 features normalisées [0,1]** — voir `FEATURES` dans le fichier.
**Nommage automatique** par priorité décroissante :
1. Pattern Masscan (mss 14401460, scale 35, TTL<60)
2. Fuzzing agressif (fuzzing_index normalisé > 0.35 ≈ valeur brute > 100)
3. UA rotatif + UA-CH mismatch simultanés
4. UA-CH mismatch seul > 80%
5. Score anomalie ML > 20% + signal comportemental
6. Classification réseau / OS par TTL/MSS
## 🗄️ Tables ClickHouse utilisées
| Table / Vue | Routes |
|---|---|
| `ja4_processing.ml_detected_anomalies` | metrics, detections, variability, analysis, clustering |
| `ja4_processing.agg_host_ip_ja4_1h` | tcp_spoofing, clustering, investigation_summary |
| `ja4_processing.view_dashboard_entities` | entities (UA, JA4, paths, query params) |
| `ja4_processing.classifications` | analysis (classifications SOC manuelles) |
| `ja4_processing.audit_logs` | audit (optionnel — silencieux si absent) |
**Conventions SQL :**
- IPs stockées en IPv6-mappé : `replaceRegexpAll(toString(src_ip), '^::ffff:', '')`
- `anomaly_score` peut être négatif : toujours utiliser `abs()`
- `fuzzing_index` peut dépasser 200 : normaliser avec `log1p`
- `multiplexing_efficiency` peut dépasser 1 : normaliser avec `log1p`
- Paramètres SQL : syntaxe `%(name)s` (dict ClickHouse)
- **SPA catch-all DOIT être le dernier router dans `main.py`**
## 🎨 Thème
Le dashboard utilise un **thème sombre** optimisé SOC (dark par défaut, clair et auto disponibles) :
- **Tokens CSS sémantiques** : `bg-background`, `bg-background-card`, `text-text-primary`, `text-text-secondary`…
- **Taxonomie menaces** : rouge CRITICAL / orange HIGH / jaune MEDIUM / vert LOW
- **Persistance** : `localStorage` clé `soc_theme`
- **Ne jamais utiliser** de classes Tailwind brutes (`slate-800`) — toujours les tokens sémantiques
## 📝 Logs
Les logs du dashboard sont accessibles via Docker :
```bash
# Logs du container
docker logs dashboard_web
# Logs en temps réel
docker logs -f dashboard_web
```
## 🧪 Tests et Validation
### Script de test rapide
Créez un fichier `test_dashboard.sh` :
```bash
#!/bin/bash
echo "=== Test Dashboard Bot Detector ==="
# 1. Health check
echo -n "1. Health check... "
curl -s http://localhost:3000/health > /dev/null && echo "✅ OK" || echo "❌ ÉCHOUÉ"
# 2. API Metrics
echo -n "2. API Metrics... "
curl -s http://localhost:3000/api/metrics | jq -e '.summary' > /dev/null && echo "✅ OK" || echo "❌ ÉCHOUÉ"
# 3. API Detections
echo -n "3. API Detections... "
curl -s http://localhost:3000/api/detections | jq -e '.items' > /dev/null && echo "✅ OK" || echo "❌ ÉCHOUÉ"
# 4. Frontend
echo -n "4. Frontend HTML... "
curl -s http://localhost:3000 | grep -q "Bot Detector" && echo "✅ OK" || echo "❌ ÉCHOUÉ"
echo "=== Tests terminés ==="
```
Rendez-le exécutable et lancez-le :
```bash
chmod +x test_dashboard.sh
./test_dashboard.sh
```
### Tests manuels de l'API
```bash
# 1. Health check
curl http://localhost:3000/health
# 2. Métriques globales
curl http://localhost:3000/api/metrics | jq
# 3. Liste des détections (page 1, 25 items)
curl "http://localhost:3000/api/detections?page=1&page_size=25" | jq
# 4. Filtrer par menace CRITICAL
curl "http://localhost:3000/api/detections?threat_level=CRITICAL" | jq '.items[].src_ip'
# 5. Distribution par menace
curl http://localhost:3000/api/metrics/threats | jq
# 6. Liste des IPs uniques (top 10)
curl "http://localhost:3000/api/attributes/ip?limit=10" | jq
# 7. Variabilité d'une IP (remplacer par une IP réelle)
curl http://localhost:3000/api/variability/ip/192.168.1.100 | jq
# 8. Variabilité d'un pays
curl http://localhost:3000/api/variability/country/FR | jq
# 9. Variabilité d'un ASN
curl http://localhost:3000/api/variability/asn/16276 | jq
```
### Test du Frontend
```bash
# Vérifier que le HTML est servi
curl -s http://localhost:3000 | head -20
# Ou ouvrir dans le navigateur
# http://localhost:3000
```
### Scénarios de test utilisateur
1. **Navigation de base**
- Ouvrir http://localhost:3000
- Vérifier que les métriques s'affichent
- Cliquer sur "📋 Détections"
2. **Recherche et filtres**
- Rechercher une IP : `192.168`
- Filtrer par menace : CRITICAL
- Changer de page
3. **Investigation (variabilité)**
- Cliquer sur une IP dans le tableau
- Vérifier la section "User-Agents" (plusieurs valeurs ?)
- Cliquer sur un User-Agent pour investiguer
- Utiliser le breadcrumb pour revenir en arrière
4. **Insights**
- Trouver une IP avec plusieurs User-Agents
- Vérifier que l'insight "Possible rotation/obfuscation" s'affiche
### Vérifier les données ClickHouse
```bash
# Compter les détections (24h)
docker compose exec clickhouse clickhouse-client -d ja4_processing -q \
"SELECT count() FROM ml_detected_anomalies WHERE detected_at >= now() - INTERVAL 24 HOUR"
# Voir un échantillon
docker compose exec clickhouse clickhouse-client -d ja4_processing -q \
"SELECT src_ip, threat_level, model_name, detected_at FROM ml_detected_anomalies ORDER BY detected_at DESC LIMIT 5"
# Vérifier les vues du dashboard
docker compose exec clickhouse clickhouse-client -d ja4_processing -q \
"SELECT * FROM view_dashboard_summary"
```
---
## 🐛 Dépannage
### Diagnostic rapide
```bash
# 1. Vérifier que les services tournent
docker compose ps
# 2. Vérifier les logs du dashboard
docker compose logs dashboard_web | tail -50
# 3. Tester la connexion ClickHouse depuis le dashboard
docker compose exec dashboard_web curl -v http://clickhouse:8123/ping
```
### Le dashboard ne démarre pas
```bash
# Vérifier les logs
docker compose logs dashboard_web
# Erreur courante: Port déjà utilisé
# Solution: Changer le port dans docker-compose.yml
# Erreur courante: Image non construite
docker compose build dashboard_web
docker compose up -d dashboard_web
```
### Aucune donnée affichée (dashboard vide)
```bash
# 1. Vérifier qu'il y a des données dans ClickHouse
docker compose exec clickhouse clickhouse-client -d ja4_processing -q \
"SELECT count() FROM ml_detected_anomalies WHERE detected_at >= now() - INTERVAL 24 HOUR"
# Si le résultat est 0:
# - Lancer bot_detector_ai pour générer des données
docker compose up -d bot_detector_ai
docker compose logs -f bot_detector_ai
# - Ou importer des données manuellement
```
### Erreur "Connexion ClickHouse échoué"
```bash
# 1. Vérifier que ClickHouse est démarré
docker compose ps clickhouse
# 2. Tester la connexion
docker compose exec clickhouse clickhouse-client -q "SELECT 1"
# 3. Vérifier les credentials dans .env
cat .env | grep CLICKHOUSE
# 4. Redémarrer le dashboard
docker compose restart dashboard_web
# 5. Vérifier les logs d'erreur
docker compose logs dashboard_web | grep -i error
```
### Erreur 404 sur les routes API
```bash
# Vérifier que l'API répond
curl http://localhost:3000/health
curl http://localhost:3000/api/metrics
# Si 404, redémarrer le dashboard
docker compose restart dashboard_web
```
### Port 3000 déjà utilisé
```bash
# Option 1: Changer le port dans docker-compose.yml
# Remplacer: - "3000:8000"
# Par: - "8080:8000"
# Option 2: Trouver et tuer le processus
lsof -i :3000
kill <PID>
# Puis redémarrer
docker compose up -d dashboard_web
```
### Frontend ne se charge pas (page blanche)
```bash
# 1. Vérifier la console du navigateur (F12)
# 2. Vérifier que le build frontend existe
docker compose exec dashboard_web ls -la /app/frontend/dist
# 3. Si vide, reconstruire l'image
docker compose build --no-cache dashboard_web
docker compose up -d dashboard_web
```
### Logs d'erreur courants
| Erreur | Cause | Solution |
|--------|-------|----------|
| `Connection refused` | ClickHouse pas démarré | `docker compose up -d clickhouse` |
| `Authentication failed` | Mauvais credentials | Vérifier `.env` |
| `Table doesn't exist` | Vues non créées | Lancer `deploy_views.sql` |
| `No data available` | Pas de données | Lancer `bot_detector_ai` |
---
## 🔒 Sécurité
- **Pas d'authentification** : Dashboard conçu pour un usage local
- **CORS restreint** : Seulement localhost:3000
- **Rate limiting** : 100 requêtes/minute
- **Credentials** : Via variables d'environnement (jamais en dur)
## 📊 Performances
- **Temps de chargement** : < 2s (avec données)
- **Requêtes ClickHouse** : Optimisées avec agrégations
- **Rafraîchissement auto** : 30 secondes (métriques)
## 🧪 Développement
### Build local (sans Docker)
```bash
# Backend
cd dashboard
pip install -r requirements.txt
python -m uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
# Frontend (dans un autre terminal)
cd dashboard/frontend
npm install
npm run dev # http://localhost:5173
```
### Documentation API interactive
L'API inclut une documentation Swagger interactive :
```bash
# Ouvrir dans le navigateur
http://localhost:3000/docs
# Ou directement sur le port API
http://localhost:8000/docs
```
### Tests unitaires (à venir)
```bash
# Backend (pytest)
cd dashboard
pytest backend/tests/
# Frontend (jest)
cd dashboard/frontend
npm test
```
## 📄 License
Même license que le projet principal Bot Detector.
---
## 📞 Support
Pour toute question ou problème :
1. Vérifier la section **🐛 Dépannage** ci-dessus
2. Consulter les logs : `docker compose logs dashboard_web`
3. Vérifier que ClickHouse contient des données
4. Ouvrir une issue sur le dépôt

View File

@ -1,57 +0,0 @@
# Plan d'exécution — Routes & Navigation
## Contexte
- Authentification applicative **hors périmètre** (gérée par `htaccess`).
- Objectif: rendre les routes/navigation cohérentes et sans liens cassés.
## Étapes et avancement
| Étape | Description | Statut | Notes |
|---|---|---|---|
| 1 | Préparer ce document de suivi | ✅ Fait | Document créé et utilisé comme source de progression. |
| 2 | Lancer un baseline (checks existants) | ✅ Fait | `docker compose build dashboard_web` exécuté (OK). |
| 3 | Corriger les routes déclarées (aliases + routes manquantes) | ✅ Fait | Ajout de `/incidents`, `/investigate`, `/investigate/:type/:value`, `/bulk-classify` + wrappers tools route params. |
| 4 | Corriger la navigation (liens/boutons/quick search) | ✅ Fait | Navigation top enrichie, quick actions corrigées, suppression de `window.location.href`. |
| 5 | Valider après changements (build/checks) | ✅ Fait | `docker compose build dashboard_web` OK après modifications. |
| 6 | Finaliser ce document avec résultats | ✅ Fait | Synthèse et statut final complétés. |
| 7 | Réécriture graph de corrélations | ✅ Fait | Custom node types, layout radial, fitView, séparation fetch/filtre, erreur gérée, hauteur 700px. |
## Journal davancement
### Étape 1 — Préparer le document
- Statut: ✅ Fait
- Action: création du document de suivi avec étapes et statuts.
### Étape 2 — Baseline Docker
- Statut: ✅ Fait
- Action: exécution de `docker compose build dashboard_web`.
- Résultat: build OK (code de sortie 0), warning non bloquant sur `version` obsolète dans compose.
### Étape 3 — Correction des routes
- Statut: ✅ Fait
- Actions:
- ajout route alias `/incidents` vers la vue incidents;
- ajout routes `/investigate` et `/investigate/:type/:value` avec redirection intelligente;
- ajout route `/bulk-classify` avec wrapper dintégration;
- remplacement des usages `window.location.pathname` par des wrappers route basés sur `useParams`.
### Étape 4 — Correction de la navigation
- Statut: ✅ Fait
- Actions:
- ajout dun onglet navigation `Détections`;
- activation menu corrigée (gestion des alias/sous-routes);
- remplacement de `window.location.href` dans `DetectionsList` par `navigate(...)`;
- action rapide “Investigation avancée” alignée vers `/detections`.
### Étape 5 — Validation Docker post-modifications
- Statut: ✅ Fait
- Action: exécution de `docker compose build dashboard_web`.
- Résultat: build OK (code de sortie 0), warning compose `version` obsolète non bloquant.
### Étape 6 — Clôture
- Statut: ✅ Fait
- Résultat global:
- routes invalides couvertes via aliases/wrappers;
- navigation interne homogène en SPA;
- build Docker validé avant/après.

View File

@ -1 +0,0 @@
"""Package principal du backend FastAPI bot-detector."""

View File

@ -1,31 +1,20 @@
"""
Configuration du Dashboard Bot Detector
"""
from pydantic_settings import BaseSettings
import os
import re
CLICKHOUSE_HOST = os.getenv("CLICKHOUSE_HOST", "localhost")
CLICKHOUSE_PORT = int(os.getenv("CLICKHOUSE_PORT", "8123"))
CLICKHOUSE_USER = os.getenv("CLICKHOUSE_USER", "default")
CLICKHOUSE_PASSWORD = os.getenv("CLICKHOUSE_PASSWORD", "")
DB_PROCESSING = os.getenv("CLICKHOUSE_DB_PROCESSING", os.getenv("CLICKHOUSE_DB", "ja4_processing"))
DB_LOGS = os.getenv("CLICKHOUSE_DB_LOGS", "ja4_logs")
API_HOST = os.getenv("API_HOST", "0.0.0.0")
API_PORT = int(os.getenv("API_PORT", "8000"))
_SAFE_IDENT = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
class Settings(BaseSettings):
"""Paramètres de configuration de l'application chargés depuis l'environnement."""
# ClickHouse
CLICKHOUSE_HOST: str = "clickhouse"
CLICKHOUSE_PORT: int = 8123
CLICKHOUSE_DB: str = "ja4_processing" # default connection database
CLICKHOUSE_DB_LOGS: str = "ja4_logs"
CLICKHOUSE_DB_PROCESSING: str = "ja4_processing"
CLICKHOUSE_USER: str = "admin"
CLICKHOUSE_PASSWORD: str = ""
# API
API_HOST: str = "0.0.0.0"
API_PORT: int = 8000
# CORS
CORS_ORIGINS: list = ["http://localhost:3000", "http://127.0.0.1:3000"]
class Config:
"""Configuration Pydantic pour le chargement du fichier .env."""
env_file = ".env"
case_sensitive = True
settings = Settings()
def safe_identifier(name: str) -> str:
"""Validate that a string is a safe SQL identifier."""
if not _SAFE_IDENT.match(name):
raise ValueError(f"Unsafe identifier: {name!r}")
return name

View File

@ -1,7 +1,66 @@
"""
ClickHouse connection — delegates to ja4_common shared client.
"""
from ja4_common.clickhouse import get_client as _get_client, ClickHouseClient
from __future__ import annotations
# Re-export for backward compatibility with existing route imports
db: ClickHouseClient = _get_client()
import ipaddress
import logging
from typing import Any
import clickhouse_connect
from clickhouse_connect.driver.client import Client
from backend.config import CLICKHOUSE_HOST, CLICKHOUSE_PORT, CLICKHOUSE_USER, CLICKHOUSE_PASSWORD
logger = logging.getLogger(__name__)
_client: Client | None = None
def get_client() -> Client:
"""Return a lazily-initialised ClickHouse client (singleton)."""
global _client
if _client is None:
_client = clickhouse_connect.get_client(
host=CLICKHOUSE_HOST,
port=CLICKHOUSE_PORT,
username=CLICKHOUSE_USER,
password=CLICKHOUSE_PASSWORD,
)
logger.info("Connected to ClickHouse at %s:%s", CLICKHOUSE_HOST, CLICKHOUSE_PORT)
return _client
def _normalise_value(v: Any) -> Any:
"""Convert ClickHouse-specific types to JSON-friendly Python types."""
if isinstance(v, (ipaddress.IPv4Address, ipaddress.IPv6Address)):
return str(v)
if isinstance(v, bytes):
try:
return str(ipaddress.IPv6Address(v))
except Exception:
return v.hex()
return v
def query(sql: str, params: dict | None = None) -> list[dict[str, Any]]:
"""Execute *sql* and return a list of row-dicts."""
client = get_client()
result = client.query(sql, parameters=params or {})
columns = result.column_names
rows: list[dict[str, Any]] = []
for row in result.result_rows:
rows.append({col: _normalise_value(val) for col, val in zip(columns, row)})
return rows
def query_scalar(sql: str, params: dict | None = None) -> Any:
"""Execute *sql* and return the single scalar value."""
client = get_client()
result = client.query(sql, parameters=params or {})
if result.result_rows:
return _normalise_value(result.result_rows[0][0])
return None
def execute(sql: str, params: dict | None = None) -> None:
"""Execute a DDL / DML statement that returns no rows."""
client = get_client()
client.command(sql, parameters=params or {})

View File

@ -1,237 +1,37 @@
"""
Bot Detector Dashboard - API Backend
FastAPI application pour servir le dashboard web
"""
"""JA4 SOC Dashboard — FastAPI application."""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
import os
from .config import settings
from .database import db
from .routes import metrics, detections, variability, attributes, analysis, entities, incidents, audit, reputation, fingerprints
from .routes import bruteforce, tcp_spoofing, header_fingerprint, heatmap, botnets, rotation, ml_features, investigation_summary, search, clustering
from backend.routes.api import router as api_router
from backend.routes.pages import router as pages_router
# Configuration logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
app = FastAPI(title="JA4 SOC Dashboard", version="1.0.0")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Gestion du cycle de vie de l'application"""
# Startup
logger.info("Démarrage du Bot Detector Dashboard API...")
logger.info(f"ClickHouse: {settings.CLICKHOUSE_HOST}:{settings.CLICKHOUSE_PORT}")
logger.info(f"Database: {settings.CLICKHOUSE_DB}")
# Tester la connexion ClickHouse
try:
client = db.connect()
client.ping()
logger.info("Connexion ClickHouse établie avec succès")
except Exception as e:
logger.error(f"Échec de connexion ClickHouse: {e}")
raise
yield
# Shutdown
logger.info("Arrêt du Bot Detector Dashboard API...")
db.close()
# Création de l'application FastAPI
OPENAPI_TAGS = [
{
"name": "Metrics",
"description": "Métriques globales : comptages, niveaux de menace, baseline et distribution des scores ML.",
},
{
"name": "Detections",
"description": "Liste paginée et filtrée des anomalies détectées par le modèle ML. Supporte tri, recherche texte et regroupement par IP.",
},
{
"name": "investigation",
"description": (
"**Point d'entrée principal pour l'analyse d'une IP.** "
"Agrège en un seul appel : score ML, brute-force, spoofing TCP, rotation JA4, persistance et timeline 24h. "
"Retourne un `risk_score` heuristique de 0 à 100."
),
},
{
"name": "Reputation",
"description": "Réputation externe d'une IP via IP-API.com et IPinfo.io (sans clé API). Détecte proxies, VPN, Tor, hébergeurs.",
},
{
"name": "Analysis",
"description": "Analyses approfondies par IP : subnet, pays, empreintes JA4, user-agents, recommandation SOC et gestion des classifications.",
},
{
"name": "Entities",
"description": "Investigation par entité (IP, JA4, subnet, user-agent, host). Retourne détections associées, user-agents, chemins, paramètres et entités liées.",
},
{
"name": "Incidents",
"description": "Clusters d'incidents actifs regroupés par similarité comportementale. Permet la classification et le suivi des incidents.",
},
{
"name": "Fingerprints",
"description": "Analyse des empreintes JA4/TLS : spoofing, matrice JA4↔UA, user-agents suspects, cohérence par IP, JA4 légitimes et corrélation ASN.",
},
{
"name": "Bruteforce",
"description": "Détection des attaques brute-force : cibles, attaquants, timeline et détail par host.",
},
{
"name": "TCP Spoofing",
"description": "Détection du spoofing TCP/OS fingerprinting : vue d'ensemble, liste et matrice TTL×MSS.",
},
{
"name": "Header Fingerprint",
"description": "Clusters de fingerprints d'en-têtes HTTP suspects et IPs associées.",
},
{
"name": "Heatmap",
"description": "Heatmap horaire du trafic, top hosts et matrice activité/heure.",
},
{
"name": "Botnets",
"description": "Détection de botnets : spread JA4, distribution géographique par JA4, résumé global.",
},
{
"name": "Rotation",
"description": "Détection de la rotation JA4 (évasion de détection), menaces persistantes, historique JA4 par IP et score de sophistication.",
},
{
"name": "ML Features",
"description": "Données brutes du modèle ML : top anomalies, radar par IP, distribution des scores, tendances, features B et scatter plot.",
},
{
"name": "Attributes",
"description": "Listes des valeurs distinctes d'attributs (JA4, user-agents, ASN, pays…) avec comptages.",
},
{
"name": "Variability",
"description": "Variabilité comportementale : IPs par attribut, attributs par valeur, analyse des user-agents.",
},
{
"name": "Clustering",
"description": "Clustering K-Means des IPs sur les features ML. Statut du cache, clusters, points et IPs par cluster.",
},
{
"name": "Search",
"description": "Recherche rapide cross-entités (IP, JA4, host, user-agent, pays, ASN).",
},
{
"name": "Audit",
"description": "Journal d'audit SOC : création de logs, consultation filtrée, statistiques et activité par utilisateur.",
},
]
app = FastAPI(
title="Bot Detector Dashboard API",
description=(
"API REST du **Bot Detector SOC Dashboard**.\n\n"
"Permet d'interroger les bases ClickHouse (`ja4_logs` / `ja4_processing`) pour visualiser et analyser "
"les détections de bots générées par le service `bot_detector_ai`.\n\n"
"**Endpoint clé :** `GET /api/investigation/{ip}/summary` — synthèse complète en un appel.\n\n"
"Documentation interactive : `/docs` (Swagger UI) · `/redoc` (ReDoc)"
),
version="1.0.0",
openapi_tags=OPENAPI_TAGS,
lifespan=lifespan,
)
# Configuration CORS
# CORS — allow all origins for dashboard access
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Enregistrement des routes
app.include_router(metrics.router)
app.include_router(detections.router)
app.include_router(variability.router)
app.include_router(attributes.router)
app.include_router(analysis.router)
app.include_router(entities.router)
app.include_router(incidents.router)
app.include_router(audit.router)
app.include_router(reputation.router)
app.include_router(fingerprints.router)
app.include_router(bruteforce.router)
app.include_router(tcp_spoofing.router)
app.include_router(header_fingerprint.router)
app.include_router(heatmap.router)
app.include_router(botnets.router)
app.include_router(rotation.router)
app.include_router(ml_features.router)
app.include_router(investigation_summary.router)
app.include_router(search.router)
app.include_router(clustering.router)
# Static assets
app.mount("/static", StaticFiles(directory="backend/static"), name="static")
# Routers — API first so /api/* paths match before page catch-all
app.include_router(api_router)
app.include_router(pages_router)
# Chemin vers le fichier index.html du frontend (utilisé par serve_frontend et serve_spa)
_FRONTEND_INDEX = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist", "index.html")
# Route pour servir le frontend
@app.get("/")
async def serve_frontend():
"""Sert l'application React"""
if os.path.exists(_FRONTEND_INDEX):
return FileResponse(_FRONTEND_INDEX)
return {"message": "Dashboard API - Frontend non construit. Voir /docs pour l'API."}
# Servir les assets statiques
_assets_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist", "assets")
if os.path.exists(_assets_path):
try:
app.mount("/assets", StaticFiles(directory=_assets_path), name="assets")
except Exception as _e:
logger.warning(f"Impossible de monter les assets statiques : {_e}")
# Health check
@app.get("/health")
async def health_check():
"""Endpoint de santé pour le health check Docker"""
try:
db.connect().ping()
return {"status": "healthy", "clickhouse": "connected"}
except Exception as e:
return {"status": "unhealthy", "clickhouse": "disconnected", "error": str(e)}
# Route catch-all pour le routing SPA (React Router) - DOIT ÊTRE EN DERNIER
# Sauf pour /api/* qui doit être géré par les routers
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
"""Redirige toutes les routes vers index.html pour le routing React"""
# Ne pas intercepter les routes API
if full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="API endpoint not found")
if os.path.exists(_FRONTEND_INDEX):
return FileResponse(_FRONTEND_INDEX)
return {"message": "Dashboard API - Frontend non construit"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host=settings.API_HOST,
port=settings.API_PORT,
reload=True
)
async def health():
return {"status": "ok"}

View File

@ -1,339 +0,0 @@
"""
Modèles de données pour l'API
"""
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List, Dict, Any
from datetime import datetime
from enum import Enum
class ThreatLevel(str, Enum):
"""Niveaux de menace supportés par le modèle de détection."""
CRITICAL = "CRITICAL"
HIGH = "HIGH"
MEDIUM = "MEDIUM"
LOW = "LOW"
# ─────────────────────────────────────────────────────────────────────────────
# MÉTRIQUES
# ─────────────────────────────────────────────────────────────────────────────
class MetricsSummary(BaseModel):
"""Résumé agrégé des métriques sur les dernières 24 heures."""
total_detections: int
critical_count: int
high_count: int
medium_count: int
low_count: int
known_bots_count: int
anomalies_count: int
unique_ips: int
class TimeSeriesPoint(BaseModel):
"""Point de série temporelle par heure pour les métriques."""
hour: datetime
total: int
critical: int
high: int
medium: int
low: int
class MetricsResponse(BaseModel):
"""Réponse complète des métriques du dashboard avec série temporelle."""
summary: MetricsSummary
timeseries: List[TimeSeriesPoint]
threat_distribution: Dict[str, int]
# ─────────────────────────────────────────────────────────────────────────────
# DÉTECTIONS
# ─────────────────────────────────────────────────────────────────────────────
class Detection(BaseModel):
"""Représentation d'une détection d'anomalie émise par le modèle ML."""
detected_at: datetime
src_ip: str
ja4: str
host: str
bot_name: str
anomaly_score: float
threat_level: str
model_name: str
recurrence: int
asn_number: str
asn_org: str
asn_detail: str
asn_domain: str
country_code: str
asn_label: str
hits: int
hit_velocity: float
fuzzing_index: float
post_ratio: float
reason: str
client_headers: str = ""
asn_score: Optional[float] = None
asn_rep_label: str = ""
first_seen: Optional[datetime] = None
last_seen: Optional[datetime] = None
unique_ja4s: Optional[List[str]] = None
unique_hosts: Optional[List[str]] = None
anubis_bot_name: str = ""
anubis_bot_action: str = ""
anubis_bot_category: str = ""
class DetectionsListResponse(BaseModel):
"""Liste paginée de détections d'anomalies."""
items: List[Detection]
total: int
page: int
page_size: int
total_pages: int
# ─────────────────────────────────────────────────────────────────────────────
# VARIABILITÉ
# ─────────────────────────────────────────────────────────────────────────────
class AttributeValue(BaseModel):
"""Valeur d'attribut avec comptage, pourcentage et métadonnées temporelles."""
value: str
count: int
percentage: float
first_seen: Optional[datetime] = None
last_seen: Optional[datetime] = None
threat_levels: Optional[Dict[str, int]] = None
unique_ips: Optional[int] = None
primary_threat: Optional[str] = None
class VariabilityAttributes(BaseModel):
"""Ensemble des attributs de variabilité comportementale pour une entité."""
user_agents: List[AttributeValue] = Field(default_factory=list)
ja4: List[AttributeValue] = Field(default_factory=list)
countries: List[AttributeValue] = Field(default_factory=list)
asns: List[AttributeValue] = Field(default_factory=list)
hosts: List[AttributeValue] = Field(default_factory=list)
threat_levels: List[AttributeValue] = Field(default_factory=list)
model_names: List[AttributeValue] = Field(default_factory=list)
class Insight(BaseModel):
"""Message d'analyse contextuelle (alerte, information ou succès)."""
type: str # "warning", "info", "success"
message: str
class VariabilityResponse(BaseModel):
"""Réponse d'analyse de variabilité pour un attribut donné."""
type: str
value: str
total_detections: int
unique_ips: int
date_range: Dict[str, datetime]
attributes: VariabilityAttributes
insights: List[Insight] = Field(default_factory=list)
# ─────────────────────────────────────────────────────────────────────────────
# ATTRIBUTS UNIQUES
# ─────────────────────────────────────────────────────────────────────────────
class AttributeListItem(BaseModel):
"""Élément de la liste des valeurs uniques d'un attribut avec son comptage."""
value: str
count: int
class AttributeListResponse(BaseModel):
"""Réponse de la liste des valeurs uniques pour un type d'attribut."""
type: str
items: List[AttributeListItem]
total: int
# ─────────────────────────────────────────────────────────────────────────────
# USER-AGENTS
# ─────────────────────────────────────────────────────────────────────────────
class UserAgentValue(BaseModel):
"""Valeur de User-Agent avec comptage et plage temporelle d'observation."""
value: str
count: int
percentage: float
first_seen: Optional[datetime] = None
last_seen: Optional[datetime] = None
class UserAgentsResponse(BaseModel):
"""Réponse de la liste des User-Agents associés à une entité."""
type: str
value: str
user_agents: List[UserAgentValue]
total: int
showing: int
# ─────────────────────────────────────────────────────────────────────────────
# CLASSIFICATIONS (SOC / ML)
# ─────────────────────────────────────────────────────────────────────────────
class ClassificationLabel(str, Enum):
"""Étiquettes de classification SOC pour les IPs et fingerprints JA4."""
LEGITIMATE = "legitimate"
SUSPICIOUS = "suspicious"
MALICIOUS = "malicious"
class ClassificationBase(BaseModel):
"""Modèle de base partagé pour les classifications SOC."""
ip: Optional[str] = None
ja4: Optional[str] = None
label: ClassificationLabel
tags: List[str] = Field(default_factory=list)
comment: str = ""
confidence: float = Field(ge=0.0, le=1.0, default=0.5)
analyst: str = "unknown"
class ClassificationCreate(ClassificationBase):
"""Données pour créer une classification"""
features: dict = Field(default_factory=dict)
class Classification(ClassificationBase):
"""Classification complète avec métadonnées"""
model_config = ConfigDict(from_attributes=True)
created_at: datetime
features: dict = Field(default_factory=dict)
class ClassificationsListResponse(BaseModel):
"""Liste paginée des classifications SOC enregistrées."""
items: List[Classification]
total: int
# ─────────────────────────────────────────────────────────────────────────────
# ANALYSIS (CORRELATION)
# ─────────────────────────────────────────────────────────────────────────────
class SubnetAnalysis(BaseModel):
"""Analyse subnet/ASN"""
ip: str
subnet: str
ips_in_subnet: List[str]
total_in_subnet: int
asn_number: str
asn_org: str
total_in_asn: int
alert: bool # True si > 10 IPs du subnet
class CountryData(BaseModel):
"""Données pour un pays"""
code: str
name: str
count: int
percentage: float
class CountryAnalysis(BaseModel):
"""Analyse des pays"""
top_countries: List[CountryData]
baseline: dict # Pays habituels
alert_country: Optional[str] = None # Pays surreprésenté
class JA4SubnetData(BaseModel):
"""Subnet pour un JA4"""
subnet: str
count: int
class JA4Analysis(BaseModel):
"""Analyse JA4"""
ja4: str
shared_ips_count: int
top_subnets: List[JA4SubnetData]
other_ja4_for_ip: List[str]
class UserAgentData(BaseModel):
"""Données pour un User-Agent"""
value: str
count: int
percentage: float
classification: str # "normal", "bot", "script"
class UserAgentAnalysis(BaseModel):
"""Analyse User-Agents"""
ip_user_agents: List[UserAgentData]
ja4_user_agents: List[UserAgentData]
bot_percentage: float
alert: bool # True si > 20% bots/scripts
class CorrelationIndicators(BaseModel):
"""Indicateurs de corrélation"""
subnet_ips_count: int
asn_ips_count: int
country_percentage: float
ja4_shared_ips: int
user_agents_count: int
bot_ua_percentage: float
class ClassificationRecommendation(BaseModel):
"""Recommandation de classification"""
label: ClassificationLabel
confidence: float
indicators: CorrelationIndicators
suggested_tags: List[str]
reason: str
# ─────────────────────────────────────────────────────────────────────────────
# ENTITIES (UNIFIED VIEW)
# ─────────────────────────────────────────────────────────────────────────────
class EntityStats(BaseModel):
"""Statistiques pour une entité"""
entity_type: str
entity_value: str
total_requests: int
unique_ips: int
first_seen: datetime
last_seen: datetime
class EntityRelatedAttributes(BaseModel):
"""Attributs associés à une entité"""
ips: List[str] = Field(default_factory=list)
ja4s: List[str] = Field(default_factory=list)
hosts: List[str] = Field(default_factory=list)
asns: List[str] = Field(default_factory=list)
countries: List[str] = Field(default_factory=list)
class EntityAttributeValue(BaseModel):
"""Valeur d'attribut avec count et percentage (pour les entities)"""
value: str
count: int
percentage: float
class EntityInvestigation(BaseModel):
"""Investigation complète pour une entité"""
stats: EntityStats
related: EntityRelatedAttributes
user_agents: List[EntityAttributeValue] = Field(default_factory=list)
client_headers: List[EntityAttributeValue] = Field(default_factory=list)
paths: List[EntityAttributeValue] = Field(default_factory=list)
query_params: List[EntityAttributeValue] = Field(default_factory=list)

View File

@ -1 +0,0 @@
"""Package des routes FastAPI de l'API bot-detector."""

View File

@ -1,688 +0,0 @@
"""
Endpoints pour l'analyse de corrélations et la classification SOC
"""
from collections import defaultdict
from fastapi import APIRouter, HTTPException, Query
from typing import Optional, List
import ipaddress
import json
from ..database import db
from ..models import (
SubnetAnalysis, CountryAnalysis, CountryData, JA4Analysis, JA4SubnetData,
UserAgentAnalysis, UserAgentData, CorrelationIndicators,
ClassificationRecommendation, ClassificationLabel,
ClassificationCreate, Classification, ClassificationsListResponse
)
from ..config import settings
router = APIRouter(prefix="/api/analysis", tags=["analysis"])
# Mapping code ISO → nom lisible (utilisé par analyze_ip_country et analyze_country)
_COUNTRY_NAMES: dict[str, str] = {
"CN": "China", "US": "United States", "DE": "Germany",
"FR": "France", "RU": "Russia", "GB": "United Kingdom",
"NL": "Netherlands", "IN": "India", "BR": "Brazil",
"JP": "Japan", "KR": "South Korea", "IT": "Italy",
"ES": "Spain", "CA": "Canada", "AU": "Australia"
}
# =============================================================================
# ANALYSE SUBNET / ASN
# =============================================================================
@router.get("/{ip}/subnet", response_model=SubnetAnalysis)
async def analyze_subnet(ip: str):
"""
Analyse les IPs du même subnet et ASN
"""
try:
# Calculer le subnet /24
ip_obj = ipaddress.ip_address(ip)
subnet = ipaddress.ip_network(f"{ip}/24", strict=False)
subnet_str = str(subnet)
# Récupérer les infos ASN pour cette IP
asn_query = f"""
SELECT asn_number, asn_org
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE src_ip = %(ip)s
ORDER BY detected_at DESC
LIMIT 1
"""
asn_result = db.query(asn_query, {"ip": ip})
if not asn_result.result_rows:
# Fallback: utiliser données par défaut
asn_number = "0"
asn_org = "Unknown"
else:
asn_number = str(asn_result.result_rows[0][0] or "0")
asn_org = asn_result.result_rows[0][1] or "Unknown"
# IPs du même subnet /24
subnet_ips_query = f"""
SELECT DISTINCT src_ip
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE toIPv4(src_ip) >= toIPv4(%(subnet_start)s)
AND toIPv4(src_ip) <= toIPv4(%(subnet_end)s)
AND detected_at >= now() - INTERVAL 24 HOUR
ORDER BY src_ip
"""
subnet_result = db.query(subnet_ips_query, {
"subnet_start": str(subnet.network_address),
"subnet_end": str(subnet.broadcast_address)
})
subnet_ips = [str(row[0]) for row in subnet_result.result_rows]
# Total IPs du même ASN
if asn_number != "0":
asn_total_query = f"""
SELECT uniq(src_ip)
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE asn_number = %(asn_number)s
AND detected_at >= now() - INTERVAL 24 HOUR
"""
asn_total_result = db.query(asn_total_query, {"asn_number": asn_number})
asn_total = asn_total_result.result_rows[0][0] if asn_total_result.result_rows else 0
else:
asn_total = 0
return SubnetAnalysis(
ip=ip,
subnet=subnet_str,
ips_in_subnet=subnet_ips,
total_in_subnet=len(subnet_ips),
asn_number=asn_number,
asn_org=asn_org,
total_in_asn=asn_total,
alert=len(subnet_ips) > 10
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
@router.get("/{ip}/country", response_model=dict)
async def analyze_ip_country(ip: str):
"""
Analyse le pays d'une IP spécifique et la répartition des autres pays du même ASN
"""
try:
# Pays de l'IP
ip_country_query = f"""
SELECT country_code, asn_number
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE src_ip = %(ip)s
ORDER BY detected_at DESC
LIMIT 1
"""
ip_result = db.query(ip_country_query, {"ip": ip})
if not ip_result.result_rows:
return {"ip_country": None, "asn_countries": []}
ip_country_code = ip_result.result_rows[0][0]
asn_number = ip_result.result_rows[0][1]
# Répartition des autres pays du même ASN
asn_countries_query = f"""
SELECT
country_code,
count() AS count
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE asn_number = %(asn_number)s
AND detected_at >= now() - INTERVAL 24 HOUR
GROUP BY country_code
ORDER BY count DESC
"""
asn_result = db.query(asn_countries_query, {"asn_number": asn_number})
total = sum(row[1] for row in asn_result.result_rows)
asn_countries = [
{
"code": row[0],
"name": _COUNTRY_NAMES.get(row[0], row[0]),
"count": row[1],
"percentage": round((row[1] / total * 100), 2) if total > 0 else 0.0
}
for row in asn_result.result_rows
]
return {
"ip_country": {
"code": ip_country_code,
"name": _COUNTRY_NAMES.get(ip_country_code, ip_country_code)
},
"asn_countries": asn_countries
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
# =============================================================================
# ANALYSE PAYS
# =============================================================================
@router.get("/country", response_model=CountryAnalysis)
async def analyze_country(days: int = Query(1, ge=1, le=30)):
"""
Analyse la distribution des pays
"""
try:
# Top pays
top_query = f"""
SELECT
country_code,
count() AS count
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE detected_at >= now() - INTERVAL %(days)s DAY
AND country_code != '' AND country_code IS NOT NULL
GROUP BY country_code
ORDER BY count DESC
"""
top_result = db.query(top_query, {"days": days})
# Calculer le total pour le pourcentage
total = sum(row[1] for row in top_result.result_rows)
top_countries = [
CountryData(
code=row[0],
name=_COUNTRY_NAMES.get(row[0], row[0]),
count=row[1],
percentage=round((row[1] / total * 100), 2) if total > 0 else 0.0
)
for row in top_result.result_rows
]
# Baseline (7 derniers jours)
baseline_query = f"""
SELECT
country_code,
count() AS count
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE detected_at >= now() - INTERVAL 7 DAY
AND country_code != '' AND country_code IS NOT NULL
GROUP BY country_code
ORDER BY count DESC
"""
baseline_result = db.query(baseline_query)
baseline_total = sum(row[1] for row in baseline_result.result_rows)
baseline = {
row[0]: round((row[1] / baseline_total * 100), 2) if baseline_total > 0 else 0.0
for row in baseline_result.result_rows
}
# Détecter pays surreprésenté
alert_country = None
for country in top_countries:
baseline_pct = baseline.get(country.code, 0)
if baseline_pct > 0 and country.percentage > baseline_pct * 2 and country.percentage > 30:
alert_country = country.code
break
return CountryAnalysis(
top_countries=top_countries,
baseline=baseline,
alert_country=alert_country
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
# =============================================================================
# ANALYSE JA4
# =============================================================================
@router.get("/{ip}/ja4", response_model=JA4Analysis)
async def analyze_ja4(ip: str):
"""
Analyse le JA4 fingerprint
"""
try:
# JA4 de cette IP
ja4_query = f"""
SELECT ja4
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE src_ip = %(ip)s
AND ja4 != '' AND ja4 IS NOT NULL
ORDER BY detected_at DESC
LIMIT 1
"""
ja4_result = db.query(ja4_query, {"ip": ip})
if not ja4_result.result_rows:
return JA4Analysis(
ja4="",
shared_ips_count=0,
top_subnets=[],
other_ja4_for_ip=[]
)
ja4 = ja4_result.result_rows[0][0]
# IPs avec le même JA4
shared_query = f"""
SELECT uniq(src_ip)
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE ja4 = %(ja4)s
AND detected_at >= now() - INTERVAL 24 HOUR
"""
shared_result = db.query(shared_query, {"ja4": ja4})
shared_count = shared_result.result_rows[0][0] if shared_result.result_rows else 0
# Top subnets pour ce JA4 - Simplifié
subnets_query = f"""
SELECT
src_ip,
count() AS count
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE ja4 = %(ja4)s
AND detected_at >= now() - INTERVAL 24 HOUR
GROUP BY src_ip
ORDER BY count DESC
"""
subnets_result = db.query(subnets_query, {"ja4": ja4})
# Grouper par subnet /24
subnet_counts = defaultdict(int)
for row in subnets_result.result_rows:
ip_addr = str(row[0])
parts = ip_addr.split('.')
if len(parts) == 4:
subnet = f"{parts[0]}.{parts[1]}.{parts[2]}.0/24"
subnet_counts[subnet] += row[1]
top_subnets = [
JA4SubnetData(subnet=subnet, count=count)
for subnet, count in sorted(subnet_counts.items(), key=lambda x: x[1], reverse=True)[:10]
]
# Autres JA4 pour cette IP
other_ja4_query = f"""
SELECT DISTINCT ja4
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE src_ip = %(ip)s
AND ja4 != '' AND ja4 IS NOT NULL
AND ja4 != %(current_ja4)s
"""
other_result = db.query(other_ja4_query, {"ip": ip, "current_ja4": ja4})
other_ja4 = [row[0] for row in other_result.result_rows]
return JA4Analysis(
ja4=ja4,
shared_ips_count=shared_count,
top_subnets=top_subnets,
other_ja4_for_ip=other_ja4
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
# =============================================================================
# ANALYSE USER-AGENTS
# =============================================================================
@router.get("/{ip}/user-agents", response_model=UserAgentAnalysis)
async def analyze_user_agents(ip: str):
"""
Analyse les User-Agents
"""
try:
# User-Agents pour cette IP (depuis http_logs)
ip_ua_query = f"""
SELECT
header_user_agent AS ua,
count() AS count
FROM {settings.CLICKHOUSE_DB_LOGS}.http_logs
WHERE src_ip = %(ip)s
AND header_user_agent != '' AND header_user_agent IS NOT NULL
AND time >= now() - INTERVAL 24 HOUR
GROUP BY ua
ORDER BY count DESC
"""
# Total réel des requêtes (pour les pourcentages corrects)
ip_total_query = f"""
SELECT count()
FROM {settings.CLICKHOUSE_DB_LOGS}.http_logs
WHERE src_ip = %(ip)s
AND time >= now() - INTERVAL 24 HOUR
"""
ip_ua_result = db.query(ip_ua_query, {"ip": ip})
ip_total_result = db.query(ip_total_query, {"ip": ip})
# Classification des UAs
def classify_ua(ua: str) -> str:
"""Classe un User-Agent en 'bot', 'script', 'browser' ou 'unknown'."""
ua_lower = ua.lower()
if any(bot in ua_lower for bot in ['bot', 'crawler', 'spider', 'curl', 'wget', 'python', 'requests', 'scrapy']):
return 'bot'
if any(script in ua_lower for script in ['python', 'java', 'php', 'ruby', 'perl', 'node']):
return 'script'
if not ua or ua.strip() == '':
return 'script'
return 'normal'
# Total réel de toutes les requêtes (pour des pourcentages corrects même avec LIMIT)
total_count = ip_total_result.result_rows[0][0] if ip_total_result.result_rows else 0
if total_count == 0:
total_count = sum(row[1] for row in ip_ua_result.result_rows)
ip_user_agents = [
UserAgentData(
value=row[0],
count=row[1],
percentage=round((row[1] / total_count * 100), 2) if total_count > 0 else 0.0,
classification=classify_ua(row[0])
)
for row in ip_ua_result.result_rows
]
# Pour les UAs du JA4, on retourne les mêmes pour l'instant
ja4_user_agents = ip_user_agents
# Pourcentage de bots
bot_count = sum(ua.count for ua in ip_user_agents if ua.classification in ['bot', 'script'])
bot_percentage = (bot_count / total_count * 100) if total_count > 0 else 0
return UserAgentAnalysis(
ip_user_agents=ip_user_agents,
ja4_user_agents=ja4_user_agents,
bot_percentage=bot_percentage,
alert=bot_percentage > 20
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
# =============================================================================
# RECOMMANDATION DE CLASSIFICATION
# =============================================================================
@router.get("/{ip}/recommendation", response_model=ClassificationRecommendation)
async def get_classification_recommendation(ip: str):
"""
Génère une recommandation de classification basée sur les corrélations
"""
try:
# Récupérer les analyses
try:
subnet_analysis = await analyze_subnet(ip)
except Exception:
subnet_analysis = None
try:
country_analysis = await analyze_country(1)
except Exception:
country_analysis = None
try:
ja4_analysis = await analyze_ja4(ip)
except Exception:
ja4_analysis = None
try:
ua_analysis = await analyze_user_agents(ip)
except Exception:
ua_analysis = None
# Indicateurs par défaut
indicators = CorrelationIndicators(
subnet_ips_count=subnet_analysis.total_in_subnet if subnet_analysis else 0,
asn_ips_count=subnet_analysis.total_in_asn if subnet_analysis else 0,
country_percentage=0.0,
ja4_shared_ips=ja4_analysis.shared_ips_count if ja4_analysis else 0,
user_agents_count=len(ua_analysis.ja4_user_agents) if ua_analysis else 0,
bot_ua_percentage=ua_analysis.bot_percentage if ua_analysis else 0.0
)
# Score de confiance
score = 0.0
reasons = []
tags = []
# Subnet > 10 IPs
if subnet_analysis and subnet_analysis.total_in_subnet > 10:
score += 0.25
reasons.append(f"{subnet_analysis.total_in_subnet} IPs du même subnet")
tags.append("distributed")
# JA4 partagé > 50 IPs
if ja4_analysis and ja4_analysis.shared_ips_count > 50:
score += 0.25
reasons.append(f"{ja4_analysis.shared_ips_count} IPs avec même JA4")
tags.append("ja4-rotation")
# Bot UA > 20%
if ua_analysis and ua_analysis.bot_percentage > 20:
score += 0.25
reasons.append(f"{ua_analysis.bot_percentage:.0f}% UAs bots/scripts")
tags.append("bot-ua")
# Pays surreprésenté
if country_analysis and country_analysis.alert_country:
score += 0.15
reasons.append(f"Pays {country_analysis.alert_country} surreprésenté")
tags.append(f"country-{country_analysis.alert_country.lower()}")
# ASN hosting
if subnet_analysis:
hosting_keywords = ["ovh", "amazon", "aws", "google", "azure", "digitalocean", "linode", "vultr", "china169", "chinamobile"]
if any(kw in (subnet_analysis.asn_org or "").lower() for kw in hosting_keywords):
score += 0.10
tags.append("hosting-asn")
# Déterminer label
if score >= 0.7:
label = ClassificationLabel.MALICIOUS
tags.append("campaign")
elif score >= 0.4:
label = ClassificationLabel.SUSPICIOUS
else:
label = ClassificationLabel.LEGITIMATE
reason = " | ".join(reasons) if reasons else "Aucun indicateur fort"
return ClassificationRecommendation(
label=label,
confidence=min(score, 1.0),
indicators=indicators,
suggested_tags=tags,
reason=reason
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
# =============================================================================
# CLASSIFICATIONS CRUD
# =============================================================================
@router.post("/classifications", response_model=Classification)
async def create_classification(data: ClassificationCreate):
"""
Crée une classification pour une IP ou un JA4
"""
try:
# Validation: soit ip, soit ja4 doit être fourni
if not data.ip and not data.ja4:
raise HTTPException(status_code=400, detail="IP ou JA4 requis")
query = f"""
INSERT INTO {settings.CLICKHOUSE_DB_PROCESSING}.classifications
(ip, ja4, label, tags, comment, confidence, features, analyst, created_at)
VALUES
(%(ip)s, %(ja4)s, %(label)s, %(tags)s, %(comment)s, %(confidence)s, %(features)s, %(analyst)s, now())
"""
db.query(query, {
"ip": data.ip or "",
"ja4": data.ja4 or "",
"label": data.label.value,
"tags": data.tags,
"comment": data.comment,
"confidence": data.confidence,
"features": json.dumps(data.features),
"analyst": data.analyst
})
# Récupérer la classification créée
where_clause = "ip = %(entity)s" if data.ip else "ja4 = %(entity)s"
select_query = f"""
SELECT ip, ja4, label, tags, comment, confidence, features, analyst, created_at
FROM {settings.CLICKHOUSE_DB_PROCESSING}.classifications
WHERE {where_clause}
ORDER BY created_at DESC
LIMIT 1
"""
result = db.query(select_query, {"entity": data.ip or data.ja4})
if not result.result_rows:
raise HTTPException(status_code=404, detail="Classification non trouvée")
row = result.result_rows[0]
return Classification(
ip=row[0] or None,
ja4=row[1] or None,
label=ClassificationLabel(row[2]),
tags=row[3],
comment=row[4],
confidence=row[5],
features=json.loads(row[6]) if row[6] else {},
analyst=row[7],
created_at=row[8]
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
@router.get("/classifications", response_model=ClassificationsListResponse)
async def list_classifications(
ip: Optional[str] = Query(None, description="Filtrer par IP"),
ja4: Optional[str] = Query(None, description="Filtrer par JA4"),
label: Optional[str] = Query(None, description="Filtrer par label"),
limit: int = Query(100, ge=1, le=1000)
):
"""
Liste les classifications
"""
try:
where_clauses = ["1=1"]
params = {"limit": limit}
if ip:
where_clauses.append("ip = %(ip)s")
params["ip"] = ip
if ja4:
where_clauses.append("ja4 = %(ja4)s")
params["ja4"] = ja4
if label:
where_clauses.append("label = %(label)s")
params["label"] = label
where_clause = " AND ".join(where_clauses)
query = f"""
SELECT ip, ja4, label, tags, comment, confidence, features, analyst, created_at
FROM {settings.CLICKHOUSE_DB_PROCESSING}.classifications
WHERE {where_clause}
ORDER BY created_at DESC
LIMIT %(limit)s
"""
result = db.query(query, params)
classifications = [
Classification(
ip=row[0] or None,
ja4=row[1] or None,
label=ClassificationLabel(row[2]),
tags=row[3],
comment=row[4],
confidence=row[5],
features=json.loads(row[6]) if row[6] else {},
analyst=row[7],
created_at=row[8]
)
for row in result.result_rows
]
# Total
count_query = f"""
SELECT count()
FROM {settings.CLICKHOUSE_DB_PROCESSING}.classifications
WHERE {where_clause}
"""
count_result = db.query(count_query, params)
total = count_result.result_rows[0][0] if count_result.result_rows else 0
return ClassificationsListResponse(
items=classifications,
total=total
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
@router.get("/classifications/stats")
async def get_classification_stats():
"""
Statistiques des classifications
"""
try:
stats_query = f"""
SELECT
label,
count() AS total,
uniq(ip) AS unique_ips,
avg(confidence) AS avg_confidence
FROM {settings.CLICKHOUSE_DB_PROCESSING}.classifications
GROUP BY label
ORDER BY total DESC
"""
result = db.query(stats_query)
stats = [
{
"label": row[0],
"total": row[1],
"unique_ips": row[2],
"avg_confidence": float(row[3]) if row[3] else 0.0
}
for row in result.result_rows
]
return {"stats": stats}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")

View File

@ -0,0 +1,507 @@
"""JSON API endpoints for the JA4 SOC Dashboard."""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from backend.config import DB_PROCESSING, DB_LOGS, safe_identifier
from backend.database import query, query_scalar, execute
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
# Pre-validate DB identifiers at import time
_DB = safe_identifier(DB_PROCESSING)
_DB_LOGS = safe_identifier(DB_LOGS)
# Whitelists for sort/order to prevent SQL injection
_DETECTION_SORT_COLS = {
"detected_at", "src_ip", "ja4", "host", "anomaly_score",
"threat_level", "recurrence", "hits", "hit_velocity",
"fuzzing_index", "post_ratio", "campaign_id",
}
_SCORE_SORT_COLS = {
"detected_at", "window_start", "src_ip", "ja4", "host",
"anomaly_score", "raw_anomaly_score", "threat_level",
"hits", "hit_velocity", "xgb_prob", "ae_recon_error",
}
_TRAFFIC_SORT_COLS = {
"time", "src_ip", "method", "host", "path", "http_version",
"header_user_agent", "ja4", "src_country_code",
}
_ORDER_VALUES = {"ASC", "DESC"}
def _validate_sort(value: str, whitelist: set[str], default: str) -> str:
return value if value in whitelist else default
def _validate_order(value: str) -> str:
return value.upper() if value.upper() in _ORDER_VALUES else "DESC"
# ---------------------------------------------------------------------------
# GET /api/overview
# ---------------------------------------------------------------------------
@router.get("/overview")
async def overview() -> dict[str, Any]:
try:
detections_24h = query_scalar(
f"SELECT count() FROM {_DB}.ml_detected_anomalies "
"WHERE detected_at >= now() - INTERVAL 1 DAY"
) or 0
scored_24h = query_scalar(
f"SELECT count() FROM {_DB}.ml_all_scores "
"WHERE detected_at >= now() - INTERVAL 1 DAY"
) or 0
threat_distribution = query(
f"SELECT threat_level, count() AS cnt "
f"FROM {_DB}.ml_all_scores "
"WHERE detected_at >= now() - INTERVAL 1 DAY "
"GROUP BY threat_level"
)
# Compute critical / high counts from distribution
threat_map = {r["threat_level"]: r["cnt"] for r in threat_distribution}
critical_count = threat_map.get("CRITICAL", 0)
high_count = threat_map.get("HIGH", 0)
unique_ips = query_scalar(
f"SELECT uniq(src_ip) FROM {_DB}.ml_detected_anomalies "
"WHERE detected_at >= now() - INTERVAL 1 DAY"
) or 0
top_ips = query(
f"SELECT toString(src_ip) AS src_ip, count() AS cnt, "
f"max(anomaly_score) AS worst_score, "
f"any(threat_level) AS threat_level, "
f"any(asn_org) AS asn_org, any(country_code) AS country_code "
f"FROM {_DB}.ml_detected_anomalies "
"WHERE detected_at >= now() - INTERVAL 1 DAY "
"GROUP BY src_ip ORDER BY cnt DESC LIMIT 10"
)
timeline = query(
f"SELECT toStartOfHour(detected_at) AS hour, count() AS cnt "
f"FROM {_DB}.ml_detected_anomalies "
"WHERE detected_at >= now() - INTERVAL 1 DAY "
"GROUP BY hour ORDER BY hour"
)
traffic_24h = query_scalar(
f"SELECT count() FROM {_DB_LOGS}.http_logs "
"WHERE time >= now() - INTERVAL 1 DAY"
) or 0
models = query(
f"SELECT model_name, count() AS scored "
f"FROM {_DB}.ml_all_scores "
"WHERE detected_at >= now() - INTERVAL 1 DAY "
"GROUP BY model_name"
)
return {
"detections_24h": detections_24h,
"scored_24h": scored_24h,
"traffic_24h": traffic_24h,
"unique_ips": unique_ips,
"critical_count": critical_count,
"high_count": high_count,
"threat_distribution": threat_distribution,
"top_ips": top_ips,
"timeline": [{"hour": str(r["hour"]), "cnt": r["cnt"]} for r in timeline],
"models": models,
}
except Exception as exc:
logger.exception("overview query failed")
raise HTTPException(status_code=500, detail=str(exc))
# ---------------------------------------------------------------------------
# GET /api/detections
# ---------------------------------------------------------------------------
@router.get("/detections")
async def detections(
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=500),
sort: str = Query("detected_at"),
order: str = Query("DESC"),
threat_level: str | None = Query(None),
search: str | None = Query(None),
) -> dict[str, Any]:
sort = _validate_sort(sort, _DETECTION_SORT_COLS, "detected_at")
order = _validate_order(order)
offset = (page - 1) * per_page
where_clauses = ["detected_at >= now() - INTERVAL 30 DAY"]
params: dict[str, Any] = {}
if threat_level:
where_clauses.append("threat_level = {tl:String}")
params["tl"] = threat_level
if search:
where_clauses.append(
"(toString(src_ip) LIKE {search:String} OR host LIKE {search:String})"
)
params["search"] = f"%{search}%"
where = " AND ".join(where_clauses)
try:
total = query_scalar(
f"SELECT count() FROM {_DB}.ml_detected_anomalies WHERE {where}",
params,
)
rows = query(
f"SELECT *, toString(src_ip) AS src_ip_str "
f"FROM {_DB}.ml_detected_anomalies "
f"WHERE {where} ORDER BY {sort} {order} "
f"LIMIT {{lim:UInt32}} OFFSET {{off:UInt32}}",
{**params, "lim": per_page, "off": offset},
)
return {
"data": rows,
"total": total or 0,
"page": page,
"per_page": per_page,
"pages": max(1, -(-((total or 0)) // per_page)),
}
except Exception as exc:
logger.exception("detections query failed")
raise HTTPException(status_code=500, detail=str(exc))
# ---------------------------------------------------------------------------
# GET /api/scores
# ---------------------------------------------------------------------------
@router.get("/scores")
async def scores(
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=500),
sort: str = Query("detected_at"),
order: str = Query("DESC"),
threat_level: str | None = Query(None),
search: str | None = Query(None),
) -> dict[str, Any]:
sort = _validate_sort(sort, _SCORE_SORT_COLS, "detected_at")
order = _validate_order(order)
offset = (page - 1) * per_page
where_clauses = ["detected_at >= now() - INTERVAL 3 DAY"]
params: dict[str, Any] = {}
if threat_level:
where_clauses.append("threat_level = {tl:String}")
params["tl"] = threat_level
if search:
where_clauses.append(
"(toString(src_ip) LIKE {search:String} OR host LIKE {search:String})"
)
params["search"] = f"%{search}%"
where = " AND ".join(where_clauses)
try:
total = query_scalar(
f"SELECT count() FROM {_DB}.ml_all_scores WHERE {where}",
params,
)
rows = query(
f"SELECT *, toString(src_ip) AS src_ip_str "
f"FROM {_DB}.ml_all_scores "
f"WHERE {where} ORDER BY {sort} {order} "
f"LIMIT {{lim:UInt32}} OFFSET {{off:UInt32}}",
{**params, "lim": per_page, "off": offset},
)
return {
"data": rows,
"total": total or 0,
"page": page,
"per_page": per_page,
"pages": max(1, -(-((total or 0)) // per_page)),
}
except Exception as exc:
logger.exception("scores query failed")
raise HTTPException(status_code=500, detail=str(exc))
# ---------------------------------------------------------------------------
# GET /api/traffic
# ---------------------------------------------------------------------------
@router.get("/traffic")
async def traffic(
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=500),
sort: str = Query("time"),
order: str = Query("DESC"),
method: str | None = Query(None),
host: str | None = Query(None),
status: str | None = Query(None),
) -> dict[str, Any]:
sort = _validate_sort(sort, _TRAFFIC_SORT_COLS, "time")
order = _validate_order(order)
offset = (page - 1) * per_page
where_clauses = ["time >= now() - INTERVAL 1 DAY"]
params: dict[str, Any] = {}
if method:
where_clauses.append("method = {method:String}")
params["method"] = method
if host:
where_clauses.append("host LIKE {host:String}")
params["host"] = f"%{host}%"
if status is not None:
where_clauses.append("http_version = {status:String}")
params["status"] = status
where = " AND ".join(where_clauses)
try:
total = query_scalar(
f"SELECT count() FROM {_DB_LOGS}.http_logs WHERE {where}",
params,
)
rows = query(
f"SELECT time, toString(src_ip) AS src_ip, method, host, path, "
f"http_version, header_user_agent, ja4, src_country_code "
f"FROM {_DB_LOGS}.http_logs "
f"WHERE {where} ORDER BY {sort} {order} "
f"LIMIT {{lim:UInt32}} OFFSET {{off:UInt32}}",
{**params, "lim": per_page, "off": offset},
)
return {
"data": rows,
"total": total or 0,
"page": page,
"per_page": per_page,
"pages": max(1, -(-((total or 0)) // per_page)),
}
except Exception as exc:
logger.exception("traffic query failed")
raise HTTPException(status_code=500, detail=str(exc))
# ---------------------------------------------------------------------------
# GET /api/ip/{ip}
# ---------------------------------------------------------------------------
@router.get("/ip/{ip}")
async def ip_detail(ip: str) -> dict[str, Any]:
# Strip ::ffff: prefix for IPv4-mapped addresses
clean_ip = ip.replace("::ffff:", "")
params = {"ip": clean_ip}
try:
detections = query(
f"SELECT *, toString(src_ip) AS src_ip_str "
f"FROM {_DB}.ml_detected_anomalies "
"WHERE src_ip = toIPv6({ip:String}) "
"AND detected_at >= now() - INTERVAL 30 DAY "
"ORDER BY detected_at DESC",
params,
)
all_scores = query(
f"SELECT *, toString(src_ip) AS src_ip_str "
f"FROM {_DB}.ml_all_scores "
"WHERE src_ip = toIPv6({ip:String}) "
"AND detected_at >= now() - INTERVAL 3 DAY "
"ORDER BY detected_at DESC",
params,
)
http_logs = query(
f"SELECT time, method, host, path, http_version, header_user_agent, ja4 "
f"FROM {_DB_LOGS}.http_logs "
"WHERE src_ip = toIPv4OrZero({ip:String}) "
"AND time >= now() - INTERVAL 1 DAY "
"ORDER BY time DESC LIMIT 100",
params,
)
ai_features: list[dict] = []
try:
ai_features = query(
f"SELECT * FROM {_DB}.view_ai_features_1h "
"WHERE src_ip = toIPv6({ip:String}) LIMIT 1",
params,
)
except Exception:
logger.debug("view_ai_features_1h unavailable for %s", ip)
recurrence: list[dict] = []
try:
recurrence = query(
f"SELECT * FROM {_DB}.view_ip_recurrence "
"WHERE src_ip = toIPv6({ip:String})",
params,
)
except Exception:
logger.debug("view_ip_recurrence unavailable for %s", ip)
return {
"ip": ip,
"detections": detections,
"scores": all_scores,
"http_logs": http_logs,
"ai_features": ai_features,
"recurrence": recurrence,
}
except Exception as exc:
logger.exception("ip detail query failed for %s", ip)
raise HTTPException(status_code=500, detail=str(exc))
# ---------------------------------------------------------------------------
# GET /api/features
# ---------------------------------------------------------------------------
@router.get("/features")
async def features() -> dict[str, Any]:
result: dict[str, Any] = {"ai_features": {}, "thesis_features": {}}
try:
ai_stats = query(
f"SELECT count() AS total, "
f"avg(hits) AS avg_hits, "
f"avg(hit_velocity) AS avg_hit_velocity, "
f"avg(fuzzing_index) AS avg_fuzzing_index, "
f"avg(post_ratio) AS avg_post_ratio "
f"FROM {_DB}.view_ai_features_1h"
)
if ai_stats:
result["ai_features"] = ai_stats[0]
except Exception:
logger.debug("view_ai_features_1h not available")
try:
thesis_stats = query(
f"SELECT count() AS total, "
f"avg(hits) AS avg_hits, "
f"avg(hit_velocity) AS avg_hit_velocity, "
f"avg(fuzzing_index) AS avg_fuzzing_index, "
f"avg(post_ratio) AS avg_post_ratio "
f"FROM {_DB}.view_thesis_features_1h"
)
if thesis_stats:
result["thesis_features"] = thesis_stats[0]
except Exception:
logger.debug("view_thesis_features_1h not available")
return result
# ---------------------------------------------------------------------------
# GET /api/models
# ---------------------------------------------------------------------------
_MODEL_DIR = Path("/data/models")
@router.get("/models")
async def models() -> dict[str, Any]:
model_info: list[dict[str, Any]] = []
if _MODEL_DIR.is_dir():
for p in sorted(_MODEL_DIR.glob("*.json")):
try:
data = json.loads(p.read_text())
model_info.append(data)
except Exception:
logger.warning("Could not read model metadata %s", p)
# Also fetch latest scoring stats from ClickHouse
scoring_stats: list[dict] = []
try:
scoring_stats = query(
f"SELECT model_name, count() AS scored, "
f"min(detected_at) AS first_seen, max(detected_at) AS last_seen "
f"FROM {_DB}.ml_all_scores "
"WHERE detected_at >= now() - INTERVAL 7 DAY "
"GROUP BY model_name"
)
except Exception:
logger.debug("could not fetch model scoring stats")
return {"models": model_info, "scoring_stats": scoring_stats}
# ---------------------------------------------------------------------------
# POST /api/classify — SOC analyst feedback
# ---------------------------------------------------------------------------
class ClassifyRequest(BaseModel):
src_ip: str
classification: str # bot | legitimate | suspicious
comment: str = ""
_VALID_CLASSIFICATIONS = {"bot", "legitimate", "suspicious"}
_feedback_table_ensured = False
def _ensure_feedback_table() -> None:
global _feedback_table_ensured
if _feedback_table_ensured:
return
execute(
f"CREATE TABLE IF NOT EXISTS {_DB}.soc_feedback ("
" created_at DateTime DEFAULT now(),"
" src_ip IPv6,"
" classification LowCardinality(String),"
" comment String"
") ENGINE = MergeTree() ORDER BY (src_ip, created_at)"
)
_feedback_table_ensured = True
@router.post("/classify")
async def classify(body: ClassifyRequest) -> dict[str, Any]:
if body.classification not in _VALID_CLASSIFICATIONS:
raise HTTPException(
status_code=422,
detail=f"classification must be one of {_VALID_CLASSIFICATIONS}",
)
try:
_ensure_feedback_table()
execute(
f"INSERT INTO {_DB}.soc_feedback (src_ip, classification, comment) VALUES "
"(toIPv6({ip:String}), {cls:String}, {cmt:String})",
{"ip": body.src_ip, "cls": body.classification, "cmt": body.comment},
)
return {"status": "ok", "src_ip": body.src_ip, "classification": body.classification}
except Exception as exc:
logger.exception("classify insert failed")
raise HTTPException(status_code=500, detail=str(exc))
# ---------------------------------------------------------------------------
# GET /api/classifications — recent SOC feedback
# ---------------------------------------------------------------------------
@router.get("/classifications")
async def classifications() -> dict[str, Any]:
try:
_ensure_feedback_table()
rows = query(
f"SELECT created_at, toString(src_ip) AS src_ip, classification, comment "
f"FROM {_DB}.soc_feedback "
"ORDER BY created_at DESC LIMIT 50"
)
return {"data": rows}
except Exception as exc:
logger.exception("classifications query failed")
return {"data": []}

View File

@ -1,93 +0,0 @@
"""
Endpoints pour la liste des attributs uniques
"""
from fastapi import APIRouter, HTTPException, Query
from ..database import db
from ..models import AttributeListResponse, AttributeListItem
from ..config import settings
router = APIRouter(prefix="/api/attributes", tags=["attributes"])
@router.get("/{attr_type}", response_model=AttributeListResponse)
async def get_attributes(
attr_type: str,
limit: int = Query(100, ge=1, le=1000, description="Nombre maximum de résultats")
):
"""
Récupère la liste des valeurs uniques pour un type d'attribut
"""
try:
# Mapping des types vers les colonnes
type_column_map = {
"ip": "src_ip",
"ja4": "ja4",
"country": "country_code",
"asn": "asn_number",
"host": "host",
"threat_level": "threat_level",
"model_name": "model_name",
"asn_org": "asn_org"
}
if attr_type not in type_column_map:
raise HTTPException(
status_code=400,
detail=f"Type invalide. Types supportés: {', '.join(type_column_map.keys())}"
)
column = type_column_map[attr_type]
# Requête de base
base_query = f"""
SELECT
{column} AS value,
count() AS count
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE detected_at >= now() - INTERVAL 24 HOUR
"""
# Ajout du filtre pour exclure les valeurs vides/nulles
# Gestion spéciale pour les types IPv6/IPv4 qui ne peuvent pas être comparés à ''
if attr_type == "ip":
# Pour les adresses IP, on convertit en string et on filtre
query = f"""
SELECT value, count FROM (
SELECT toString({column}) AS value, count() AS count
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE detected_at >= now() - INTERVAL 24 HOUR
GROUP BY {column}
)
WHERE value != '' AND value IS NOT NULL
ORDER BY count DESC
LIMIT %(limit)s
"""
else:
query = f"""
{base_query}
AND {column} != '' AND {column} IS NOT NULL
GROUP BY value
ORDER BY count DESC
LIMIT %(limit)s
"""
result = db.query(query, {"limit": limit})
items = [
AttributeListItem(
value=str(row[0]),
count=row[1]
)
for row in result.result_rows
]
return AttributeListResponse(
type=attr_type,
items=items,
total=len(items)
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")

View File

@ -1,239 +0,0 @@
"""
Routes pour l'audit et les logs d'activité
"""
import logging
from fastapi import APIRouter, HTTPException, Query, Request
from typing import Optional
from datetime import datetime
from ..database import db
from ..config import settings
router = APIRouter(prefix="/api/audit", tags=["audit"])
logger = logging.getLogger(__name__)
@router.post("/logs")
async def create_audit_log(
request: Request,
action: str,
entity_type: Optional[str] = None,
entity_id: Optional[str] = None,
entity_count: Optional[int] = None,
details: Optional[dict] = None,
user: Optional[str] = "soc_user"
):
"""
Crée un log d'audit pour une action utilisateur
"""
try:
# Récupérer l'IP du client
client_ip = request.client.host if request.client else "unknown"
# Insérer dans ClickHouse
insert_query = f"""
INSERT INTO {settings.CLICKHOUSE_DB_PROCESSING}.audit_logs
(timestamp, user_name, action, entity_type, entity_id, entity_count, details, client_ip)
VALUES
(%(timestamp)s, %(user)s, %(action)s, %(entity_type)s, %(entity_id)s, %(entity_count)s, %(details)s, %(client_ip)s)
"""
params = {
'timestamp': datetime.now(),
'user': user,
'action': action,
'entity_type': entity_type,
'entity_id': entity_id,
'entity_count': entity_count,
'details': str(details) if details else '',
'client_ip': client_ip
}
# Note: This requires the audit_logs table to exist
# See deploy_audit_logs_table.sql
try:
db.query(insert_query, params)
except Exception as e:
# La table peut ne pas encore exister — on logue mais on ne bloque pas l'appelant
logger.warning(f"Could not insert audit log: {e}")
return {
"status": "success",
"message": "Audit log created",
"action": action,
"timestamp": params['timestamp'].isoformat()
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
@router.get("/logs")
async def get_audit_logs(
hours: int = Query(24, ge=1, le=720, description="Fenêtre temporelle en heures"),
user: Optional[str] = Query(None, description="Filtrer par utilisateur"),
action: Optional[str] = Query(None, description="Filtrer par action"),
entity_type: Optional[str] = Query(None, description="Filtrer par type d'entité"),
limit: int = Query(100, ge=1, le=1000, description="Nombre maximum de résultats")
):
"""
Récupère les logs d'audit avec filtres
"""
try:
where_clauses = ["timestamp >= now() - INTERVAL %(hours)s HOUR"]
params = {"hours": hours, "limit": limit}
if user:
where_clauses.append("user_name = %(user)s")
params["user"] = user
if action:
where_clauses.append("action = %(action)s")
params["action"] = action
if entity_type:
where_clauses.append("entity_type = %(entity_type)s")
params["entity_type"] = entity_type
where_clause = " AND ".join(where_clauses)
query = f"""
SELECT
timestamp,
user_name,
action,
entity_type,
entity_id,
entity_count,
details,
client_ip
FROM {settings.CLICKHOUSE_DB_PROCESSING}.audit_logs
WHERE {where_clause}
ORDER BY timestamp DESC
LIMIT %(limit)s
"""
result = db.query(query, params)
logs = []
for row in result.result_rows:
logs.append({
"timestamp": row[0].isoformat() if row[0] else "",
"user_name": row[1] or "",
"action": row[2] or "",
"entity_type": row[3] or "",
"entity_id": row[4] or "",
"entity_count": row[5] or 0,
"details": row[6] or "",
"client_ip": row[7] or ""
})
return {
"items": logs,
"total": len(logs),
"period_hours": hours
}
except Exception as e:
# If table doesn't exist, return empty result
if "Table" in str(e) and "doesn't exist" in str(e):
return {
"items": [],
"total": 0,
"period_hours": hours,
"warning": "Audit logs table not created yet"
}
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
@router.get("/stats")
async def get_audit_stats(
hours: int = Query(24, ge=1, le=720)
):
"""
Statistiques d'audit
"""
try:
query = f"""
SELECT
action,
count() AS count,
uniq(user_name) AS unique_users,
sum(entity_count) AS total_entities
FROM {settings.CLICKHOUSE_DB_PROCESSING}.audit_logs
WHERE timestamp >= now() - INTERVAL %(hours)s HOUR
GROUP BY action
ORDER BY count DESC
"""
result = db.query(query, {"hours": hours})
stats = []
for row in result.result_rows:
stats.append({
"action": row[0] or "",
"count": row[1] or 0,
"unique_users": row[2] or 0,
"total_entities": row[3] or 0
})
return {
"items": stats,
"period_hours": hours
}
except Exception as e:
if "Table" in str(e) and "doesn't exist" in str(e):
return {
"items": [],
"period_hours": hours,
"warning": "Audit logs table not created yet"
}
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
@router.get("/users/activity")
async def get_user_activity(
hours: int = Query(24, ge=1, le=720)
):
"""
Activité par utilisateur
"""
try:
query = f"""
SELECT
user_name,
count() AS actions,
uniq(action) AS action_types,
min(timestamp) AS first_action,
max(timestamp) AS last_action
FROM {settings.CLICKHOUSE_DB_PROCESSING}.audit_logs
WHERE timestamp >= now() - INTERVAL %(hours)s HOUR
GROUP BY user_name
ORDER BY actions DESC
"""
result = db.query(query, {"hours": hours})
users = []
for row in result.result_rows:
users.append({
"user_name": row[0] or "",
"actions": row[1] or 0,
"action_types": row[2] or 0,
"first_action": row[3].isoformat() if row[3] else "",
"last_action": row[4].isoformat() if row[4] else ""
})
return {
"items": users,
"period_hours": hours
}
except Exception as e:
if "Table" in str(e) and "doesn't exist" in str(e):
return {
"items": [],
"period_hours": hours,
"warning": "Audit logs table not created yet"
}
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")

View File

@ -1,107 +0,0 @@
"""
Endpoints pour l'analyse des botnets via la propagation des fingerprints JA4
"""
from fastapi import APIRouter, HTTPException, Query
from ..database import db
from ..config import settings
router = APIRouter(prefix="/api/botnets", tags=["botnets"])
def _botnet_class(unique_countries: int) -> str:
"""Classifie un JA4 selon sa dispersion géographique."""
if unique_countries > 100:
return "global_botnet"
if unique_countries > 20:
return "regional_botnet"
return "concentrated"
@router.get("/ja4-spread")
async def get_ja4_spread():
"""Propagation des JA4 fingerprints à travers les pays et les IPs."""
try:
sql = f"""
SELECT
ja4,
unique_ips,
unique_countries,
targeted_hosts
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_host_ja4_anomalies
ORDER BY unique_countries DESC
"""
result = db.query(sql)
items = []
for row in result.result_rows:
ja4 = str(row[0])
unique_ips = int(row[1])
unique_countries = int(row[2])
targeted_hosts = int(row[3])
dist_score = round(
unique_countries / max(unique_ips ** 0.5, 0.001), 2
)
items.append({
"ja4": ja4,
"unique_ips": unique_ips,
"unique_countries": unique_countries,
"targeted_hosts": targeted_hosts,
"distribution_score":dist_score,
"botnet_class": _botnet_class(unique_countries),
})
return {"items": items, "total": len(items)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/ja4/{ja4}/countries")
async def get_ja4_countries(ja4: str, limit: int = Query(30, ge=1, le=200)):
"""Top pays pour un JA4 donné depuis agg_host_ip_ja4_1h."""
try:
sql = f"""
SELECT
src_country_code AS country_code,
uniq(replaceRegexpAll(toString(src_ip), '^::ffff:', '')) AS unique_ips,
sum(hits) AS hits
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h
WHERE ja4 = %(ja4)s
GROUP BY src_country_code
ORDER BY unique_ips DESC
LIMIT %(limit)s
"""
result = db.query(sql, {"ja4": ja4, "limit": limit})
items = [
{
"country_code": str(row[0]),
"unique_ips": int(row[1]),
"hits": int(row[2]),
}
for row in result.result_rows
]
return {"items": items, "total": len(items)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/summary")
async def get_botnets_summary():
"""Statistiques globales sur les botnets détectés."""
try:
sql = f"""
SELECT
countIf(unique_countries > 100) AS total_global_botnets,
sumIf(unique_ips, unique_countries > 50) AS total_ips_in_botnets,
argMax(ja4, unique_countries) AS most_spread_ja4,
argMax(ja4, unique_ips) AS most_ips_ja4
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_host_ja4_anomalies
"""
result = db.query(sql)
row = result.result_rows[0]
return {
"total_global_botnets": int(row[0]),
"total_ips_in_botnets": int(row[1]),
"most_spread_ja4": str(row[2]),
"most_ips_ja4": str(row[3]),
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -1,142 +0,0 @@
"""
Endpoints pour l'analyse des attaques par force brute sur les formulaires
"""
from fastapi import APIRouter, HTTPException, Query
from ..database import db
from ..config import settings
router = APIRouter(prefix="/api/bruteforce", tags=["bruteforce"])
@router.get("/targets")
async def get_bruteforce_targets():
"""Liste des hôtes ciblés par brute-force, triés par total_hits DESC."""
try:
sql = f"""
SELECT
host,
uniq(src_ip) AS unique_ips,
sum(hits) AS total_hits,
sum(query_params_count) AS total_params,
groupArray(3)(ja4) AS top_ja4s
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_form_bruteforce_detected
GROUP BY host
ORDER BY total_hits DESC
"""
result = db.query(sql)
items = []
for row in result.result_rows:
host = str(row[0])
unique_ips = int(row[1])
total_hits = int(row[2])
total_params= int(row[3])
top_ja4s = [str(j) for j in (row[4] or [])]
attack_type = (
"credential_stuffing"
if total_hits > 0 and total_params / total_hits > 0.5
else "enumeration"
)
items.append({
"host": host,
"unique_ips": unique_ips,
"total_hits": total_hits,
"total_params":total_params,
"attack_type": attack_type,
"top_ja4s": top_ja4s,
})
return {"items": items, "total": len(items)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/attackers")
async def get_bruteforce_attackers(limit: int = Query(50, ge=1, le=500)):
"""Top IPs attaquantes triées par total_hits DESC."""
try:
sql = f"""
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
uniq(host) AS distinct_hosts,
sum(hits) AS total_hits,
sum(query_params_count) AS total_params,
argMax(ja4, hits) AS ja4
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_form_bruteforce_detected
GROUP BY src_ip
ORDER BY total_hits DESC
LIMIT %(limit)s
"""
result = db.query(sql, {"limit": limit})
items = []
for row in result.result_rows:
items.append({
"ip": str(row[0]),
"distinct_hosts":int(row[1]),
"total_hits": int(row[2]),
"total_params": int(row[3]),
"ja4": str(row[4]),
})
return {"items": items, "total": len(items)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/timeline")
async def get_bruteforce_timeline():
"""Hits par heure (dernières 72h) depuis agg_host_ip_ja4_1h."""
try:
sql = f"""
SELECT
toHour(window_start) AS hour,
sum(hits) AS hits,
uniq(replaceRegexpAll(toString(src_ip), '^::ffff:', '')) AS ips
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h
WHERE window_start >= now() - INTERVAL 72 HOUR
GROUP BY hour
ORDER BY hour ASC
"""
result = db.query(sql)
hours = []
for row in result.result_rows:
hours.append({
"hour": int(row[0]),
"hits": int(row[1]),
"ips": int(row[2]),
})
return {"hours": hours}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/host/{host:path}/attackers")
async def get_host_attackers(host: str, limit: int = Query(20, ge=1, le=200)):
"""Top IPs attaquant un hôte spécifique, avec JA4 et type d'attaque."""
try:
sql = f"""
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
sum(hits) AS total_hits,
sum(query_params_count) AS total_params,
argMax(ja4, hits) AS ja4,
max(hits) AS max_hits_per_window
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_form_bruteforce_detected
WHERE host = %(host)s
GROUP BY src_ip
ORDER BY total_hits DESC
LIMIT %(limit)s
"""
result = db.query(sql, {"host": host, "limit": limit})
items = []
for row in result.result_rows:
total_hits = int(row[1])
total_params = int(row[2])
items.append({
"ip": str(row[0]),
"total_hits": total_hits,
"total_params":total_params,
"ja4": str(row[3] or ""),
"attack_type": "credential_stuffing" if total_hits > 0 and total_params / total_hits > 0.5 else "enumeration",
})
return {"host": host, "items": items, "total": len(items)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -1,554 +0,0 @@
"""
Clustering d'IPs multi-métriques — WebGL / deck.gl backend.
- Calcul sur la TOTALITÉ des IPs (GROUP BY src_ip, ja4 sans LIMIT)
- K-means++ vectorisé (numpy) + PCA-2D + enveloppes convexes (scipy)
- Calcul en background thread + cache 30 min
- Endpoints : /clusters, /status, /cluster/{id}/points
"""
import math
import time
import logging
import threading
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from typing import Any
import numpy as np
from fastapi import APIRouter, HTTPException, Query
from ..database import db
from ..services.clustering_engine import (
FEATURE_NAMES,
build_feature_vector, kmeans_pp, pca_2d, compute_hulls,
name_cluster, risk_score_from_centroid, standardize,
risk_to_gradient_color,
)
from ..config import settings
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/clustering", tags=["clustering"])
# ─── Cache global ──────────────────────────────────────────────────────────────
_CACHE: dict[str, Any] = {
"status": "idle", # idle | computing | ready | error
"error": None,
"result": None, # dict résultat complet
"ts": 0.0, # timestamp dernière mise à jour
"params": {},
"cluster_ips": {}, # cluster_idx → [(ip, ja4, pca_x, pca_y, risk)]
}
_CACHE_TTL = 1800 # 30 minutes
_LOCK = threading.Lock()
_EXECUTOR = ThreadPoolExecutor(max_workers=1, thread_name_prefix="clustering")
# ─── Palette de couleurs (remplace l'ancienne logique menace) ─────────────────
# Les couleurs sont désormais attribuées par index de cluster pour maximiser
# la distinction visuelle, indépendamment du niveau de risque.
# ─── SQL : TOUTES les IPs sans LIMIT ─────────────────────────────────────────
_SQL_ALL_IPS = f"""
SELECT
replaceRegexpAll(toString(t.src_ip), '^::ffff:', '') AS ip,
t.ja4,
any(t.tcp_ttl_raw) AS ttl,
any(t.tcp_win_raw) AS win,
any(t.tcp_scale_raw) AS scale,
any(t.tcp_mss_raw) AS mss,
any(t.first_ua) AS ua,
sum(t.hits) AS hits,
avg(abs(ml.anomaly_score)) AS avg_score,
avg(ml.hit_velocity) AS avg_velocity,
avg(ml.fuzzing_index) AS avg_fuzzing,
avg(ml.is_headless) AS pct_headless,
avg(ml.post_ratio) AS avg_post,
avg(ml.ip_id_zero_ratio) AS ip_id_zero,
avg(ml.temporal_entropy) AS entropy,
avg(ml.modern_browser_score) AS browser_score,
avg(ml.alpn_http_mismatch) AS alpn_mismatch,
avg(ml.is_alpn_missing) AS alpn_missing,
avg(ml.multiplexing_efficiency) AS h2_eff,
avg(ml.header_order_confidence) AS hdr_conf,
avg(ml.ua_ch_mismatch) AS ua_ch_mismatch,
avg(ml.asset_ratio) AS asset_ratio,
avg(ml.direct_access_ratio) AS direct_ratio,
avg(ml.distinct_ja4_count) AS ja4_count,
max(ml.is_ua_rotating) AS ua_rotating,
max(ml.threat_level) AS threat,
any(ml.country_code) AS country,
any(ml.asn_org) AS asn_org,
-- Features headers HTTP (depuis view_dashboard_entities)
avg(ml.has_accept_language) AS hdr_accept_lang,
any(vh.hdr_enc) AS hdr_has_encoding,
any(vh.hdr_sec_fetch) AS hdr_has_sec_fetch,
any(vh.hdr_count) AS hdr_count_raw,
-- Fingerprint HTTP Headers (depuis agg_header_fingerprint_1h + ml_detected_anomalies)
-- header_order_shared_count : nb d'IPs partageant le même fingerprint
-- → faible = fingerprint rare = comportement suspect
avg(ml.header_order_shared_count) AS hfp_shared_count,
-- distinct_header_orders : nb de fingerprints distincts émis par cette IP
-- → élevé = rotation de fingerprint = comportement bot
avg(ml.distinct_header_orders) AS hfp_distinct_orders,
-- Cookie et Referer issus de la table dédiée aux empreintes
any(hfp.hfp_cookie) AS hfp_cookie,
any(hfp.hfp_referer) AS hfp_referer
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h t
LEFT JOIN {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies ml
ON t.src_ip = ml.src_ip AND t.ja4 = ml.ja4
AND ml.detected_at >= now() - INTERVAL %(hours)s HOUR
LEFT JOIN (
SELECT
toIPv6(concat('::ffff:', toString(src_ip))) AS src_ip_v6,
ja4,
any(arrayExists(x -> x LIKE '%%Accept-Encoding%%', client_headers)) AS hdr_enc,
any(arrayExists(x -> x LIKE '%%Sec-Fetch%%', client_headers)) AS hdr_sec_fetch,
any(length(splitByChar(',', client_headers[1]))) AS hdr_count
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_entities
WHERE length(client_headers) > 0
AND log_date >= today() - 2
GROUP BY src_ip_v6, ja4
) vh ON t.src_ip = vh.src_ip_v6 AND t.ja4 = vh.ja4
LEFT JOIN (
SELECT
src_ip,
avg(has_cookie) AS hfp_cookie,
avg(has_referer) AS hfp_referer
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_header_fingerprint_1h
WHERE window_start >= now() - INTERVAL %(hours)s HOUR
GROUP BY src_ip
) hfp ON t.src_ip = hfp.src_ip
WHERE t.window_start >= now() - INTERVAL %(hours)s HOUR
AND t.tcp_ttl_raw > 0
GROUP BY t.src_ip, t.ja4
"""
_SQL_COLS = [
"ip", "ja4", "ttl", "win", "scale", "mss", "ua", "hits",
"avg_score", "avg_velocity", "avg_fuzzing", "pct_headless", "avg_post",
"ip_id_zero", "entropy", "browser_score", "alpn_mismatch", "alpn_missing",
"h2_eff", "hdr_conf", "ua_ch_mismatch", "asset_ratio", "direct_ratio",
"ja4_count", "ua_rotating", "threat", "country", "asn_org",
"hdr_accept_lang", "hdr_has_encoding", "hdr_has_sec_fetch", "hdr_count_raw",
"hfp_shared_count", "hfp_distinct_orders", "hfp_cookie", "hfp_referer",
]
# ─── Worker de clustering (thread pool) ──────────────────────────────────────
def _run_clustering_job(k: int, hours: int, sensitivity: float = 1.0) -> None:
"""Exécuté dans le thread pool. Met à jour _CACHE.
sensitivity : multiplicateur de k [0.5 5.0].
0.5 = vue très agrégée (k/2 clusters)
1.0 = comportement par défaut
2.0 = deux fois plus de clusters → groupes plus homogènes
5.0 = granularité maximale (classification la plus fine)
k_actual est plafonné à 300 pour éviter des temps de calcul excessifs.
n_init est réduit à 1 quand k_actual > 60 pour rester rapide.
"""
k_actual = max(4, min(300, round(k * sensitivity)))
t0 = time.time()
with _LOCK:
_CACHE["status"] = "computing"
_CACHE["error"] = None
try:
log.info(f"[clustering] Démarrage k={k_actual} (base={k}×sens={sensitivity}) hours={hours}")
# ── 1. Chargement de toutes les IPs ──────────────────────────────
result = db.query(_SQL_ALL_IPS, {"hours": hours})
rows: list[dict] = []
for row in result.result_rows:
rows.append({col: row[i] for i, col in enumerate(_SQL_COLS)})
n = len(rows)
log.info(f"[clustering] {n} IPs chargées")
if n < k_actual:
raise ValueError(f"Seulement {n} IPs disponibles (k={k_actual} requis)")
# ── 2. Construction de la matrice de features (numpy) ────────────
X = np.array([build_feature_vector(r) for r in rows], dtype=np.float32)
log.info(f"[clustering] Matrice X: {X.shape}{X.nbytes/1024/1024:.1f} MB")
# ── 3. Standardisation z-score ────────────────────────────────────
# Normalise par variance : features discriminantes (forte std)
# contribuent plus que les features quasi-constantes.
X64 = X.astype(np.float64)
X_std, feat_mean, feat_std = standardize(X64)
# ── 4. K-means++ sur l'espace standardisé ────────────────────────
# n_init réduit à 1 pour k élevé (> 60) afin de limiter le temps de calcul
n_init = 1 if k_actual > 60 else 3
km = kmeans_pp(X_std, k=k_actual, max_iter=80, n_init=n_init, seed=42)
log.info(f"[clustering] K-means: {km.n_iter} iters, inertia={km.inertia:.2f}")
# Centroïdes dans l'espace original [0,1] pour affichage radar
# (dé-standardisation : c_orig = c_std * std + mean, puis clip [0,1])
centroids_orig = np.clip(km.centroids * feat_std + feat_mean, 0.0, 1.0)
# ── 5. PCA-2D sur les features ORIGINALES (normalisées [0,1]) ────
coords = pca_2d(X64) # (n, 2), normalisé [0,1]
# ── 5b. Enveloppes convexes par cluster ──────────────────────────
hulls = compute_hulls(coords, km.labels, k_actual)
# ── 6. Agrégation par cluster ─────────────────────────────────────
cluster_rows: list[list[dict]] = [[] for _ in range(k_actual)]
cluster_coords: list[list[list[float]]] = [[] for _ in range(k_actual)]
cluster_ips_map: dict[int, list] = {j: [] for j in range(k_actual)}
for i, label in enumerate(km.labels):
j = int(label)
cluster_rows[j].append(rows[i])
cluster_coords[j].append(coords[i].tolist())
cluster_ips_map[j].append((
rows[i]["ip"],
rows[i]["ja4"],
float(coords[i][0]),
float(coords[i][1]),
float(risk_score_from_centroid(centroids_orig[j])),
))
# ── 7. Construction des nœuds ─────────────────────────────────────
nodes = []
for j in range(k_actual):
if not cluster_rows[j]:
continue
def avg_f(key: str, crows: list[dict] = cluster_rows[j]) -> float:
"""Calcule la moyenne flottante d'un champ numérique sur les lignes du cluster."""
return float(np.mean([float(r.get(key) or 0) for r in crows]))
mean_ttl = avg_f("ttl")
mean_mss = avg_f("mss")
mean_scale = avg_f("scale")
mean_win = avg_f("win")
raw_stats = {"mean_ttl": mean_ttl, "mean_mss": mean_mss, "mean_scale": mean_scale}
label_name = name_cluster(centroids_orig[j], raw_stats)
risk = float(risk_score_from_centroid(centroids_orig[j]))
color = risk_to_gradient_color(risk)
# Centroïde 2D = moyenne des coords du cluster
cxy = np.mean(cluster_coords[j], axis=0).tolist() if cluster_coords[j] else [0.5, 0.5]
ip_set = list({r["ip"] for r in cluster_rows[j]})
ip_count = len(ip_set)
hit_count = int(sum(float(r.get("hits") or 0) for r in cluster_rows[j]))
threats = [str(r.get("threat") or "") for r in cluster_rows[j] if r.get("threat")]
countries = [str(r.get("country") or "") for r in cluster_rows[j] if r.get("country")]
orgs = [str(r.get("asn_org") or "") for r in cluster_rows[j] if r.get("asn_org")]
def topk(lst: list[str], n: int = 5) -> list[str]:
"""Retourne les n valeurs les plus fréquentes d'une liste (valeurs vides exclues)."""
return [v for v, _ in Counter(lst).most_common(n) if v]
radar = [
{"feature": name, "value": round(float(centroids_orig[j][i]), 4)}
for i, name in enumerate(FEATURE_NAMES)
]
radius = max(8, min(30, int(math.log1p(ip_count) * 2.2)))
sample_rows = sorted(cluster_rows[j], key=lambda r: float(r.get("hits") or 0), reverse=True)[:8]
sample_ips = [r["ip"] for r in sample_rows]
sample_ua = str(cluster_rows[j][0].get("ua") or "")
nodes.append({
"id": f"c{j}_k{k_actual}",
"cluster_idx": j,
"label": label_name,
"pca_x": round(cxy[0], 6),
"pca_y": round(cxy[1], 6),
"radius": radius,
"color": color,
"risk_score": round(risk, 4),
"mean_ttl": round(mean_ttl, 1),
"mean_mss": round(mean_mss, 0),
"mean_scale": round(mean_scale, 1),
"mean_win": round(mean_win, 0),
"mean_velocity":round(avg_f("avg_velocity"),3),
"mean_fuzzing": round(avg_f("avg_fuzzing"), 3),
"mean_headless":round(avg_f("pct_headless"),3),
"mean_post": round(avg_f("avg_post"), 3),
"mean_asset": round(avg_f("asset_ratio"), 3),
"mean_direct": round(avg_f("direct_ratio"),3),
"mean_alpn_mismatch": round(avg_f("alpn_mismatch"),3),
"mean_h2_eff": round(avg_f("h2_eff"), 3),
"mean_hdr_conf":round(avg_f("hdr_conf"), 3),
"mean_ua_ch": round(avg_f("ua_ch_mismatch"),3),
"mean_entropy": round(avg_f("entropy"), 3),
"mean_ja4_diversity": round(avg_f("ja4_count"),3),
"mean_ip_id_zero": round(avg_f("ip_id_zero"),3),
"mean_browser_score": round(avg_f("browser_score"),1),
"mean_ua_rotating": round(avg_f("ua_rotating"),3),
"ip_count": ip_count,
"hit_count": hit_count,
"top_threat": topk(threats, 1)[0] if threats else "",
"top_countries":topk(countries, 5),
"top_orgs": topk(orgs, 5),
"sample_ips": sample_ips,
"sample_ua": sample_ua,
"radar": radar,
# Hull pour deck.gl PolygonLayer
"hull": hulls.get(j, []),
})
# ── 8. Arêtes k-NN entre clusters ────────────────────────────────
edges = []
seen: set[frozenset] = set()
for i, ni in enumerate(nodes):
ci = ni["cluster_idx"]
dists = sorted(
[(j, nj["cluster_idx"],
float(np.sum((centroids_orig[ci] - centroids_orig[nj["cluster_idx"]]) ** 2)))
for j, nj in enumerate(nodes) if j != i],
key=lambda x: x[2]
)
for j_idx, cj, d2 in dists[:2]:
key = frozenset([ni["id"], nodes[j_idx]["id"]])
if key in seen:
continue
seen.add(key)
edges.append({
"id": f"e_{ni['id']}_{nodes[j_idx]['id']}",
"source": ni["id"],
"target": nodes[j_idx]["id"],
"similarity": round(1.0 / (1.0 + math.sqrt(d2)), 3),
})
# ── 9. Stockage résultat + cache IPs ─────────────────────────────
total_ips = sum(n_["ip_count"] for n_ in nodes)
total_hits = sum(n_["hit_count"] for n_ in nodes)
elapsed = round(time.time() - t0, 2)
result_dict = {
"nodes": nodes,
"edges": edges,
"stats": {
"total_clusters": len(nodes),
"total_ips": total_ips,
"total_hits": total_hits,
"n_samples": n,
"k": k_actual,
"k_base": k,
"sensitivity": sensitivity,
"elapsed_s": elapsed,
},
"feature_names": FEATURE_NAMES,
}
with _LOCK:
_CACHE["result"] = result_dict
_CACHE["cluster_ips"] = cluster_ips_map
_CACHE["status"] = "ready"
_CACHE["ts"] = time.time()
_CACHE["params"] = {"k": k, "hours": hours, "sensitivity": sensitivity}
_CACHE["error"] = None
log.info(f"[clustering] Terminé en {elapsed}s — {total_ips} IPs, {len(nodes)} clusters")
except Exception as e:
log.exception("[clustering] Erreur lors du calcul")
with _LOCK:
_CACHE["status"] = "error"
_CACHE["error"] = str(e)
def _maybe_trigger(k: int, hours: int, sensitivity: float) -> None:
"""Lance le calcul si cache absent, expiré ou paramètres différents."""
with _LOCK:
status = _CACHE["status"]
params = _CACHE["params"]
ts = _CACHE["ts"]
cache_stale = (time.time() - ts) > _CACHE_TTL
params_changed = (
params.get("k") != k or
params.get("hours") != hours or
params.get("sensitivity") != sensitivity
)
if status in ("computing",):
return # déjà en cours
if status == "ready" and not cache_stale and not params_changed:
return # cache frais
_EXECUTOR.submit(_run_clustering_job, k, hours, sensitivity)
# ─── Endpoints ────────────────────────────────────────────────────────────────
@router.get("/status")
async def get_status():
"""État du calcul en cours (polling frontend)."""
with _LOCK:
return {
"status": _CACHE["status"],
"error": _CACHE["error"],
"ts": _CACHE["ts"],
"params": _CACHE["params"],
"age_s": round(time.time() - _CACHE["ts"], 0) if _CACHE["ts"] else None,
}
@router.get("/clusters")
async def get_clusters(
k: int = Query(20, ge=4, le=100, description="Nombre de clusters de base"),
hours: int = Query(24, ge=1, le=168, description="Fenêtre temporelle (heures)"),
sensitivity: float = Query(1.0, ge=0.5, le=5.0, description="Sensibilité : multiplicateur de k (5.0 = granularité maximale)"),
force: bool = Query(False, description="Forcer le recalcul"),
):
"""
Clustering multi-métriques sur TOUTES les IPs.
k_actual = round(k × sensitivity) — la sensibilité contrôle la granularité.
Retourne immédiatement depuis le cache. Déclenche le calcul si nécessaire.
"""
if force:
with _LOCK:
_CACHE["status"] = "idle"
_CACHE["ts"] = 0.0
_CACHE["result"] = None
_CACHE["cluster_ips"] = {}
_maybe_trigger(k, hours, sensitivity)
with _LOCK:
status = _CACHE["status"]
result = _CACHE["result"]
error = _CACHE["error"]
if status == "computing":
return {"status": "computing", "message": "Calcul en cours, réessayez dans quelques secondes"}
if status == "error":
raise HTTPException(status_code=500, detail=error or "Erreur inconnue")
if result is None:
return {"status": "idle", "message": "Calcul démarré, réessayez dans quelques secondes"}
return {**result, "status": "ready"}
@router.get("/cluster/{cluster_id}/points")
async def get_cluster_points(
cluster_id: str,
limit: int = Query(5000, ge=1, le=20000),
offset: int = Query(0, ge=0),
):
"""
Coordonnées PCA + métadonnées de toutes les IPs d'un cluster.
Utilisé par deck.gl ScatterplotLayer (drill-down ou zoom avancé).
"""
with _LOCK:
status = _CACHE["status"]
ips_map = _CACHE["cluster_ips"]
if status != "ready" or not ips_map:
raise HTTPException(status_code=404, detail="Cache absent — appelez /clusters d'abord")
try:
idx = int(cluster_id.split("_")[0][1:])
except (ValueError, IndexError):
raise HTTPException(status_code=400, detail="cluster_id invalide (format: c{n}_k{k})")
members = ips_map.get(idx, [])
total = len(members)
page = members[offset: offset + limit]
points = [
{"ip": m[0], "ja4": m[1], "pca_x": round(m[2], 6), "pca_y": round(m[3], 6), "risk": round(m[4], 3)}
for m in page
]
return {"points": points, "total": total, "offset": offset, "limit": limit}
@router.get("/cluster/{cluster_id}/ips")
async def get_cluster_ips(
cluster_id: str,
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
):
"""IPs avec détails SQL (backward-compat avec l'ancienne UI)."""
with _LOCK:
status = _CACHE["status"]
ips_map = _CACHE["cluster_ips"]
if status != "ready" or not ips_map:
raise HTTPException(status_code=404, detail="Cache absent — appelez /clusters d'abord")
try:
idx = int(cluster_id.split("_")[0][1:])
except (ValueError, IndexError):
raise HTTPException(status_code=400, detail="cluster_id invalide")
members = ips_map.get(idx, [])
total = len(members)
page = members[offset: offset + limit]
if not page:
return {"ips": [], "total": total, "cluster_id": cluster_id}
safe_ips = [m[0].replace("'", "") for m in page[:200]]
ip_filter = ", ".join(f"'{ip}'" for ip in safe_ips)
sql = f"""
SELECT
replaceRegexpAll(toString(t.src_ip), '^::ffff:', '') AS src_ip,
t.ja4,
any(t.tcp_ttl_raw) AS ttl,
any(t.tcp_win_raw) AS win,
any(t.tcp_scale_raw) AS scale,
any(t.tcp_mss_raw) AS mss,
sum(t.hits) AS hits,
any(t.first_ua) AS ua,
round(avg(abs(ml.anomaly_score)), 3) AS avg_score,
max(ml.threat_level) AS threat_level,
any(ml.country_code) AS country_code,
any(ml.asn_org) AS asn_org,
round(avg(ml.fuzzing_index), 2) AS fuzzing,
round(avg(ml.hit_velocity), 2) AS velocity
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h t
LEFT JOIN {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies ml
ON t.src_ip = ml.src_ip AND t.ja4 = ml.ja4
AND ml.detected_at >= now() - INTERVAL 24 HOUR
WHERE t.window_start >= now() - INTERVAL 24 HOUR
AND replaceRegexpAll(toString(t.src_ip), '^::ffff:', '') IN ({ip_filter})
GROUP BY t.src_ip, t.ja4
ORDER BY hits DESC
"""
try:
result = db.query(sql)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
ips = []
for row in result.result_rows:
ips.append({
"ip": str(row[0] or ""),
"ja4": str(row[1] or ""),
"tcp_ttl": int(row[2] or 0),
"tcp_win": int(row[3] or 0),
"tcp_scale": int(row[4] or 0),
"tcp_mss": int(row[5] or 0),
"hits": int(row[6] or 0),
"ua": str(row[7] or ""),
"avg_score": float(row[8] or 0),
"threat_level": str(row[9] or ""),
"country_code": str(row[10] or ""),
"asn_org": str(row[11] or ""),
"fuzzing": float(row[12] or 0),
"velocity": float(row[13] or 0),
})
return {"ips": ips, "total": total, "cluster_id": cluster_id}

View File

@ -1,451 +0,0 @@
"""
Endpoints pour la liste des détections
"""
from fastapi import APIRouter, HTTPException, Query
from typing import Optional, List
from ..database import db
from ..models import DetectionsListResponse, Detection
from ..config import settings
router = APIRouter(prefix="/api/detections", tags=["detections"])
# Mapping label ASN → score float (0 = très suspect, 1 = légitime)
_ASN_LABEL_SCORES: dict[str, float] = {
'human': 0.9, 'bot': 0.05, 'proxy': 0.25, 'vpn': 0.3,
'tor': 0.1, 'datacenter': 0.4, 'scanner': 0.05, 'malicious': 0.05,
}
def _label_to_score(label: str) -> float | None:
"""Convertit un label de réputation ASN en score numérique."""
if not label:
return None
return _ASN_LABEL_SCORES.get(label.lower(), 0.5)
@router.get("", response_model=DetectionsListResponse, summary="Liste paginée des détections")
async def get_detections(
page: int = Query(1, ge=1, description="Numéro de page"),
page_size: int = Query(25, ge=1, le=100, description="Nombre de lignes par page"),
threat_level: Optional[str] = Query(None, description="Filtrer par niveau de menace"),
model_name: Optional[str] = Query(None, description="Filtrer par modèle"),
country_code: Optional[str] = Query(None, description="Filtrer par pays"),
asn_number: Optional[str] = Query(None, description="Filtrer par ASN"),
search: Optional[str] = Query(None, description="Recherche texte (IP, JA4, Host)"),
sort_by: str = Query("detected_at", description="Trier par"),
sort_order: str = Query("DESC", description="Ordre (ASC/DESC)"),
group_by_ip: bool = Query(False, description="Grouper par IP (first_seen/last_seen agrégés)"),
score_type: Optional[str] = Query(None, description="Filtrer par type de score: BOT, REGLE, BOT_REGLE, SCORE")
):
"""
Récupère la liste des détections avec pagination et filtres
"""
try:
# Construction de la requête
where_clauses = ["detected_at >= now() - INTERVAL 24 HOUR"]
params = {}
if threat_level:
where_clauses.append("threat_level = %(threat_level)s")
params["threat_level"] = threat_level
if model_name:
where_clauses.append("model_name = %(model_name)s")
params["model_name"] = model_name
if country_code:
where_clauses.append("country_code = %(country_code)s")
params["country_code"] = country_code.upper()
if asn_number:
where_clauses.append("asn_number = %(asn_number)s")
params["asn_number"] = asn_number
if search:
where_clauses.append(
"(ilike(toString(src_ip), %(search)s) OR ilike(ja4, %(search)s) OR ilike(host, %(search)s))"
)
params["search"] = f"%{search}%"
if score_type:
st = score_type.upper()
if st == "BOT":
where_clauses.append("threat_level = 'KNOWN_BOT'")
elif st == "REGLE":
where_clauses.append("threat_level = 'ANUBIS_DENY'")
elif st == "BOT_REGLE":
where_clauses.append("threat_level IN ('KNOWN_BOT', 'ANUBIS_DENY')")
elif st == "SCORE":
where_clauses.append("threat_level NOT IN ('KNOWN_BOT', 'ANUBIS_DENY')")
where_clause = " AND ".join(where_clauses)
# Requête de comptage
count_query = f"""
SELECT count()
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE {where_clause}
"""
count_result = db.query(count_query, params)
total = count_result.result_rows[0][0] if count_result.result_rows else 0
# Requête principale
offset = (page - 1) * page_size
sort_order = "DESC" if sort_order.upper() == "DESC" else "ASC"
# ── Mode groupé par IP (first_seen / last_seen depuis la DB) ────────────
if group_by_ip:
valid_sort_grouped = ["anomaly_score", "hits", "hit_velocity", "first_seen", "last_seen", "src_ip", "detected_at"]
grouped_sort = sort_by if sort_by in valid_sort_grouped else "last_seen"
# detected_at → last_seen (max(detected_at) dans le GROUP BY)
if grouped_sort == "detected_at":
grouped_sort = "last_seen"
# In outer query, min_score is exposed as anomaly_score — keep the alias
outer_sort = "min_score" if grouped_sort == "anomaly_score" else grouped_sort
# Count distinct IPs
count_ip_query = f"""
SELECT uniq(src_ip)
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE {where_clause}
"""
cr = db.query(count_ip_query, params)
total = cr.result_rows[0][0] if cr.result_rows else 0
grouped_query = f"""
SELECT
ip_data.src_ip,
ip_data.first_seen,
ip_data.last_seen,
ip_data.detection_count,
ip_data.unique_ja4s,
ip_data.unique_hosts,
ip_data.min_score AS anomaly_score,
ip_data.threat_level_best,
ip_data.model_name_best,
ip_data.country_code,
ip_data.asn_number,
ip_data.asn_org,
ip_data.hit_velocity,
ip_data.hits,
ip_data.asn_label,
ar.label AS asn_rep_label,
ip_data.anubis_bot_name_best,
ip_data.anubis_bot_action_best,
ip_data.anubis_bot_category_best
FROM (
SELECT
src_ip,
min(detected_at) AS first_seen,
max(detected_at) AS last_seen,
count() AS detection_count,
groupUniqArray(5)(ja4) AS unique_ja4s,
groupUniqArray(5)(host) AS unique_hosts,
min(anomaly_score) AS min_score,
argMin(threat_level, anomaly_score) AS threat_level_best,
argMin(model_name, anomaly_score) AS model_name_best,
any(country_code) AS country_code,
any(asn_number) AS asn_number,
any(asn_org) AS asn_org,
max(hit_velocity) AS hit_velocity,
sum(hits) AS hits,
any(asn_label) AS asn_label,
argMin(anubis_bot_name, anomaly_score) AS anubis_bot_name_best,
argMin(anubis_bot_action, anomaly_score) AS anubis_bot_action_best,
argMin(anubis_bot_category, anomaly_score) AS anubis_bot_category_best
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE {where_clause}
GROUP BY src_ip
) ip_data
LEFT JOIN {settings.CLICKHOUSE_DB_PROCESSING}.asn_reputation ar
ON ar.src_asn = toUInt32OrZero(ip_data.asn_number)
ORDER BY {outer_sort} {sort_order}
LIMIT %(limit)s OFFSET %(offset)s
"""
params["limit"] = page_size
params["offset"] = offset
gresult = db.query(grouped_query, params)
detections = []
for row in gresult.result_rows:
# row: src_ip, first_seen, last_seen, detection_count, unique_ja4s, unique_hosts,
# anomaly_score, threat_level_best, model_name_best, country_code, asn_number,
# asn_org, hit_velocity, hits, asn_label, asn_rep_label,
# anubis_bot_name, anubis_bot_action, anubis_bot_category
ja4s = list(row[4]) if row[4] else []
hosts = list(row[5]) if row[5] else []
detections.append(Detection(
detected_at=row[1],
src_ip=str(row[0]),
ja4=ja4s[0] if ja4s else "",
host=hosts[0] if hosts else "",
bot_name="",
anomaly_score=float(row[6]) if row[6] else 0.0,
threat_level=row[7] or "LOW",
model_name=row[8] or "",
recurrence=int(row[3] or 0),
asn_number=str(row[10]) if row[10] else "",
asn_org=row[11] or "",
asn_detail="",
asn_domain="",
country_code=row[9] or "",
asn_label=row[14] or "",
hits=int(row[13] or 0),
hit_velocity=float(row[12]) if row[12] else 0.0,
fuzzing_index=0.0,
post_ratio=0.0,
reason="",
asn_rep_label=row[15] or "",
asn_score=_label_to_score(row[15] or ""),
first_seen=row[1],
last_seen=row[2],
unique_ja4s=ja4s,
unique_hosts=hosts,
anubis_bot_name=row[16] or "",
anubis_bot_action=row[17] or "",
anubis_bot_category=row[18] or "",
))
total_pages = (total + page_size - 1) // page_size
return DetectionsListResponse(
items=detections, total=total, page=page,
page_size=page_size, total_pages=total_pages
)
# ── Mode individuel (comportement original) ──────────────────────────────
# Validation du tri
valid_sort_columns = [
"detected_at", "src_ip", "threat_level", "anomaly_score",
"asn_number", "country_code", "hits", "hit_velocity"
]
if sort_by not in valid_sort_columns:
sort_by = "detected_at"
main_query = f"""
SELECT
detected_at,
src_ip,
ja4,
host,
bot_name,
anomaly_score,
threat_level,
model_name,
recurrence,
asn_number,
asn_org,
asn_detail,
asn_domain,
country_code,
asn_label,
hits,
hit_velocity,
fuzzing_index,
post_ratio,
reason,
ar.label AS asn_rep_label,
anubis_bot_name,
anubis_bot_action,
anubis_bot_category
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
LEFT JOIN {settings.CLICKHOUSE_DB_PROCESSING}.asn_reputation ar ON ar.src_asn = toUInt32OrZero(asn_number)
WHERE {where_clause}
ORDER BY {sort_by} {sort_order}
LIMIT %(limit)s OFFSET %(offset)s
"""
params["limit"] = page_size
params["offset"] = offset
result = db.query(main_query, params)
detections = [
Detection(
detected_at=row[0],
src_ip=str(row[1]),
ja4=row[2] or "",
host=row[3] or "",
bot_name=row[4] or "",
anomaly_score=float(row[5]) if row[5] else 0.0,
threat_level=row[6] or "LOW",
model_name=row[7] or "",
recurrence=row[8] or 0,
asn_number=str(row[9]) if row[9] else "",
asn_org=row[10] or "",
asn_detail=row[11] or "",
asn_domain=row[12] or "",
country_code=row[13] or "",
asn_label=row[14] or "",
hits=row[15] or 0,
hit_velocity=float(row[16]) if row[16] else 0.0,
fuzzing_index=float(row[17]) if row[17] else 0.0,
post_ratio=float(row[18]) if row[18] else 0.0,
reason=row[19] or "",
asn_rep_label=row[20] or "",
asn_score=_label_to_score(row[20] or ""),
anubis_bot_name=row[21] or "",
anubis_bot_action=row[22] or "",
anubis_bot_category=row[23] or "",
)
for row in result.result_rows
]
total_pages = (total + page_size - 1) // page_size
return DetectionsListResponse(
items=detections,
total=total,
page=page,
page_size=page_size,
total_pages=total_pages
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur lors de la récupération des détections: {str(e)}")
@router.get("/{detection_id}")
async def get_detection_details(detection_id: str):
"""
Récupère les détails d'une détection spécifique
detection_id peut être une IP ou un identifiant
"""
try:
query = f"""
SELECT
detected_at,
src_ip,
ja4,
host,
bot_name,
anomaly_score,
threat_level,
model_name,
recurrence,
asn_number,
asn_org,
asn_detail,
asn_domain,
country_code,
asn_label,
hits,
hit_velocity,
fuzzing_index,
post_ratio,
port_exhaustion_ratio,
orphan_ratio,
tcp_jitter_variance,
tcp_shared_count,
true_window_size,
window_mss_ratio,
alpn_http_mismatch,
is_alpn_missing,
sni_host_mismatch,
header_count,
has_accept_language,
has_cookie,
has_referer,
modern_browser_score,
ua_ch_mismatch,
header_order_shared_count,
ip_id_zero_ratio,
request_size_variance,
multiplexing_efficiency,
mss_mobile_mismatch,
correlated,
reason,
asset_ratio,
direct_access_ratio,
is_ua_rotating,
distinct_ja4_count,
src_port_density,
ja4_asn_concentration,
ja4_country_concentration,
is_rare_ja4
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE src_ip = %(ip)s
ORDER BY detected_at DESC
LIMIT 1
"""
result = db.query(query, {"ip": detection_id})
if not result.result_rows:
raise HTTPException(status_code=404, detail="Détection non trouvée")
row = result.result_rows[0]
return {
"detected_at": row[0],
"src_ip": str(row[1]),
"ja4": row[2] or "",
"host": row[3] or "",
"bot_name": row[4] or "",
"anomaly_score": float(row[5]) if row[5] else 0.0,
"threat_level": row[6] or "LOW",
"model_name": row[7] or "",
"recurrence": row[8] or 0,
"asn": {
"number": str(row[9]) if row[9] else "",
"org": row[10] or "",
"detail": row[11] or "",
"domain": row[12] or "",
"label": row[14] or ""
},
"country": {
"code": row[13] or "",
},
"metrics": {
"hits": row[15] or 0,
"hit_velocity": float(row[16]) if row[16] else 0.0,
"fuzzing_index": float(row[17]) if row[17] else 0.0,
"post_ratio": float(row[18]) if row[18] else 0.0,
"port_exhaustion_ratio": float(row[19]) if row[19] else 0.0,
"orphan_ratio": float(row[20]) if row[20] else 0.0,
},
"tcp": {
"jitter_variance": float(row[21]) if row[21] else 0.0,
"shared_count": row[22] or 0,
"true_window_size": row[23] or 0,
"window_mss_ratio": float(row[24]) if row[24] else 0.0,
},
"tls": {
"alpn_http_mismatch": bool(row[25]) if row[25] is not None else False,
"is_alpn_missing": bool(row[26]) if row[26] is not None else False,
"sni_host_mismatch": bool(row[27]) if row[27] is not None else False,
},
"headers": {
"count": row[28] or 0,
"has_accept_language": bool(row[29]) if row[29] is not None else False,
"has_cookie": bool(row[30]) if row[30] is not None else False,
"has_referer": bool(row[31]) if row[31] is not None else False,
"modern_browser_score": row[32] or 0,
"ua_ch_mismatch": bool(row[33]) if row[33] is not None else False,
"header_order_shared_count": row[34] or 0,
},
"behavior": {
"ip_id_zero_ratio": float(row[35]) if row[35] else 0.0,
"request_size_variance": float(row[36]) if row[36] else 0.0,
"multiplexing_efficiency": float(row[37]) if row[37] else 0.0,
"mss_mobile_mismatch": bool(row[38]) if row[38] is not None else False,
"correlated": bool(row[39]) if row[39] is not None else False,
},
"advanced": {
"asset_ratio": float(row[41]) if row[41] else 0.0,
"direct_access_ratio": float(row[42]) if row[42] else 0.0,
"is_ua_rotating": bool(row[43]) if row[43] is not None else False,
"distinct_ja4_count": row[44] or 0,
"src_port_density": float(row[45]) if row[45] else 0.0,
"ja4_asn_concentration": float(row[46]) if row[46] else 0.0,
"ja4_country_concentration": float(row[47]) if row[47] else 0.0,
"is_rare_ja4": bool(row[48]) if row[48] is not None else False,
},
"reason": row[40] or ""
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")

View File

@ -1,510 +0,0 @@
"""
Routes pour l'investigation d'entités (IP, JA4, User-Agent, Client-Header, Host, Path, Query-Param)
"""
from fastapi import APIRouter, HTTPException, Query
from typing import Optional, List
from ..database import db
from ..models import (
EntityInvestigation,
EntityStats,
EntityRelatedAttributes,
EntityAttributeValue
)
from ..config import settings
router = APIRouter(prefix="/api/entities", tags=["Entities"])
# Ensemble des types d'entités valides
VALID_ENTITY_TYPES = frozenset({
'ip', 'ja4', 'user_agent', 'client_header', 'host', 'path', 'query_param'
})
def get_entity_stats(entity_type: str, entity_value: str, hours: int = 24) -> Optional[EntityStats]:
"""
Récupère les statistiques pour une entité donnée
"""
query = f"""
SELECT
entity_type,
entity_value,
sum(requests) as total_requests,
sum(unique_ips) as unique_ips,
min(log_date) as first_seen,
max(log_date) as last_seen
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_entities
WHERE entity_type = %(entity_type)s
AND entity_value = %(entity_value)s
AND log_date >= toDate(now() - INTERVAL %(hours)s HOUR)
GROUP BY entity_type, entity_value
"""
result = db.query(query, {
'entity_type': entity_type,
'entity_value': entity_value,
'hours': hours
})
if not result.result_rows:
return None
row = result.result_rows[0]
return EntityStats(
entity_type=row[0],
entity_value=row[1],
total_requests=row[2],
unique_ips=row[3],
first_seen=row[4],
last_seen=row[5]
)
def get_related_attributes(entity_type: str, entity_value: str, hours: int = 24) -> EntityRelatedAttributes:
"""
Récupère les attributs associés à une entité
"""
# Requête pour agréger tous les attributs associés
query = f"""
SELECT
(SELECT groupUniqArray(toString(src_ip)) FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_entities WHERE entity_type = %(entity_type)s AND entity_value = %(entity_value)s AND log_date >= toDate(now() - INTERVAL %(hours)s HOUR)) as ips,
(SELECT groupUniqArray(ja4) FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_entities WHERE entity_type = %(entity_type)s AND entity_value = %(entity_value)s AND log_date >= toDate(now() - INTERVAL %(hours)s HOUR) AND ja4 != '') as ja4s,
(SELECT groupUniqArray(host) FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_entities WHERE entity_type = %(entity_type)s AND entity_value = %(entity_value)s AND log_date >= toDate(now() - INTERVAL %(hours)s HOUR) AND host != '') as hosts,
(SELECT groupUniqArrayArray(asns) FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_entities WHERE entity_type = %(entity_type)s AND entity_value = %(entity_value)s AND log_date >= toDate(now() - INTERVAL %(hours)s HOUR) AND notEmpty(asns)) as asns,
(SELECT groupUniqArrayArray(countries) FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_entities WHERE entity_type = %(entity_type)s AND entity_value = %(entity_value)s AND log_date >= toDate(now() - INTERVAL %(hours)s HOUR) AND notEmpty(countries)) as countries
"""
result = db.query(query, {
'entity_type': entity_type,
'entity_value': entity_value,
'hours': hours
})
if not result.result_rows or not any(result.result_rows[0]):
return EntityRelatedAttributes(
ips=[],
ja4s=[],
hosts=[],
asns=[],
countries=[]
)
row = result.result_rows[0]
return EntityRelatedAttributes(
ips=[str(ip) for ip in (row[0] or []) if ip],
ja4s=[ja4 for ja4 in (row[1] or []) if ja4],
hosts=[host for host in (row[2] or []) if host],
asns=[asn for asn in (row[3] or []) if asn],
countries=[country for country in (row[4] or []) if country]
)
def get_array_values(entity_type: str, entity_value: str, array_field: str, hours: int = 24) -> List[EntityAttributeValue]:
"""
Extrait et retourne les valeurs d'un champ Array (user_agents, client_headers, etc.)
"""
query = f"""
SELECT
value,
count() as count,
round(count * 100.0 / sum(count) OVER (), 2) as percentage
FROM (
SELECT
arrayJoin({array_field}) as value
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_entities
WHERE entity_type = %(entity_type)s
AND entity_value = %(entity_value)s
AND log_date >= toDate(now() - INTERVAL %(hours)s HOUR)
AND notEmpty({array_field})
)
GROUP BY value
ORDER BY count DESC
"""
result = db.query(query, {
'entity_type': entity_type,
'entity_value': entity_value,
'hours': hours
})
return [
EntityAttributeValue(
value=row[0],
count=row[1],
percentage=row[2]
)
for row in result.result_rows
]
@router.get("/subnet/{subnet:path}")
async def get_subnet_investigation(
subnet: str,
hours: int = Query(default=24, ge=1, le=720)
):
"""
Récupère toutes les IPs d'un subnet /24 avec leurs statistiques
Utilise ml_detected_anomalies pour les détections + view_dashboard_entities pour les user-agents
"""
try:
# Extraire l'IP de base du subnet (ex: 192.168.1.0/24 -> 192.168.1.0)
subnet_ip = subnet.replace('/24', '').replace('/16', '').replace('/8', '')
# Extraire les 3 premiers octets pour le filtre (ex: 141.98.11)
subnet_parts = subnet_ip.split('.')[:3]
subnet_prefix = subnet_parts[0]
subnet_mask = subnet_parts[1]
subnet_third = subnet_parts[2]
# Stats globales du subnet - utilise ml_detected_anomalies + view_dashboard_entities pour UA
stats_query = f"""
WITH cleaned_ips AS (
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS clean_ip,
detected_at,
ja4,
host,
country_code,
asn_number
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE detected_at >= now() - INTERVAL %(hours)s HOUR
),
subnet_filter AS (
SELECT *
FROM cleaned_ips
WHERE splitByChar('.', clean_ip)[1] = %(subnet_prefix)s
AND splitByChar('.', clean_ip)[2] = %(subnet_mask)s
AND splitByChar('.', clean_ip)[3] = %(subnet_third)s
),
-- Récupérer les user-agents depuis view_dashboard_entities
ua_data AS (
SELECT
entity_value AS ip,
arrayJoin(user_agents) AS user_agent
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_entities
WHERE entity_type = 'ip'
AND log_date >= toDate(now() - INTERVAL %(hours)s HOUR)
AND splitByChar('.', entity_value)[1] = %(subnet_prefix)s
AND splitByChar('.', entity_value)[2] = %(subnet_mask)s
AND splitByChar('.', entity_value)[3] = %(subnet_third)s
)
SELECT
%(subnet)s AS subnet,
uniq(clean_ip) AS total_ips,
count() AS total_detections,
uniq(ja4) AS unique_ja4,
(SELECT uniq(user_agent) FROM ua_data) AS unique_ua,
uniq(host) AS unique_hosts,
argMax(country_code, detected_at) AS primary_country,
argMax(asn_number, detected_at) AS primary_asn,
min(detected_at) AS first_seen,
max(detected_at) AS last_seen
FROM subnet_filter
"""
stats_result = db.query(stats_query, {
"subnet": subnet,
"subnet_prefix": subnet_prefix,
"subnet_mask": subnet_mask,
"subnet_third": subnet_third,
"hours": hours
})
if not stats_result.result_rows or stats_result.result_rows[0][1] == 0:
raise HTTPException(status_code=404, detail="Subnet non trouvé")
stats_row = stats_result.result_rows[0]
stats = {
"subnet": subnet,
"total_ips": stats_row[1] or 0,
"total_detections": stats_row[2] or 0,
"unique_ja4": stats_row[3] or 0,
"unique_ua": stats_row[4] or 0,
"unique_hosts": stats_row[5] or 0,
"primary_country": stats_row[6] or "XX",
"primary_asn": str(stats_row[7]) if stats_row[7] else "?",
"first_seen": stats_row[8].isoformat() if stats_row[8] else "",
"last_seen": stats_row[9].isoformat() if stats_row[9] else ""
}
# Liste des IPs avec détails - 2 requêtes séparées + fusion en Python
ips_query = f"""
WITH cleaned_ips AS (
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS clean_ip,
detected_at,
ja4,
country_code,
asn_number,
threat_level,
anomaly_score
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE detected_at >= now() - INTERVAL %(hours)s HOUR
),
subnet_filter AS (
SELECT *
FROM cleaned_ips
WHERE splitByChar('.', clean_ip)[1] = %(subnet_prefix)s
AND splitByChar('.', clean_ip)[2] = %(subnet_mask)s
AND splitByChar('.', clean_ip)[3] = %(subnet_third)s
)
SELECT
clean_ip AS ip,
count() AS total_detections,
uniq(ja4) AS unique_ja4,
argMax(country_code, detected_at) AS primary_country,
argMax(asn_number, detected_at) AS primary_asn,
argMax(threat_level, detected_at) AS threat_level,
avg(anomaly_score) AS avg_score,
min(detected_at) AS first_seen,
max(detected_at) AS last_seen
FROM subnet_filter
GROUP BY ip
ORDER BY total_detections DESC
"""
# Exécuter la première requête pour obtenir les IPs
ips_result = db.query(ips_query, {
"subnet_prefix": subnet_prefix,
"subnet_mask": subnet_mask,
"subnet_third": subnet_third,
"hours": hours
})
# Extraire la liste des IPs pour la requête UA
ip_list = [str(row[0]) for row in ips_result.result_rows]
# Requête pour les user-agents avec IN clause (utilise l'index)
unique_ua_dict = {}
if ip_list:
# Formater la liste pour la clause IN
ip_values = ', '.join(f"'{ip}'" for ip in ip_list)
ua_query = f"""
SELECT
entity_value AS ip,
uniq(arrayJoin(user_agents)) AS unique_ua
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_entities
PREWHERE entity_type = 'ip'
WHERE entity_value IN ({ip_values})
AND log_date >= today() - INTERVAL 30 DAY
GROUP BY entity_value
"""
ua_result = db.query(ua_query, {})
unique_ua_dict = {row[0]: row[1] for row in ua_result.result_rows}
# Fusionner les résultats
ips = []
for row in ips_result.result_rows:
ips.append({
"ip": str(row[0]),
"total_detections": row[1],
"unique_ja4": row[2],
"unique_ua": unique_ua_dict.get(row[0], 0),
"primary_country": row[3] or "XX",
"primary_asn": str(row[4]) if row[4] else "?",
"threat_level": row[5] or "LOW",
"avg_score": abs(row[6] or 0),
"first_seen": row[7].isoformat() if row[7] else "",
"last_seen": row[8].isoformat() if row[8] else ""
})
return {
"stats": stats,
"ips": ips
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
@router.get("/types")
async def get_entity_types():
"""
Retourne la liste des types d'entités supportés.
NOTE: Cette route DOIT être déclarée avant /{entity_type}/... pour ne pas être masquée.
"""
return {
"entity_types": sorted(VALID_ENTITY_TYPES),
"descriptions": {
"ip": "Adresse IP source",
"ja4": "Fingerprint JA4 TLS",
"user_agent": "User-Agent HTTP",
"client_header": "Client Header",
"host": "Host HTTP",
"path": "Path URL",
"query_param": "Query Param"
}
}
@router.get("/{entity_type}/{entity_value:path}", response_model=EntityInvestigation)
async def get_entity_investigation(
entity_type: str,
entity_value: str,
hours: int = Query(default=24, ge=1, le=720, description="Fenêtre temporelle en heures")
):
"""
Investigation complète pour une entité donnée
- **entity_type**: Type d'entité (ip, ja4, user_agent, client_header, host, path, query_param)
- **entity_value**: Valeur de l'entité
- **hours**: Fenêtre temporelle (défaut: 24h)
Retourne:
- Stats générales
- Attributs associés (IPs, JA4, Hosts, ASNs, Pays)
- User-Agents
- Client-Headers
- Paths
- Query-Params
"""
# Valider le type d'entité
if entity_type not in VALID_ENTITY_TYPES:
raise HTTPException(
status_code=400,
detail=f"Type d'entité invalide. Types supportés: {', '.join(VALID_ENTITY_TYPES)}"
)
# Stats générales
stats = get_entity_stats(entity_type, entity_value, hours)
if not stats:
raise HTTPException(status_code=404, detail="Entité non trouvée")
# Attributs associés
related = get_related_attributes(entity_type, entity_value, hours)
# User-Agents
user_agents = get_array_values(entity_type, entity_value, 'user_agents', hours)
# Client-Headers
client_headers = get_array_values(entity_type, entity_value, 'client_headers', hours)
# Paths
paths = get_array_values(entity_type, entity_value, 'paths', hours)
# Query-Params
query_params = get_array_values(entity_type, entity_value, 'query_params', hours)
return EntityInvestigation(
stats=stats,
related=related,
user_agents=user_agents,
client_headers=client_headers,
paths=paths,
query_params=query_params
)
@router.get("/{entity_type}/{entity_value:path}/related")
async def get_entity_related(
entity_type: str,
entity_value: str,
hours: int = Query(default=24, ge=1, le=720)
):
"""
Récupère uniquement les attributs associés à une entité
"""
if entity_type not in VALID_ENTITY_TYPES:
raise HTTPException(
status_code=400,
detail=f"Type d'entité invalide. Types supportés: {', '.join(VALID_ENTITY_TYPES)}"
)
related = get_related_attributes(entity_type, entity_value, hours)
return {
"entity_type": entity_type,
"entity_value": entity_value,
"hours": hours,
"related": related
}
@router.get("/{entity_type}/{entity_value:path}/user_agents")
async def get_entity_user_agents(
entity_type: str,
entity_value: str,
hours: int = Query(default=24, ge=1, le=720)
):
"""
Récupère les User-Agents associés à une entité
"""
if entity_type not in VALID_ENTITY_TYPES:
raise HTTPException(status_code=400, detail="Type d'entité invalide")
user_agents = get_array_values(entity_type, entity_value, 'user_agents', hours)
return {
"entity_type": entity_type,
"entity_value": entity_value,
"user_agents": user_agents,
"total": len(user_agents)
}
@router.get("/{entity_type}/{entity_value:path}/client_headers")
async def get_entity_client_headers(
entity_type: str,
entity_value: str,
hours: int = Query(default=24, ge=1, le=720)
):
"""
Récupère les Client-Headers associés à une entité
"""
if entity_type not in VALID_ENTITY_TYPES:
raise HTTPException(status_code=400, detail="Type d'entité invalide")
client_headers = get_array_values(entity_type, entity_value, 'client_headers', hours)
return {
"entity_type": entity_type,
"entity_value": entity_value,
"client_headers": client_headers,
"total": len(client_headers)
}
@router.get("/{entity_type}/{entity_value:path}/paths")
async def get_entity_paths(
entity_type: str,
entity_value: str,
hours: int = Query(default=24, ge=1, le=720)
):
"""
Récupère les Paths associés à une entité
"""
if entity_type not in VALID_ENTITY_TYPES:
raise HTTPException(status_code=400, detail="Type d'entité invalide")
paths = get_array_values(entity_type, entity_value, 'paths', hours)
return {
"entity_type": entity_type,
"entity_value": entity_value,
"paths": paths,
"total": len(paths)
}
@router.get("/{entity_type}/{entity_value:path}/query_params")
async def get_entity_query_params(
entity_type: str,
entity_value: str,
hours: int = Query(default=24, ge=1, le=720)
):
"""
Récupère les Query-Params associés à une entité
"""
if entity_type not in VALID_ENTITY_TYPES:
raise HTTPException(status_code=400, detail="Type d'entité invalide")
query_params = get_array_values(entity_type, entity_value, 'query_params', hours)
return {
"entity_type": entity_type,
"entity_value": entity_value,
"query_params": query_params,
"total": len(query_params)
}

View File

@ -1,829 +0,0 @@
"""
Endpoints pour l'analyse des fingerprints JA4 et User-Agents
Objectifs:
- Détecter le spoofing JA4 (fingerprint TLS qui prétend être un navigateur mais
dont les User-Agents, les headers HTTP ou les métriques comportementales trahissent
une origine bot/script)
- Construire une matrice JA4 × User-Agent pour visualiser les associations suspectes
- Analyser la distribution des User-Agents pour identifier les rotateurs et les bots
qui usurpent des UA de navigateurs légitimes
"""
from fastapi import APIRouter, HTTPException, Query
import re
from ..database import db
from ..config import settings
router = APIRouter(prefix="/api/fingerprints", tags=["fingerprints"])
# ─── Helpers ──────────────────────────────────────────────────────────────────
# Patterns indiquant clairement un bot/script sans simulation de navigateur
_BOT_PATTERNS = re.compile(
r"bot|crawler|spider|scraper|python|curl|wget|go-http|java/|axios|"
r"libwww|httpclient|okhttp|requests|aiohttp|httpx|playwright|puppeteer|"
r"selenium|headless|phantomjs",
re.IGNORECASE,
)
# Navigateurs légitimes communs — un JA4 de type "browser" devrait venir avec ces UAs
_BROWSER_PATTERNS = re.compile(
r"mozilla|chrome|safari|firefox|edge|opera|trident",
re.IGNORECASE,
)
def _classify_ua(ua: str) -> str:
"""Retourne 'bot', 'browser', ou 'script'"""
if not ua:
return "empty"
if _BOT_PATTERNS.search(ua):
return "bot"
if _BROWSER_PATTERNS.search(ua):
return "browser"
return "script"
# =============================================================================
# ENDPOINT 1 — Détection de spoofing JA4
# =============================================================================
@router.get("/spoofing")
async def get_ja4_spoofing(
hours: int = Query(24, ge=1, le=168, description="Fenêtre temporelle"),
min_detections: int = Query(10, ge=1, description="Nombre minimum de détections"),
limit: int = Query(50, ge=1, le=200),
):
"""
Identifie les JA4 fingerprints suspects de spoofing navigateur.
Un JA4 est considéré suspect quand:
- Il présente un taux élevé de ua_ch_mismatch (header UA ≠ Client Hints)
- Son modern_browser_score est élevé mais les UAs associés sont des bots/scripts
- Il apparaît avec un taux élevé de sni_host_mismatch ou alpn_http_mismatch
- is_rare_ja4 = true avec un volume important
Retourne un score de confiance de spoofing [0-100] pour chaque JA4.
"""
try:
# Agrégation par JA4 avec tous les indicateurs de spoofing
query = f"""
SELECT
ja4,
count() AS total_detections,
uniq(src_ip) AS unique_ips,
-- Indicateurs de mismatch
countIf(ua_ch_mismatch = true) AS ua_ch_mismatch_count,
round(countIf(ua_ch_mismatch = true) * 100.0 / count(), 2) AS ua_ch_mismatch_pct,
countIf(sni_host_mismatch = true) AS sni_mismatch_count,
round(countIf(sni_host_mismatch = true) * 100.0 / count(), 2) AS sni_mismatch_pct,
countIf(alpn_http_mismatch = true) AS alpn_mismatch_count,
round(countIf(alpn_http_mismatch = true) * 100.0 / count(), 2) AS alpn_mismatch_pct,
-- Indicateurs comportementaux
avg(modern_browser_score) AS avg_browser_score,
countIf(is_rare_ja4 = true) AS rare_ja4_count,
round(countIf(is_rare_ja4 = true) * 100.0 / count(), 2) AS rare_ja4_pct,
countIf(is_ua_rotating = true) AS ua_rotating_count,
round(countIf(is_ua_rotating = true) * 100.0 / count(), 2) AS ua_rotating_pct,
-- Métriques TLS/TCP
countIf(is_alpn_missing = true) AS alpn_missing_count,
avg(distinct_ja4_count) AS avg_distinct_ja4_per_ip,
-- Répartition threat levels
countIf(threat_level = 'CRITICAL') AS critical_count,
countIf(threat_level = 'HIGH') AS high_count,
-- Botnet indicators
avg(ja4_asn_concentration) AS avg_asn_concentration,
avg(ja4_country_concentration) AS avg_country_concentration,
argMax(threat_level, detected_at) AS last_threat_level
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE detected_at >= now() - INTERVAL %(hours)s HOUR
AND ja4 != '' AND ja4 IS NOT NULL
GROUP BY ja4
HAVING total_detections >= %(min_detections)s
ORDER BY ua_ch_mismatch_pct DESC, total_detections DESC
LIMIT %(limit)s
"""
result = db.query(query, {
"hours": hours,
"min_detections": min_detections,
"limit": limit,
})
# Fetch top UA per JA4 from view_dashboard_user_agents
ja4_list = [str(r[0]) for r in result.result_rows if r[0]]
ua_by_ja4: dict = {}
if ja4_list:
ja4_sql = ", ".join(f"'{j}'" for j in ja4_list[:100])
ua_q = f"""
SELECT ja4, groupArray(5)(ua) AS top_uas
FROM (
SELECT ja4, arrayJoin(user_agents) AS ua, sum(requests) AS cnt
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_user_agents
WHERE ja4 IN ({ja4_sql})
AND hour >= now() - INTERVAL {hours} HOUR
AND ua != ''
GROUP BY ja4, ua
ORDER BY ja4, cnt DESC
)
GROUP BY ja4
"""
try:
ua_res = db.query(ua_q)
for ua_row in ua_res.result_rows:
j4 = str(ua_row[0])
if ua_row[1]:
ua_by_ja4[j4] = list(ua_row[1])
except Exception:
pass
items = []
for row in result.result_rows:
ja4 = str(row[0])
ua_ch_mismatch_pct = float(row[4] or 0)
sni_mismatch_pct = float(row[6] or 0)
alpn_mismatch_pct = float(row[8] or 0)
avg_browser_score = float(row[9] or 0)
rare_ja4_pct = float(row[11] or 0)
ua_rotating_pct = float(row[13] or 0)
alpn_missing_count = int(row[14] or 0)
total = int(row[1] or 1)
top_uas = ua_by_ja4.get(ja4, [])
ua_classes = [_classify_ua(u) for u in top_uas]
has_bot_ua = any(c == "bot" for c in ua_classes)
has_browser_ua = any(c == "browser" for c in ua_classes)
# Spoofing confidence score [0-100]:
# UA/CH mismatch est le signal le plus fort (poids 40)
# Browser UA avec score navigateur élevé mais indicateurs bot (poids 25)
# SNI/ALPN mismatches (poids 15)
# is_rare_ja4 avec gros volume (poids 10)
# UA rotating (poids 10)
spoof_score = min(100, round(
ua_ch_mismatch_pct * 0.40
+ (avg_browser_score * 25 / 100 if has_bot_ua else 0)
+ sni_mismatch_pct * 0.10
+ alpn_mismatch_pct * 0.05
+ rare_ja4_pct * 0.10
+ ua_rotating_pct * 0.10
+ (10 if alpn_missing_count > total * 0.3 else 0)
))
# Classification du JA4
if spoof_score >= 60:
classification = "spoofed_browser"
elif has_bot_ua and avg_browser_score < 30:
classification = "known_bot"
elif has_browser_ua and ua_ch_mismatch_pct < 10:
classification = "legitimate_browser"
else:
classification = "suspicious"
items.append({
"ja4": ja4,
"classification": classification,
"spoofing_score": spoof_score,
"total_detections": int(row[1] or 0),
"unique_ips": int(row[2] or 0),
"indicators": {
"ua_ch_mismatch_pct": ua_ch_mismatch_pct,
"sni_mismatch_pct": sni_mismatch_pct,
"alpn_mismatch_pct": alpn_mismatch_pct,
"avg_browser_score": round(avg_browser_score, 1),
"rare_ja4_pct": rare_ja4_pct,
"ua_rotating_pct": ua_rotating_pct,
"alpn_missing_count": alpn_missing_count,
"avg_asn_concentration": round(float(row[18] or 0), 3),
"avg_country_concentration": round(float(row[19] or 0), 3),
},
"top_user_agents": [
{"ua": u, "type": _classify_ua(u)} for u in top_uas
],
"threat_breakdown": {
"critical": int(row[16] or 0),
"high": int(row[17] or 0),
"last_level": str(row[20] or "LOW"),
},
})
# Trier: spoofed_browser d'abord, puis par score
items.sort(key=lambda x: (-x["spoofing_score"], -x["total_detections"]))
return {
"items": items,
"total": len(items),
"period_hours": hours,
"summary": {
"spoofed_browser": sum(1 for i in items if i["classification"] == "spoofed_browser"),
"known_bot": sum(1 for i in items if i["classification"] == "known_bot"),
"suspicious": sum(1 for i in items if i["classification"] == "suspicious"),
"legitimate_browser": sum(1 for i in items if i["classification"] == "legitimate_browser"),
},
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
# =============================================================================
# ENDPOINT 2 — Matrice JA4 × User-Agent
# =============================================================================
@router.get("/ja4-ua-matrix")
async def get_ja4_ua_matrix(
hours: int = Query(24, ge=1, le=168),
min_ips: int = Query(3, ge=1, description="Nombre minimum d'IPs pour inclure un JA4"),
limit: int = Query(30, ge=1, le=100),
):
"""
Matrice JA4 × User-Agent.
Pour chaque JA4:
- Top User-Agents associés (depuis view_dashboard_entities)
- Taux de ua_ch_mismatch
- Classification UA (bot / browser / script)
- Indicateur de spoofing si browser_score élevé + UA non-navigateur
"""
try:
# Stats JA4 depuis ml_detected_anomalies
stats_query = f"""
SELECT
ja4,
uniq(src_ip) AS unique_ips,
count() AS total_detections,
round(countIf(ua_ch_mismatch = true) * 100.0 / count(), 2) AS ua_ch_mismatch_pct,
avg(modern_browser_score) AS avg_browser_score,
countIf(is_rare_ja4 = true) AS rare_count,
countIf(is_ua_rotating = true) AS rotating_count,
argMax(threat_level, detected_at) AS last_threat
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE detected_at >= now() - INTERVAL %(hours)s HOUR
AND ja4 != '' AND ja4 IS NOT NULL
GROUP BY ja4
HAVING unique_ips >= %(min_ips)s
ORDER BY ua_ch_mismatch_pct DESC, unique_ips DESC
LIMIT %(limit)s
"""
stats_res = db.query(stats_query, {"hours": hours, "min_ips": min_ips, "limit": limit})
ja4_list = [str(r[0]) for r in stats_res.result_rows]
if not ja4_list:
return {"items": [], "total": 0, "period_hours": hours}
# UAs par JA4 depuis view_dashboard_user_agents
ja4_sql = ", ".join(f"'{j}'" for j in ja4_list)
ua_query = f"""
SELECT
ja4,
ua,
sum(requests) AS cnt
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_user_agents
ARRAY JOIN user_agents AS ua
WHERE ja4 IN ({ja4_sql})
AND hour >= now() - INTERVAL {hours} HOUR
AND ua != ''
GROUP BY ja4, ua
ORDER BY ja4, cnt DESC
"""
ua_by_ja4: dict = {}
try:
ua_res = db.query(ua_query)
for row in ua_res.result_rows:
j4 = str(row[0])
if j4 not in ua_by_ja4:
ua_by_ja4[j4] = []
if len(ua_by_ja4[j4]) < 8:
ua_by_ja4[j4].append({"ua": str(row[1]), "count": int(row[2] or 0)})
except Exception:
pass
items = []
for row in stats_res.result_rows:
ja4 = str(row[0])
unique_ips = int(row[1] or 0)
ua_ch_mismatch_pct = float(row[3] or 0)
avg_browser_score = float(row[4] or 0)
top_uas = ua_by_ja4.get(ja4, [])
ua_total = sum(u["count"] for u in top_uas) or 1
classified_uas = []
for u in top_uas:
ua_type = _classify_ua(u["ua"])
classified_uas.append({
"ua": u["ua"],
"count": u["count"],
"pct": round(u["count"] * 100 / ua_total, 1),
"type": ua_type,
})
bot_pct = sum(u["pct"] for u in classified_uas if u["type"] == "bot")
browser_pct = sum(u["pct"] for u in classified_uas if u["type"] == "browser")
# Spoofing flag: JA4 ressemble à un navigateur (browser_score élevé)
# mais les UAs sont des bots/scripts
is_spoofing = avg_browser_score > 50 and bot_pct > 30 and ua_ch_mismatch_pct > 20
items.append({
"ja4": ja4,
"unique_ips": unique_ips,
"total_detections": int(row[2] or 0),
"ua_ch_mismatch_pct": ua_ch_mismatch_pct,
"avg_browser_score": round(avg_browser_score, 1),
"rare_count": int(row[5] or 0),
"rotating_count": int(row[6] or 0),
"last_threat": str(row[7] or "LOW"),
"user_agents": classified_uas,
"ua_summary": {
"bot_pct": round(bot_pct, 1),
"browser_pct": round(browser_pct, 1),
"script_pct": round(100 - bot_pct - browser_pct, 1),
"total_distinct": len(top_uas),
},
"is_spoofing_suspect": is_spoofing,
})
return {
"items": items,
"total": len(items),
"period_hours": hours,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
# =============================================================================
# ENDPOINT 3 — Analyse globale des User-Agents
# =============================================================================
@router.get("/ua-analysis")
async def get_ua_analysis(
hours: int = Query(24, ge=1, le=168),
limit: int = Query(50, ge=1, le=200),
):
"""
Analyse globale des User-Agents dans les détections.
Identifie:
- UAs de type bot/script
- UAs browser légitimes vs UAs browser utilisés par des bots (via ua_ch_mismatch)
- UAs rares/suspects qui tournent (is_ua_rotating)
- Distribution JA4 par UA pour détecter les UAs multi-fingerprints (rotation)
"""
try:
# Top UAs globaux depuis view_dashboard_user_agents
ua_global_query = """
SELECT
ua,
sum(requests) AS ip_count
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_user_agents
ARRAY JOIN user_agents AS ua
WHERE hour >= now() - INTERVAL %(hours)s HOUR
AND ua != ''
GROUP BY ua
ORDER BY ip_count DESC
LIMIT %(limit)s
"""
ua_global_res = db.query(ua_global_query, {"hours": hours, "limit": limit})
top_uas = [str(r[0]) for r in ua_global_res.result_rows]
# Pour chaque UA, chercher ses JA4 via view_dashboard_user_agents
ua_sql = ", ".join(f"'{u.replace(chr(39), chr(39)*2)}'" for u in top_uas[:50]) if top_uas else "''"
ja4_per_ua_query = f"""
SELECT
ua,
uniq(ja4) AS unique_ja4s,
groupUniqArray(3)(ja4) AS sample_ja4s
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_user_agents
ARRAY JOIN user_agents AS ua
WHERE ua IN ({ua_sql})
AND hour >= now() - INTERVAL {hours} HOUR
AND ua != ''
AND ja4 != ''
GROUP BY ua
"""
ja4_by_ua: dict = {}
try:
ja4_res = db.query(ja4_per_ua_query)
for r in ja4_res.result_rows:
ja4_by_ua[str(r[0])] = {
"unique_ja4s": int(r[1] or 0),
"sample_ja4s": list(r[2] or []),
}
except Exception:
pass
# IPs avec is_ua_rotating depuis ml_detected_anomalies
rotating_query = f"""
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS clean_ip,
avg(ua_ch_mismatch) AS avg_ua_ch_mismatch
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE detected_at >= now() - INTERVAL %(hours)s HOUR
AND is_ua_rotating = true
GROUP BY clean_ip
ORDER BY avg_ua_ch_mismatch DESC
"""
rotating_ips: list = []
try:
rot_res = db.query(rotating_query, {"hours": hours})
rotating_ips = [str(r[0]) for r in rot_res.result_rows]
except Exception:
pass
# Construire la réponse
items = []
for row in ua_global_res.result_rows:
ua = str(row[0])
ip_count = int(row[1] or 0)
ua_type = _classify_ua(ua)
ja4_info = ja4_by_ua.get(ua, {"unique_ja4s": 0, "sample_ja4s": []})
# UA multi-JA4 est suspect: un vrai navigateur a généralement 1-2 JA4
multi_ja4_flag = ja4_info["unique_ja4s"] > 3
items.append({
"user_agent": ua,
"type": ua_type,
"ip_count": ip_count,
"unique_ja4_count": ja4_info["unique_ja4s"],
"sample_ja4s": ja4_info["sample_ja4s"],
"is_multi_ja4_suspect": multi_ja4_flag,
"risk_flags": _build_ua_risk_flags(ua, ua_type, ja4_info["unique_ja4s"], ip_count),
})
# IPs avec rotation d'UA
ua_rotating_stats = {
"rotating_ip_count": len(rotating_ips),
"sample_rotating_ips": rotating_ips[:10],
}
return {
"items": items,
"total": len(items),
"period_hours": hours,
"ua_rotating_stats": ua_rotating_stats,
"summary": {
"bot_count": sum(1 for i in items if i["type"] == "bot"),
"browser_count": sum(1 for i in items if i["type"] == "browser"),
"script_count": sum(1 for i in items if i["type"] == "script"),
"multi_ja4_suspect_count": sum(1 for i in items if i["is_multi_ja4_suspect"]),
},
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
def _build_ua_risk_flags(ua: str, ua_type: str, unique_ja4s: int, ip_count: int) -> list:
"""Construit la liste des indicateurs de risque pour un User-Agent."""
flags = []
if ua_type == "bot":
flags.append("ua_bot_signature")
elif ua_type == "script":
flags.append("ua_script_library")
if unique_ja4s > 5:
flags.append("ja4_rotation_suspect")
if unique_ja4s > 3 and ua_type == "browser":
flags.append("browser_ua_multi_fingerprint")
if ip_count > 100:
flags.append("high_volume")
return flags
# =============================================================================
# ENDPOINT 4 — JA4 d'un IP spécifique: analyse de cohérence UA/JA4
# =============================================================================
@router.get("/ip/{ip}/coherence")
async def get_ip_fingerprint_coherence(ip: str):
"""
Analyse la cohérence JA4/UA pour une IP spécifique.
Répond à la question: "Cette IP spoofait-elle son fingerprint?"
Calcule un score de cohérence basé sur:
- Correspondance entre JA4 (TLS client fingerprint) et User-Agent
- ua_ch_mismatch (User-Agent vs Client Hints)
- modern_browser_score vs type d'UA réel
- Nombre de JA4 distincts utilisés (rotation)
- sni_host_mismatch, alpn_http_mismatch
"""
try:
# Données depuis ml_detected_anomalies
ml_query = f"""
SELECT
ja4,
ua_ch_mismatch,
modern_browser_score,
sni_host_mismatch,
alpn_http_mismatch,
is_alpn_missing,
is_rare_ja4,
is_ua_rotating,
distinct_ja4_count,
header_count,
has_accept_language,
has_cookie,
has_referer,
header_order_shared_count,
detected_at,
threat_level,
window_mss_ratio,
tcp_jitter_variance,
multiplexing_efficiency
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE src_ip = %(ip)s
ORDER BY detected_at DESC
"""
ml_res = db.query(ml_query, {"ip": ip})
if not ml_res.result_rows:
raise HTTPException(status_code=404, detail="IP non trouvée dans les détections")
# User-agents réels depuis view_dashboard_user_agents
ua_query = """
SELECT ua, sum(requests) AS cnt
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_user_agents
ARRAY JOIN user_agents AS ua
WHERE toString(src_ip) = %(ip)s
AND hour >= now() - INTERVAL 72 HOUR
AND ua != ''
GROUP BY ua ORDER BY cnt DESC
"""
ua_res = db.query(ua_query, {"ip": ip})
top_uas = [{"ua": str(r[0]), "count": int(r[1] or 0), "type": _classify_ua(str(r[0]))}
for r in ua_res.result_rows]
# Agréger les indicateurs de la dernière session
rows = ml_res.result_rows
latest = rows[0]
total_rows = len(rows)
ua_ch_mismatch_count = sum(1 for r in rows if r[1])
sni_mismatch_count = sum(1 for r in rows if r[3])
alpn_mismatch_count = sum(1 for r in rows if r[4])
is_rare_count = sum(1 for r in rows if r[6])
is_rotating = any(r[7] for r in rows)
distinct_ja4s = {str(r[0]) for r in rows if r[0]}
avg_browser_score = sum(int(r[2] or 0) for r in rows) / total_rows
# UA analysis
has_browser_ua = any(u["type"] == "browser" for u in top_uas)
has_bot_ua = any(u["type"] == "bot" for u in top_uas)
primary_ua_type = top_uas[0]["type"] if top_uas else "empty"
# Calcul du score de spoofing
spoof_score = min(100, round(
(ua_ch_mismatch_count / total_rows * 100) * 0.40
+ (avg_browser_score * 0.20 if has_bot_ua else 0)
+ (sni_mismatch_count / total_rows * 100) * 0.10
+ (alpn_mismatch_count / total_rows * 100) * 0.05
+ (len(distinct_ja4s) * 5 if len(distinct_ja4s) > 2 else 0)
+ (15 if is_rotating else 0)
+ (10 if is_rare_count > total_rows * 0.5 else 0)
))
# Verdict
if spoof_score >= 70:
verdict = "high_confidence_spoofing"
elif spoof_score >= 40:
verdict = "suspicious_spoofing"
elif has_bot_ua and avg_browser_score < 20:
verdict = "known_bot_no_spoofing"
elif has_browser_ua and spoof_score < 20:
verdict = "legitimate_browser"
else:
verdict = "inconclusive"
# Explication humaine
explanation = []
if ua_ch_mismatch_count > total_rows * 0.3:
explanation.append(f"UA-Client-Hints mismatch sur {round(ua_ch_mismatch_count*100/total_rows)}% des requêtes")
if has_bot_ua and avg_browser_score > 40:
explanation.append(f"JA4 ressemble à un navigateur (score {round(avg_browser_score)}/100) mais UA est de type bot")
if len(distinct_ja4s) > 2:
explanation.append(f"{len(distinct_ja4s)} JA4 distincts utilisés → rotation de fingerprint")
if is_rotating:
explanation.append("is_ua_rotating détecté → rotation d'User-Agent confirmée")
if sni_mismatch_count > 0:
explanation.append(f"SNI ≠ Host header sur {sni_mismatch_count}/{total_rows} requêtes")
if not explanation:
explanation.append("Aucun indicateur de spoofing majeur détecté")
return {
"ip": ip,
"verdict": verdict,
"spoofing_score": spoof_score,
"explanation": explanation,
"indicators": {
"ua_ch_mismatch_rate": round(ua_ch_mismatch_count / total_rows * 100, 1),
"sni_mismatch_rate": round(sni_mismatch_count / total_rows * 100, 1),
"alpn_mismatch_rate": round(alpn_mismatch_count / total_rows * 100, 1),
"avg_browser_score": round(avg_browser_score, 1),
"distinct_ja4_count": len(distinct_ja4s),
"is_ua_rotating": is_rotating,
"rare_ja4_rate": round(is_rare_count / total_rows * 100, 1),
},
"fingerprints": {
"ja4_list": list(distinct_ja4s),
"latest_ja4": str(latest[0] or ""),
},
"user_agents": top_uas,
"latest_detection": {
"detected_at": latest[14].isoformat() if latest[14] else "",
"threat_level": str(latest[15] or "LOW"),
"modern_browser_score": int(latest[2] or 0),
"header_count": int(latest[9] or 0),
"has_accept_language": bool(latest[10]),
"has_cookie": bool(latest[11]),
"has_referer": bool(latest[12]),
"header_order_shared_count": int(latest[13] or 0),
},
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
# =============================================================================
# ENDPOINT 5 — JA4 légitimes (baseline / whitelist)
# =============================================================================
@router.get("/legitimate-ja4")
async def get_legitimate_ja4(
hours: int = Query(168, ge=24, le=720, description="Fenêtre pour établir la baseline"),
min_ips: int = Query(50, ge=5, description="Nombre minimum d'IPs pour qualifier un JA4 de légitime"),
):
"""
Établit une baseline des JA4 fingerprints légitimes.
Un JA4 est considéré légitime si:
- Il est utilisé par un grand nombre d'IPs distinctes (> min_ips)
- Son taux de ua_ch_mismatch est faible (< 5%)
- Son modern_browser_score est élevé (> 60)
- Il n'est PAS is_rare_ja4
- Ses UAs sont dominés par des navigateurs connus
Utile comme whitelist pour réduire les faux positifs.
"""
try:
query = f"""
SELECT
ja4,
uniq(src_ip) AS unique_ips,
count() AS total_detections,
round(countIf(ua_ch_mismatch = true) * 100.0 / count(), 2) AS ua_ch_mismatch_pct,
avg(modern_browser_score) AS avg_browser_score,
countIf(is_rare_ja4 = true) AS rare_count,
round(countIf(threat_level = 'CRITICAL') * 100.0 / count(), 2) AS critical_pct,
round(countIf(threat_level = 'HIGH') * 100.0 / count(), 2) AS high_pct
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE detected_at >= now() - INTERVAL %(hours)s HOUR
AND ja4 != '' AND ja4 IS NOT NULL
GROUP BY ja4
HAVING unique_ips >= %(min_ips)s
AND ua_ch_mismatch_pct < 5.0
AND avg_browser_score > 60
AND rare_count = 0
ORDER BY unique_ips DESC
"""
result = db.query(query, {"hours": hours, "min_ips": min_ips})
items = [
{
"ja4": str(row[0]),
"unique_ips": int(row[1] or 0),
"total_detections": int(row[2] or 0),
"ua_ch_mismatch_pct": float(row[3] or 0),
"avg_browser_score": round(float(row[4] or 0), 1),
"critical_pct": float(row[6] or 0),
"high_pct": float(row[7] or 0),
"legitimacy_confidence": min(100, round(
(1 - float(row[3] or 0) / 100) * 40
+ float(row[4] or 0) * 0.40
+ min(int(row[1] or 0) / min_ips, 1) * 20
)),
}
for row in result.result_rows
]
return {
"items": items,
"total": len(items),
"period_hours": hours,
"note": "Ces JA4 sont candidats à une whitelist. Vérifier manuellement avant de whitelister.",
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
# =============================================================================
# ENDPOINT — Corrélation JA4 × ASN / Pays (C5)
# Détecte les JA4 fortement concentrés sur un seul ASN ou pays
# → signal de botnet ciblé ou d'infrastructure de test/attaque partagée
# =============================================================================
@router.get("/asn-correlation")
async def get_ja4_asn_correlation(
min_concentration: float = Query(0.7, ge=0.0, le=1.0, description="Seuil min de concentration ASN ou pays"),
min_ips: int = Query(5, ge=1, description="Nombre minimum d'IPs par JA4"),
limit: int = Query(50, ge=1, le=200),
):
"""
Identifie les JA4 fingerprints fortement concentrés sur un seul ASN ou pays.
Un JA4 avec asn_concentration ≥ 0.7 signifie que ≥70% des IPs utilisant ce fingerprint
proviennent du même ASN → infrastructure de bot partagée ou datacenter suspect.
"""
try:
# Two-pass: first aggregate per (ja4, asn) to get IP counts per ASN,
# then aggregate per ja4 to compute concentration ratio
sql = f"""
SELECT
ja4,
sum(ips_per_combo) AS unique_ips,
uniq(src_asn) AS unique_asns,
uniq(src_country_code) AS unique_countries,
toString(argMax(src_asn, ips_per_combo)) AS top_asn_number,
argMax(asn_name, ips_per_combo) AS top_asn_name,
argMax(src_country_code, country_ips) AS dominant_country,
sum(total_hits) AS total_hits,
round(max(ips_per_combo) / greatest(sum(ips_per_combo), 1), 3) AS asn_concentration,
round(max(country_ips) / greatest(sum(ips_per_combo), 1), 3) AS country_concentration
FROM (
SELECT
ja4,
src_asn,
src_country_code,
any(src_as_name) AS asn_name,
uniq(src_ip) AS ips_per_combo,
uniq(src_ip) AS country_ips,
sum(hits) AS total_hits
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h
WHERE window_start >= now() - INTERVAL 24 HOUR
AND ja4 != ''
GROUP BY ja4, src_asn, src_country_code
)
GROUP BY ja4
HAVING unique_ips >= %(min_ips)s
AND (asn_concentration >= %(min_conc)s OR country_concentration >= %(min_conc)s)
ORDER BY asn_concentration DESC, unique_ips DESC
LIMIT %(limit)s
"""
result = db.query(sql, {"min_ips": min_ips, "min_conc": min_concentration, "limit": limit})
items = []
for row in result.result_rows:
ja4 = str(row[0])
unique_ips = int(row[1])
unique_asns = int(row[2])
unique_countries = int(row[3])
top_asn_number = str(row[4] or "")
top_asn_name = str(row[5] or "")
dominant_country = str(row[6] or "")
total_hits = int(row[7] or 0)
asn_concentration = float(row[8] or 0)
country_concentration = float(row[9] or 0)
if asn_concentration >= 0.85:
corr_type, risk = "asn_monopoly", "high"
elif asn_concentration >= min_concentration:
corr_type, risk = "asn_dominant", "medium"
elif country_concentration >= min_concentration:
corr_type, risk = "geo_targeted", "medium"
else:
corr_type, risk = "distributed", "low"
items.append({
"ja4": ja4,
"unique_ips": unique_ips,
"unique_asns": unique_asns,
"unique_countries": unique_countries,
"top_asn_name": top_asn_name,
"top_asn_number": top_asn_number,
"dominant_country": dominant_country,
"total_hits": total_hits,
"asn_concentration": asn_concentration,
"country_concentration":country_concentration,
"correlation_type": corr_type,
"risk": risk,
})
return {"items": items, "total": len(items)}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")

View File

@ -1,102 +0,0 @@
"""
Endpoints pour l'analyse des empreintes d'en-têtes HTTP
"""
from fastapi import APIRouter, HTTPException, Query
from ..database import db
from ..config import settings
router = APIRouter(prefix="/api/headers", tags=["header_fingerprint"])
@router.get("/clusters")
async def get_header_clusters(limit: int = Query(50, ge=1, le=200)):
"""Clusters d'empreintes d'en-têtes groupés par header_order_hash."""
try:
sql = f"""
SELECT
header_order_hash AS hash,
uniq(replaceRegexpAll(toString(src_ip), '^::ffff:', '')) AS unique_ips,
avg(modern_browser_score) AS avg_browser_score,
sum(ua_ch_mismatch) AS ua_ch_mismatch_count,
round(sum(ua_ch_mismatch) * 100.0 / count(), 2) AS ua_ch_mismatch_pct,
groupArray(5)(sec_fetch_mode) AS top_sec_fetch_modes,
round(sum(has_cookie) * 100.0 / count(), 2) AS has_cookie_pct,
round(sum(has_referer) * 100.0 / count(), 2) AS has_referer_pct
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_header_fingerprint_1h
GROUP BY header_order_hash
ORDER BY unique_ips DESC
LIMIT %(limit)s
"""
result = db.query(sql, {"limit": limit})
total_sql = f"""
SELECT uniq(header_order_hash)
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_header_fingerprint_1h
"""
total_clusters = int(db.query(total_sql).result_rows[0][0])
clusters = []
for row in result.result_rows:
h = str(row[0])
unique_ips = int(row[1])
avg_browser_score = float(row[2] or 0)
ua_ch_mismatch_cnt = int(row[3])
ua_ch_mismatch_pct = float(row[4] or 0)
top_modes = list(set(str(m) for m in (row[5] or [])))
has_cookie_pct = float(row[6] or 0)
has_referer_pct = float(row[7] or 0)
if avg_browser_score >= 90 and ua_ch_mismatch_pct < 5:
classification = "legitimate"
elif ua_ch_mismatch_pct > 50:
classification = "bot_suspicious"
else:
classification = "mixed"
clusters.append({
"hash": h,
"unique_ips": unique_ips,
"avg_browser_score": round(avg_browser_score, 2),
"ua_ch_mismatch_count":ua_ch_mismatch_cnt,
"ua_ch_mismatch_pct": ua_ch_mismatch_pct,
"top_sec_fetch_modes": top_modes,
"has_cookie_pct": has_cookie_pct,
"has_referer_pct": has_referer_pct,
"classification": classification,
})
return {"clusters": clusters, "total_clusters": total_clusters}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/cluster/{hash}/ips")
async def get_cluster_ips(hash: str, limit: int = Query(50, ge=1, le=500)):
"""Liste des IPs appartenant à un cluster d'en-têtes donné."""
try:
sql = f"""
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
any(modern_browser_score) AS browser_score,
any(ua_ch_mismatch) AS ua_ch_mismatch,
any(sec_fetch_mode) AS sec_fetch_mode,
any(sec_fetch_dest) AS sec_fetch_dest
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_header_fingerprint_1h
WHERE header_order_hash = %(hash)s
GROUP BY src_ip
ORDER BY browser_score DESC
LIMIT %(limit)s
"""
result = db.query(sql, {"hash": hash, "limit": limit})
items = []
for row in result.result_rows:
items.append({
"ip": str(row[0]),
"browser_score": int(row[1] or 0),
"ua_ch_mismatch": int(row[2] or 0),
"sec_fetch_mode": str(row[3] or ""),
"sec_fetch_dest": str(row[4] or ""),
})
return {"items": items, "total": len(items)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -1,145 +0,0 @@
"""
Endpoints pour la heatmap temporelle (hits par heure / hôte)
"""
from collections import defaultdict
from fastapi import APIRouter, HTTPException, Query
from ..database import db
from ..config import settings
router = APIRouter(prefix="/api/heatmap", tags=["heatmap"])
@router.get("/hourly")
async def get_heatmap_hourly():
"""Hits agrégés par heure sur les 72 dernières heures."""
try:
sql = f"""
SELECT
toHour(window_start) AS hour,
sum(hits) AS hits,
uniq(replaceRegexpAll(toString(src_ip), '^::ffff:', '')) AS unique_ips,
max(max_requests_per_sec) AS max_rps
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h
WHERE window_start >= now() - INTERVAL 72 HOUR
GROUP BY hour
ORDER BY hour ASC
"""
result = db.query(sql)
hours = [
{
"hour": int(row[0]),
"hits": int(row[1]),
"unique_ips": int(row[2]),
"max_rps": int(row[3]),
}
for row in result.result_rows
]
return {"hours": hours}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/top-hosts")
async def get_heatmap_top_hosts(limit: int = Query(20, ge=1, le=100)):
"""Hôtes les plus ciblés avec répartition horaire sur 24h."""
try:
# Aggregate overall stats per host
agg_sql = f"""
SELECT
host,
sum(hits) AS total_hits,
uniq(replaceRegexpAll(toString(src_ip), '^::ffff:', '')) AS unique_ips,
uniq(ja4) AS unique_ja4s
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h
WHERE window_start >= now() - INTERVAL 72 HOUR
GROUP BY host
ORDER BY total_hits DESC
LIMIT %(limit)s
"""
agg_res = db.query(agg_sql, {"limit": limit})
top_hosts = [str(r[0]) for r in agg_res.result_rows]
host_stats = {
str(r[0]): {
"host": str(r[0]),
"total_hits": int(r[1]),
"unique_ips": int(r[2]),
"unique_ja4s":int(r[3]),
}
for r in agg_res.result_rows
}
if not top_hosts:
return {"items": []}
# Hourly breakdown per host
hourly_sql = f"""
SELECT
host,
toHour(window_start) AS hour,
sum(hits) AS hits
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h
WHERE window_start >= now() - INTERVAL 72 HOUR
AND host IN %(hosts)s
GROUP BY host, hour
"""
hourly_res = db.query(hourly_sql, {"hosts": top_hosts})
hourly_map: dict = defaultdict(lambda: [0] * 24)
for row in hourly_res.result_rows:
h = str(row[0])
hour = int(row[1])
hits = int(row[2])
hourly_map[h][hour] += hits
items = []
for host in top_hosts:
entry = dict(host_stats[host])
entry["hourly_hits"] = hourly_map[host]
items.append(entry)
return {"items": items}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/matrix")
async def get_heatmap_matrix():
"""Matrice top-15 hôtes × 24 heures (sum hits) sur les 72 dernières heures."""
try:
top_sql = f"""
SELECT host, sum(hits) AS total_hits
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h
WHERE window_start >= now() - INTERVAL 72 HOUR
GROUP BY host
ORDER BY total_hits DESC
"""
top_res = db.query(top_sql)
top_hosts = [str(r[0]) for r in top_res.result_rows]
if not top_hosts:
return {"hosts": [], "matrix": []}
cell_sql = f"""
SELECT
host,
toHour(window_start) AS hour,
sum(hits) AS hits
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h
WHERE window_start >= now() - INTERVAL 72 HOUR
AND host IN %(hosts)s
GROUP BY host, hour
"""
cell_res = db.query(cell_sql, {"hosts": top_hosts})
matrix_map: dict = defaultdict(lambda: [0] * 24)
for row in cell_res.result_rows:
h = str(row[0])
hour = int(row[1])
hits = int(row[2])
matrix_map[h][hour] += hits
matrix = [matrix_map[h] for h in top_hosts]
return {"hosts": top_hosts, "matrix": matrix}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -1,267 +0,0 @@
"""
Routes pour la gestion des incidents clusterisés
"""
import hashlib
from fastapi import APIRouter, HTTPException, Query
from typing import List, Optional
from datetime import datetime
from ..database import db
from ..config import settings
router = APIRouter(prefix="/api/incidents", tags=["incidents"])
@router.get("/clusters")
async def get_incident_clusters(
hours: int = Query(24, ge=1, le=168, description="Fenêtre temporelle en heures"),
min_severity: str = Query("LOW", description="Niveau de sévérité minimum"),
limit: int = Query(20, ge=1, le=100, description="Nombre maximum de clusters")
):
"""
Récupère les incidents clusterisés automatiquement
Les clusters sont formés par:
- Subnet /24
- JA4 fingerprint
- Pattern temporel
"""
try:
# Cluster par subnet /24 avec une IP exemple
# Note: src_ip est en IPv6, les IPv4 sont stockés comme ::ffff:x.x.x.x
# toIPv4() convertit les IPv4-mapped, IPv4NumToString() retourne l'IPv4 en notation x.x.x.x
cluster_query = f"""
WITH cleaned_ips AS (
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS clean_ip,
detected_at,
ja4,
country_code,
asn_number,
threat_level,
anomaly_score
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE detected_at >= now() - INTERVAL %(hours)s HOUR
),
subnet_groups AS (
SELECT
concat(
splitByChar('.', clean_ip)[1], '.',
splitByChar('.', clean_ip)[2], '.',
splitByChar('.', clean_ip)[3], '.0/24'
) AS subnet,
count() AS total_detections,
uniq(clean_ip) AS unique_ips,
min(detected_at) AS first_seen,
max(detected_at) AS last_seen,
argMax(ja4, detected_at) AS ja4,
argMax(country_code, detected_at) AS country_code,
argMax(asn_number, detected_at) AS asn_number,
argMax(threat_level, detected_at) AS threat_level,
avg(anomaly_score) AS avg_score,
argMax(clean_ip, detected_at) AS sample_ip
FROM cleaned_ips
GROUP BY subnet
HAVING total_detections >= 2
)
SELECT
subnet,
total_detections,
unique_ips,
first_seen,
last_seen,
ja4,
country_code,
asn_number,
threat_level,
avg_score,
sample_ip
FROM subnet_groups
ORDER BY avg_score ASC, total_detections DESC
LIMIT %(limit)s
"""
result = db.query(cluster_query, {"hours": hours, "limit": limit})
# Collect sample IPs to fetch real UA and trend data in bulk
sample_ips = [row[10] for row in result.result_rows if row[10]]
# Fetch real primary UA per sample IP from {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_entities
ua_by_ip: dict = {}
if sample_ips:
ip_list_sql = ", ".join(f"'{ip}'" for ip in sample_ips[:50])
ua_query = f"""
SELECT entity_value, arrayElement(user_agents, 1) AS top_ua
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_entities
WHERE entity_type = 'ip'
AND entity_value IN ({ip_list_sql})
AND notEmpty(user_agents)
GROUP BY entity_value, top_ua
ORDER BY entity_value
"""
try:
ua_result = db.query(ua_query)
for ua_row in ua_result.result_rows:
if ua_row[0] not in ua_by_ip and ua_row[1]:
ua_by_ip[str(ua_row[0])] = str(ua_row[1])
except Exception:
pass # UA enrichment is best-effort
# Compute real trend: compare current window vs previous window of same duration
trend_query = f"""
WITH cleaned AS (
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS clean_ip,
detected_at,
concat(
splitByChar('.', clean_ip)[1], '.',
splitByChar('.', clean_ip)[2], '.',
splitByChar('.', clean_ip)[3], '.0/24'
) AS subnet
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
),
current_window AS (
SELECT subnet, count() AS cnt
FROM cleaned
WHERE detected_at >= now() - INTERVAL %(hours)s HOUR
GROUP BY subnet
),
prev_window AS (
SELECT subnet, count() AS cnt
FROM cleaned
WHERE detected_at >= now() - INTERVAL %(hours2)s HOUR
AND detected_at < now() - INTERVAL %(hours)s HOUR
GROUP BY subnet
)
SELECT c.subnet, c.cnt AS current_cnt, p.cnt AS prev_cnt
FROM current_window c
LEFT JOIN prev_window p ON c.subnet = p.subnet
"""
trend_by_subnet: dict = {}
try:
trend_result = db.query(trend_query, {"hours": hours, "hours2": hours * 2})
for tr in trend_result.result_rows:
subnet_key = tr[0]
curr = tr[1] or 0
prev = tr[2] or 0
if prev == 0:
trend_by_subnet[subnet_key] = ("new", 100)
else:
pct = round(((curr - prev) / prev) * 100)
trend_by_subnet[subnet_key] = ("up" if pct >= 0 else "down", abs(pct))
except Exception:
pass
clusters = []
for row in result.result_rows:
subnet = row[0]
threat_level = row[8] or 'LOW'
unique_ips = row[2] or 1
avg_score = abs(row[9] or 0)
sample_ip = row[10] if row[10] else subnet.split('/')[0]
critical_count = 1 if threat_level == 'CRITICAL' else 0
high_count = 1 if threat_level == 'HIGH' else 0
risk_score = min(100, round(
(critical_count * 30) +
(high_count * 20) +
(unique_ips * 5) +
(avg_score * 100)
))
if critical_count > 0 or risk_score >= 80:
severity = "CRITICAL"
elif high_count > (row[1] or 1) * 0.3 or risk_score >= 60:
severity = "HIGH"
elif high_count > 0 or risk_score >= 40:
severity = "MEDIUM"
else:
severity = "LOW"
trend_dir, trend_pct = trend_by_subnet.get(subnet, ("stable", 0))
primary_ua = ua_by_ip.get(sample_ip, "")
clusters.append({
"id": f"INC-{hashlib.md5(subnet.encode()).hexdigest()[:8].upper()}",
"score": risk_score,
"severity": severity,
"total_detections": row[1],
"unique_ips": row[2],
"subnet": subnet,
"sample_ip": sample_ip,
"ja4": row[5] or "",
"primary_ua": primary_ua,
"primary_target": row[3].strftime('%H:%M') if row[3] else "Unknown",
"countries": [{"code": row[6] or "XX", "percentage": 100}],
"asn": str(row[7]) if row[7] else "",
"first_seen": row[3].isoformat() if row[3] else "",
"last_seen": row[4].isoformat() if row[4] else "",
"trend": trend_dir,
"trend_percentage": trend_pct,
})
return {
"items": clusters,
"total": len(clusters),
"period_hours": hours
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
@router.get("/{cluster_id}")
async def get_incident_details(cluster_id: str):
"""
Récupère les détails d'un incident spécifique.
Non encore implémenté — les détails par cluster seront disponibles dans une prochaine version.
"""
raise HTTPException(
status_code=501,
detail="Détails par incident non encore implémentés. Utilisez /api/incidents/clusters pour la liste."
)
@router.post("/{cluster_id}/classify")
async def classify_incident(
cluster_id: str,
label: str,
tags: List[str] = None,
comment: str = ""
):
"""
Classe un incident rapidement.
Non encore implémenté — utilisez /api/analysis/{ip}/classify pour classifier une IP.
"""
raise HTTPException(
status_code=501,
detail="Classification par incident non encore implémentée. Utilisez /api/analysis/{ip}/classify."
)
@router.get("")
async def list_incidents(
status: str = Query("active", description="Statut des incidents"),
severity: Optional[str] = Query(None, description="Filtrer par sévérité (LOW/MEDIUM/HIGH/CRITICAL)"),
hours: int = Query(24, ge=1, le=168)
):
"""
Liste tous les incidents avec filtres.
Délègue à get_incident_clusters ; le filtre severity est appliqué post-requête.
"""
try:
result = await get_incident_clusters(hours=hours, limit=100)
items = result["items"]
if severity:
sev_upper = severity.upper()
items = [c for c in items if c.get("severity") == sev_upper]
return {
"items": items,
"total": len(items),
"period_hours": hours,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")

View File

@ -1,186 +0,0 @@
"""
Endpoint d'investigation enrichie pour une IP donnée.
Agrège en une seule requête les données provenant de toutes les sources :
ml_detected_anomalies, view_form_bruteforce_detected, view_tcp_spoofing_detected,
agg_host_ip_ja4_1h (rotation JA4), view_ip_recurrence, view_ai_features_1h.
"""
from fastapi import APIRouter, HTTPException
from ..database import db
from ..services.tcp_fingerprint import fingerprint_os, detect_spoof, declared_os_from_ua
from ..config import settings
router = APIRouter(prefix="/api/investigation", tags=["investigation"])
@router.get(
"/{ip}/summary",
summary="Synthèse complète d'une IP",
response_description="Score de risque 0-100, détections ML, brute-force, spoofing TCP, rotation JA4, persistance et timeline 24h",
)
async def get_ip_full_summary(ip: str):
"""
Synthèse complète pour une IP : toutes les sources en un appel.
Normalise l'IP (accepte ::ffff:x.x.x.x ou x.x.x.x).
"""
clean_ip = ip.replace("::ffff:", "").strip()
try:
# ── 1. Score ML / features ─────────────────────────────────────────────
ml_sql = f"""
SELECT
max(abs(anomaly_score)) AS max_score,
any(threat_level) AS threat_level,
any(bot_name) AS bot_name,
count() AS total_detections,
uniq(host) AS distinct_hosts,
uniq(ja4) AS distinct_ja4
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE src_ip = IPv4MappedToIPv6(toIPv4(%(ip)s))
"""
ml_res = db.query(ml_sql, {"ip": clean_ip})
ml_row = ml_res.result_rows[0] if ml_res.result_rows else None
ml_data = {
"max_score": round(float(ml_row[0] or 0), 2) if ml_row else 0,
"threat_level": str(ml_row[1] or "") if ml_row else "",
"attack_type": str(ml_row[2] or "") if ml_row else "",
"total_detections": int(ml_row[3] or 0) if ml_row else 0,
"distinct_hosts": int(ml_row[4] or 0) if ml_row else 0,
"distinct_ja4": int(ml_row[5] or 0) if ml_row else 0,
}
# ── 2. Brute force ─────────────────────────────────────────────────────
bf_sql = f"""
SELECT
uniq(host) AS hosts_attacked,
sum(hits) AS total_hits,
sum(query_params_count) AS total_params,
groupArray(3)(host) AS top_hosts
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_form_bruteforce_detected
WHERE src_ip = IPv4MappedToIPv6(toIPv4(%(ip)s))
"""
bf_res = db.query(bf_sql, {"ip": clean_ip})
bf_row = bf_res.result_rows[0] if bf_res.result_rows else None
bf_data = {
"active": bool(bf_row and int(bf_row[1] or 0) > 0),
"hosts_attacked": int(bf_row[0] or 0) if bf_row else 0,
"total_hits": int(bf_row[1] or 0) if bf_row else 0,
"total_params": int(bf_row[2] or 0) if bf_row else 0,
"top_hosts": [str(h) for h in (bf_row[3] or [])] if bf_row else [],
}
# ── 3. TCP spoofing — fingerprinting multi-signal ─────────────────────
tcp_sql = f"""
SELECT
any(tcp_ttl_raw) AS ttl,
any(tcp_win_raw) AS win,
any(tcp_scale_raw) AS scale,
any(tcp_mss_raw) AS mss,
any(first_ua) AS ua
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h
WHERE src_ip = IPv4MappedToIPv6(toIPv4(%(ip)s))
AND window_start >= now() - INTERVAL 24 HOUR
AND tcp_ttl_raw > 0
LIMIT 1
"""
tcp_res = db.query(tcp_sql, {"ip": clean_ip})
tcp_data = {"detected": False, "tcp_ttl": None, "suspected_os": None}
if tcp_res.result_rows:
r = tcp_res.result_rows[0]
ttl = int(r[0] or 0)
win = int(r[1] or 0)
scale = int(r[2] or 0)
mss = int(r[3] or 0)
ua = str(r[4] or "")
fp = fingerprint_os(ttl, win, scale, mss)
dec_os = declared_os_from_ua(ua)
spoof_res = detect_spoof(fp, dec_os)
tcp_data = {
"detected": spoof_res.is_spoof,
"tcp_ttl": ttl,
"tcp_mss": mss,
"tcp_win_scale": scale,
"initial_ttl": fp.initial_ttl,
"hop_count": fp.hop_count,
"suspected_os": fp.os_name,
"declared_os": dec_os,
"confidence": fp.confidence,
"network_path": fp.network_path,
"is_bot_tool": fp.is_bot_tool,
"spoof_reason": spoof_res.reason,
}
# ── 4. JA4 rotation ────────────────────────────────────────────────────
rot_sql = f"""
SELECT distinct_ja4_count, total_hits
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_host_ip_ja4_rotation
WHERE src_ip = IPv4MappedToIPv6(toIPv4(%(ip)s))
LIMIT 1
"""
rot_res = db.query(rot_sql, {"ip": clean_ip})
rot_data = {"rotating": False, "distinct_ja4_count": 0}
if rot_res.result_rows:
row = rot_res.result_rows[0]
cnt = int(row[0] or 0)
rot_data = {"rotating": cnt > 1, "distinct_ja4_count": cnt, "total_hits": int(row[1] or 0)}
# ── 5. Persistance ─────────────────────────────────────────────────────
pers_sql = f"""
SELECT recurrence, worst_score, worst_threat_level, first_seen, last_seen
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_ip_recurrence
WHERE src_ip = IPv4MappedToIPv6(toIPv4(%(ip)s))
LIMIT 1
"""
pers_res = db.query(pers_sql, {"ip": clean_ip})
pers_data = {"persistent": False, "recurrence": 0}
if pers_res.result_rows:
row = pers_res.result_rows[0]
pers_data = {
"persistent": True,
"recurrence": int(row[0] or 0),
"worst_score": round(float(row[1] or 0), 2),
"worst_threat_level":str(row[2] or ""),
"first_seen": str(row[3]),
"last_seen": str(row[4]),
}
# ── 6. Timeline 24h ────────────────────────────────────────────────────
tl_sql = f"""
SELECT
toHour(window_start) AS hour,
sum(hits) AS hits,
groupUniqArray(3)(ja4) AS ja4s
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h
WHERE src_ip = IPv4MappedToIPv6(toIPv4(%(ip)s))
AND window_start >= now() - INTERVAL 24 HOUR
GROUP BY hour
ORDER BY hour ASC
"""
tl_res = db.query(tl_sql, {"ip": clean_ip})
timeline = [
{"hour": int(r[0]), "hits": int(r[1]), "ja4s": [str(j) for j in (r[2] or [])]}
for r in tl_res.result_rows
]
# ── Global risk score (heuristic) ──────────────────────────────────────
risk = 0
risk += min(50, ml_data["max_score"] * 50)
if bf_data["active"]: risk += 20
if tcp_data["detected"]:
if tcp_data.get("is_bot_tool"): risk += 30 # outil de scan connu
else: risk += 15 # spoof OS
if rot_data["rotating"]: risk += min(15, rot_data["distinct_ja4_count"] * 3)
if pers_data["persistent"]: risk += min(10, pers_data["recurrence"] * 2)
risk = min(100, round(risk))
return {
"ip": clean_ip,
"risk_score": risk,
"ml": ml_data,
"bruteforce": bf_data,
"tcp_spoofing":tcp_data,
"ja4_rotation":rot_data,
"persistence": pers_data,
"timeline_24h":timeline,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -1,177 +0,0 @@
"""
Endpoints pour les métriques du dashboard
"""
from fastapi import APIRouter, HTTPException
from ..database import db
from ..models import MetricsResponse, MetricsSummary, TimeSeriesPoint
from ..config import settings
router = APIRouter(prefix="/api/metrics", tags=["metrics"])
@router.get("", response_model=MetricsResponse, summary="Métriques globales du dashboard")
async def get_metrics():
"""
Récupère les métriques globales du dashboard
"""
try:
# Résumé des métriques
summary_query = f"""
SELECT
count() AS total_detections,
countIf(threat_level = 'CRITICAL') AS critical_count,
countIf(threat_level = 'HIGH') AS high_count,
countIf(threat_level = 'MEDIUM') AS medium_count,
countIf(threat_level = 'LOW') AS low_count,
countIf(bot_name != '') AS known_bots_count,
countIf(bot_name = '') AS anomalies_count,
uniq(src_ip) AS unique_ips
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE detected_at >= now() - INTERVAL 24 HOUR
"""
summary_result = db.query(summary_query)
summary_row = summary_result.result_rows[0] if summary_result.result_rows else None
if not summary_row:
raise HTTPException(status_code=404, detail="Aucune donnée disponible")
summary = MetricsSummary(
total_detections=summary_row[0],
critical_count=summary_row[1],
high_count=summary_row[2],
medium_count=summary_row[3],
low_count=summary_row[4],
known_bots_count=summary_row[5],
anomalies_count=summary_row[6],
unique_ips=summary_row[7]
)
# Série temporelle (par heure)
timeseries_query = f"""
SELECT
toStartOfHour(detected_at) AS hour,
count() AS total,
countIf(threat_level = 'CRITICAL') AS critical,
countIf(threat_level = 'HIGH') AS high,
countIf(threat_level = 'MEDIUM') AS medium,
countIf(threat_level = 'LOW') AS low
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE detected_at >= now() - INTERVAL 24 HOUR
GROUP BY hour
ORDER BY hour
"""
timeseries_result = db.query(timeseries_query)
timeseries = [
TimeSeriesPoint(
hour=row[0],
total=row[1],
critical=row[2],
high=row[3],
medium=row[4],
low=row[5]
)
for row in timeseries_result.result_rows
]
# Distribution par menace
threat_distribution = {
"CRITICAL": summary.critical_count,
"HIGH": summary.high_count,
"MEDIUM": summary.medium_count,
"LOW": summary.low_count
}
return MetricsResponse(
summary=summary,
timeseries=timeseries,
threat_distribution=threat_distribution
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur lors de la récupération des métriques: {str(e)}")
@router.get("/threats")
async def get_threat_distribution():
"""
Récupère la répartition par niveau de menace
"""
try:
query = f"""
SELECT
threat_level,
count() AS count,
round(count() * 100.0 / sum(count()) OVER (), 2) AS percentage
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE detected_at >= now() - INTERVAL 24 HOUR
GROUP BY threat_level
ORDER BY count DESC
"""
result = db.query(query)
return {
"items": [
{"threat_level": row[0], "count": row[1], "percentage": row[2]}
for row in result.result_rows
]
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
@router.get("/baseline")
async def get_metrics_baseline():
"""
Compare les métriques actuelles (24h) vs hier (24h-48h) pour afficher les tendances.
"""
try:
query = f"""
SELECT
countIf(detected_at >= now() - INTERVAL 24 HOUR) AS today_total,
countIf(detected_at >= now() - INTERVAL 48 HOUR AND detected_at < now() - INTERVAL 24 HOUR) AS yesterday_total,
uniqIf(src_ip, detected_at >= now() - INTERVAL 24 HOUR) AS today_ips,
uniqIf(src_ip, detected_at >= now() - INTERVAL 48 HOUR AND detected_at < now() - INTERVAL 24 HOUR) AS yesterday_ips,
countIf(threat_level = 'CRITICAL' AND detected_at >= now() - INTERVAL 24 HOUR) AS today_critical,
countIf(threat_level = 'CRITICAL' AND detected_at >= now() - INTERVAL 48 HOUR AND detected_at < now() - INTERVAL 24 HOUR) AS yesterday_critical
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE detected_at >= now() - INTERVAL 48 HOUR
"""
r = db.query(query)
row = r.result_rows[0] if r.result_rows else None
def pct_change(today: int, yesterday: int) -> float:
"""Calcule la variation en pourcentage entre aujourd'hui et hier. Retourne 100 si hier=0 et aujourd'hui>0."""
if yesterday == 0:
return 100.0 if today > 0 else 0.0
return round((today - yesterday) / yesterday * 100, 1)
today_total = int(row[0] or 0) if row else 0
yesterday_total = int(row[1] or 0) if row else 0
today_ips = int(row[2] or 0) if row else 0
yesterday_ips = int(row[3] or 0) if row else 0
today_crit = int(row[4] or 0) if row else 0
yesterday_crit = int(row[5] or 0) if row else 0
return {
"total_detections": {
"today": today_total,
"yesterday": yesterday_total,
"pct_change": pct_change(today_total, yesterday_total),
},
"unique_ips": {
"today": today_ips,
"yesterday": yesterday_ips,
"pct_change": pct_change(today_ips, yesterday_ips),
},
"critical_alerts": {
"today": today_crit,
"yesterday": yesterday_crit,
"pct_change": pct_change(today_crit, yesterday_crit),
},
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur baseline: {str(e)}")

View File

@ -1,428 +0,0 @@
"""
Endpoints pour les features ML / IA (scores d'anomalies, radar, scatter)
"""
from fastapi import APIRouter, HTTPException, Query
from ..database import db
from ..config import settings
router = APIRouter(prefix="/api/ml", tags=["ml_features"])
def _attack_type(fuzzing_index: float, hit_velocity: float,
is_fake_nav: int, ua_ch_mismatch: int) -> str:
"""Déduit le type d'attaque depuis les métriques comportementales."""
if fuzzing_index > 50:
return "brute_force"
if hit_velocity > 1.0:
return "flood"
if is_fake_nav:
return "scraper"
if ua_ch_mismatch:
return "spoofing"
return "scanner"
@router.get("/top-anomalies")
async def get_top_anomalies(limit: int = Query(50, ge=1, le=500)):
"""Top IPs anomales (24h) — bypass view_ai_features_1h pour éviter les window functions.
Query directe sur agg_host_ip_ja4_1h + LEFT JOIN agg_header_fingerprint_1h.
"""
try:
sql = f"""
SELECT
replaceRegexpAll(toString(a.src_ip), '^::ffff:', '') AS ip,
any(a.ja4) AS ja4,
any(a.host) AS host,
sum(a.hits) AS hits,
round(uniqMerge(a.uniq_query_params)
/ greatest(uniqMerge(a.uniq_paths), 1), 4) AS fuzzing_index,
round(sum(a.hits)
/ greatest(dateDiff('second', min(a.first_seen), max(a.last_seen)), 1), 2) AS hit_velocity,
round(sum(a.count_head) / greatest(sum(a.hits), 1), 4) AS head_ratio,
round(sum(a.count_no_sec_fetch) / greatest(sum(a.hits), 1), 4) AS sec_fetch_absence,
round(sum(a.tls12_count) / greatest(sum(a.hits), 1), 4) AS tls12_ratio,
round(sum(a.count_generic_accept) / greatest(sum(a.hits), 1), 4) AS generic_accept_ratio,
any(a.src_country_code) AS country,
any(a.src_as_name) AS asn_name,
max(h.ua_ch_mismatch) AS ua_ch_mismatch,
max(h.modern_browser_score) AS browser_score,
dictGetOrDefault('{settings.CLICKHOUSE_DB_PROCESSING}.dict_asn_reputation', 'label', toUInt64(any(a.src_asn)), 'unknown') AS asn_label,
coalesce(
nullIf(dictGetOrDefault('{settings.CLICKHOUSE_DB_PROCESSING}.dict_bot_ja4', 'bot_name', tuple(any(a.ja4)), ''), ''),
''
) AS bot_name
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h a
LEFT JOIN {settings.CLICKHOUSE_DB_PROCESSING}.agg_header_fingerprint_1h h
ON a.src_ip = h.src_ip AND a.window_start = h.window_start
WHERE a.window_start >= now() - INTERVAL 24 HOUR
GROUP BY a.src_ip
ORDER BY fuzzing_index DESC
LIMIT %(limit)s
"""
result = db.query(sql, {"limit": limit})
items = []
for row in result.result_rows:
fuzzing = float(row[4] or 0)
velocity = float(row[5] or 0)
ua_mm = int(row[12] or 0)
items.append({
"ip": str(row[0]),
"ja4": str(row[1]),
"host": str(row[2]),
"hits": int(row[3] or 0),
"fuzzing_index": fuzzing,
"hit_velocity": velocity,
"head_ratio": float(row[6] or 0),
"sec_fetch_absence": float(row[7] or 0),
"tls12_ratio": float(row[8] or 0),
"generic_accept_ratio": float(row[9] or 0),
"country": str(row[10] or ""),
"asn_name": str(row[11] or ""),
"ua_ch_mismatch": ua_mm,
"browser_score": int(row[13] or 0),
"asn_label": str(row[14] or ""),
"bot_name": str(row[15] or ""),
"attack_type": _attack_type(fuzzing, velocity, 0, ua_mm),
})
return {"items": items}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/ip/{ip}/radar")
async def get_ip_radar(ip: str):
"""Scores radar pour une IP spécifique (8 dimensions d'anomalie)."""
try:
sql = f"""
SELECT
avg(fuzzing_index) AS fuzzing_index,
avg(hit_velocity) AS hit_velocity,
avg(is_fake_navigation) AS is_fake_navigation,
avg(ua_ch_mismatch) AS ua_ch_mismatch,
avg(sni_host_mismatch) AS sni_host_mismatch,
avg(orphan_ratio) AS orphan_ratio,
avg(path_diversity_ratio) AS path_diversity_ratio,
avg(anomalous_payload_ratio) AS anomalous_payload_ratio
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_ai_features_1h
WHERE src_ip = IPv4MappedToIPv6(toIPv4(%(ip)s))
AND window_start >= now() - INTERVAL 24 HOUR
"""
result = db.query(sql, {"ip": ip})
if not result.result_rows:
raise HTTPException(status_code=404, detail="IP not found")
row = result.result_rows[0]
def _f(v) -> float:
"""Convertit une valeur nullable en float (None ou falsy → 0.0)."""
return float(v or 0)
return {
"ip": ip,
"fuzzing_score": min(100.0, _f(row[0])),
"velocity_score": min(100.0, _f(row[1]) * 100),
"fake_nav_score": _f(row[2]) * 100,
"ua_mismatch_score": _f(row[3]) * 100,
"sni_mismatch_score": _f(row[4]) * 100,
"orphan_score": min(100.0, _f(row[5]) * 100),
"path_repetition_score": max(0.0, 100 - _f(row[6]) * 100),
"payload_anomaly_score": min(100.0, _f(row[7]) * 100),
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/score-distribution")
async def get_score_distribution():
"""
Distribution de TOUS les scores ML depuis ml_all_scores (3j).
Single query avec conditional aggregates pour éviter le double scan.
"""
try:
# Single scan — global totals + per-model breakdown via GROUPING SETS
sql = f"""
SELECT
threat_level,
model_name,
count() AS total,
round(avg(anomaly_score), 4) AS avg_score,
round(min(anomaly_score), 4) AS min_score,
countIf(threat_level = 'NORMAL') AS normal_count,
countIf(threat_level NOT IN ('NORMAL','KNOWN_BOT')) AS anomaly_count,
countIf(threat_level = 'KNOWN_BOT') AS bot_count
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_all_scores
WHERE detected_at >= now() - INTERVAL 3 DAY
GROUP BY threat_level, model_name
ORDER BY model_name, total DESC
"""
result = db.query(sql)
by_model: dict = {}
grand_total = 0
total_normal = total_anomaly = total_bot = 0
for row in result.result_rows:
level = str(row[0])
model = str(row[1])
total = int(row[2])
grand_total += total
total_normal += int(row[5] or 0)
total_anomaly += int(row[6] or 0)
total_bot += int(row[7] or 0)
if model not in by_model:
by_model[model] = []
by_model[model].append({
"threat_level": level,
"total": total,
"avg_score": float(row[3] or 0),
"min_score": float(row[4] or 0),
})
grand_total = max(grand_total, 1)
return {
"by_model": by_model,
"totals": {
"normal": total_normal,
"anomaly": total_anomaly,
"known_bot": total_bot,
"grand_total": grand_total,
"normal_pct": round(total_normal / grand_total * 100, 1),
"anomaly_pct": round(total_anomaly / grand_total * 100, 1),
"bot_pct": round(total_bot / grand_total * 100, 1),
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/score-trends")
async def get_score_trends(hours: int = Query(72, ge=1, le=168)):
"""
Évolution temporelle des scores ML depuis ml_all_scores.
Retourne le score moyen et les counts par heure et par modèle.
"""
try:
sql = f"""
SELECT
toStartOfHour(window_start) AS hour,
model_name,
countIf(threat_level = 'NORMAL') AS normal_count,
countIf(threat_level IN ('LOW','MEDIUM','HIGH','CRITICAL')) AS anomaly_count,
countIf(threat_level = 'KNOWN_BOT') AS bot_count,
round(avgIf(anomaly_score, threat_level IN ('LOW','MEDIUM','HIGH','CRITICAL')), 4) AS avg_anomaly_score
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_all_scores
WHERE window_start >= now() - INTERVAL %(hours)s HOUR
GROUP BY hour, model_name
ORDER BY hour ASC, model_name
"""
result = db.query(sql, {"hours": hours})
points = []
for row in result.result_rows:
points.append({
"hour": str(row[0]),
"model": str(row[1]),
"normal_count": int(row[2] or 0),
"anomaly_count": int(row[3] or 0),
"bot_count": int(row[4] or 0),
"avg_anomaly_score": float(row[5] or 0),
})
return {"points": points, "hours": hours}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/b-features")
async def get_b_features(limit: int = Query(50, ge=1, le=200)):
"""
Agrégation des B-features (HTTP pures) pour les top IPs anomales.
Source: agg_host_ip_ja4_1h (SimpleAggregateFunction columns).
Expose: head_ratio, sec_fetch_absence, tls12_ratio, generic_accept_ratio, http10_ratio.
Ces features sont calculées dans view_ai_features_1h mais jamais visualisées dans le dashboard.
"""
try:
sql = f"""
SELECT ip, ja4, country, asn_name, total_hits AS hits,
head_ratio, sec_fetch_absence, tls12_ratio, generic_accept_ratio, http10_ratio,
missing_accept_enc_ratio, http_scheme_ratio
FROM (
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
any(ja4) AS ja4,
any(src_country_code) AS country,
any(src_as_name) AS asn_name,
sum(hits) AS total_hits,
round(sum(count_head) / greatest(sum(hits),1), 4) AS head_ratio,
round(sum(count_no_sec_fetch) / greatest(sum(hits),1), 4) AS sec_fetch_absence,
round(sum(tls12_count) / greatest(sum(hits),1), 4) AS tls12_ratio,
round(sum(count_generic_accept) / greatest(sum(hits),1), 4) AS generic_accept_ratio,
round(sum(count_http10) / greatest(sum(hits),1), 4) AS http10_ratio,
round(sum(count_no_accept_enc) / greatest(sum(hits),1), 4) AS missing_accept_enc_ratio,
round(sum(count_http_scheme) / greatest(sum(hits),1), 4) AS http_scheme_ratio
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h
WHERE window_start >= now() - INTERVAL 24 HOUR
GROUP BY src_ip
)
WHERE sec_fetch_absence > 0.5 OR generic_accept_ratio > 0.3
OR head_ratio > 0.1 OR tls12_ratio > 0.5 OR missing_accept_enc_ratio > 0.3
ORDER BY (head_ratio + sec_fetch_absence + generic_accept_ratio + missing_accept_enc_ratio) DESC
LIMIT %(limit)s
"""
result = db.query(sql, {"limit": limit})
items = []
for row in result.result_rows:
items.append({
"ip": str(row[0]),
"ja4": str(row[1] or ""),
"country": str(row[2] or ""),
"asn_name": str(row[3] or ""),
"hits": int(row[4] or 0),
"head_ratio": float(row[5] or 0),
"sec_fetch_absence": float(row[6] or 0),
"tls12_ratio": float(row[7] or 0),
"generic_accept_ratio": float(row[8] or 0),
"http10_ratio": float(row[9] or 0),
"missing_accept_enc_ratio":float(row[10] or 0),
"http_scheme_ratio": float(row[11] or 0),
})
return {"items": items, "total": len(items)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/campaigns")
async def get_ml_campaigns(hours: int = Query(24, ge=1, le=168), limit: int = Query(20, ge=1, le=100)):
"""
Groupes d'anomalies détectées par DBSCAN (campaign_id >= 0).
Si aucune campagne active, fallback sur clustering par /24 subnet + JA4 commun.
Utile pour détecter les botnets distribués sans état de campagne DBSCAN.
"""
try:
# First: check real campaigns
campaign_sql = f"""
SELECT
campaign_id,
count() AS total_detections,
uniq(src_ip) AS unique_ips,
any(threat_level) AS dominant_threat,
groupUniqArray(3)(threat_level) AS threat_levels,
groupUniqArray(3)(bot_name) AS bot_names,
min(detected_at) AS first_seen,
max(detected_at) AS last_seen
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE detected_at >= now() - INTERVAL %(hours)s HOUR
AND campaign_id >= 0
GROUP BY campaign_id
ORDER BY total_detections DESC
LIMIT %(limit)s
"""
result = db.query(campaign_sql, {"hours": hours, "limit": limit})
campaigns = []
for row in result.result_rows:
campaigns.append({
"id": f"C{row[0]}",
"campaign_id": int(row[0]),
"total_detections": int(row[1]),
"unique_ips": int(row[2]),
"dominant_threat": str(row[3] or ""),
"threat_levels": list(row[4] or []),
"bot_names": list(row[5] or []),
"first_seen": str(row[6]),
"last_seen": str(row[7]),
"source": "dbscan",
})
# Fallback: subnet-based clustering when DBSCAN has no campaigns
if not campaigns:
subnet_sql = f"""
SELECT
IPv4CIDRToRange(toIPv4(replaceRegexpAll(toString(src_ip),'^::ffff:','')), 24).1 AS subnet,
count() AS total_detections,
uniq(src_ip) AS unique_ips,
groupArray(3)(threat_level) AS threat_levels,
any(bot_name) AS bot_name,
any(ja4) AS sample_ja4,
min(detected_at) AS first_seen,
max(detected_at) AS last_seen
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE detected_at >= now() - INTERVAL %(hours)s HOUR
AND threat_level IN ('HIGH','CRITICAL','MEDIUM')
GROUP BY subnet
HAVING unique_ips >= 3
ORDER BY total_detections DESC
LIMIT %(limit)s
"""
result2 = db.query(subnet_sql, {"hours": hours, "limit": limit})
for i, row in enumerate(result2.result_rows):
subnet_str = str(row[0]) + "/24"
campaigns.append({
"id": f"S{i+1:03d}",
"campaign_id": -1,
"subnet": subnet_str,
"total_detections": int(row[1]),
"unique_ips": int(row[2]),
"dominant_threat": str((row[3] or [""])[0]),
"threat_levels": list(row[3] or []),
"bot_names": [str(row[4] or "")],
"sample_ja4": str(row[5] or ""),
"first_seen": str(row[6]),
"last_seen": str(row[7]),
"source": "subnet_cluster",
})
dbscan_active = any(c["campaign_id"] >= 0 for c in campaigns)
return {
"campaigns": campaigns,
"total": len(campaigns),
"dbscan_active": dbscan_active,
"hours": hours,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/scatter")
async def get_ml_scatter(limit: int = Query(200, ge=1, le=1000)):
"""Points scatter plot (fuzzing_index × hit_velocity) — bypass view_ai_features_1h."""
try:
sql = f"""
SELECT
ip,
ja4,
round(fuzzing_index, 4) AS fuzzing_index,
round(total_hits / greatest(dateDiff('second', min_first, max_last), 1), 2) AS hit_velocity,
total_hits AS hits,
round(total_count_head / greatest(total_hits, 1), 4) AS head_ratio,
correlated
FROM (
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
any(ja4) AS ja4,
uniqMerge(uniq_query_params) / greatest(uniqMerge(uniq_paths), 1) AS fuzzing_index,
sum(hits) AS total_hits,
min(first_seen) AS min_first,
max(last_seen) AS max_last,
sum(count_head) AS total_count_head,
max(correlated_raw) AS correlated
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h
WHERE window_start >= now() - INTERVAL 24 HOUR
GROUP BY src_ip
)
ORDER BY fuzzing_index DESC
LIMIT %(limit)s
"""
result = db.query(sql, {"limit": limit})
points = []
for row in result.result_rows:
fuzzing = float(row[2] or 0)
velocity = float(row[3] or 0)
points.append({
"ip": str(row[0]),
"ja4": str(row[1]),
"fuzzing_index":fuzzing,
"hit_velocity": velocity,
"hits": int(row[4] or 0),
"attack_type": _attack_type(fuzzing, velocity, 0, 0),
})
return {"points": points}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -0,0 +1,53 @@
"""HTML page routes served via Jinja2 templates."""
from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
router = APIRouter()
templates = Jinja2Templates(directory="backend/templates")
def _ctx(request: Request, page: str, **extra) -> dict:
return {"request": request, "active_page": page, **extra}
@router.get("/")
async def overview(request: Request):
return templates.TemplateResponse("overview.html", _ctx(request, "overview"))
@router.get("/detections")
async def detections(request: Request):
return templates.TemplateResponse("detections.html", _ctx(request, "detections"))
@router.get("/scores")
async def scores(request: Request):
return templates.TemplateResponse("scores.html", _ctx(request, "scores"))
@router.get("/traffic")
async def traffic(request: Request):
return templates.TemplateResponse("traffic.html", _ctx(request, "traffic"))
@router.get("/ip/{ip}")
async def ip_detail(request: Request, ip: str):
return templates.TemplateResponse("ip_detail.html", _ctx(request, "ip_detail", ip=ip))
@router.get("/classify")
async def classify(request: Request):
return templates.TemplateResponse("classify.html", _ctx(request, "classify"))
@router.get("/features")
async def features(request: Request):
return templates.TemplateResponse("features.html", _ctx(request, "features"))
@router.get("/models")
async def models(request: Request):
return templates.TemplateResponse("models.html", _ctx(request, "models"))

View File

@ -1,125 +0,0 @@
"""
Routes pour la réputation IP (bases de données publiques)
"""
from fastapi import APIRouter, HTTPException, Path
from typing import Dict, Any
import re
from ..services.reputation_ip import get_reputation_service
router = APIRouter(prefix="/api/reputation", tags=["Reputation"])
# Pattern de validation d'IP (IPv4)
IP_PATTERN = re.compile(r'^(\d{1,3}\.){3}\d{1,3}$')
def is_valid_ipv4(ip: str) -> bool:
"""Valide qu'une chaîne est une adresse IPv4 valide"""
if not IP_PATTERN.match(ip):
return False
# Vérifie que chaque octet est entre 0 et 255
parts = ip.split('.')
for part in parts:
try:
num = int(part)
if num < 0 or num > 255:
return False
except ValueError:
return False
return True
@router.get("/ip/{ip_address}", summary="Réputation complète d'une IP")
async def get_ip_reputation(
ip_address: str = Path(..., description="Adresse IP à vérifier")
) -> Dict[str, Any]:
"""
Récupère la réputation d'une adresse IP depuis les bases de données publiques
Sources utilisées (sans clé API):
- IP-API.com: Géolocalisation + Proxy/Hosting detection
- IPinfo.io: ASN + Organisation
Returns:
Dict avec:
- ip: Adresse IP vérifiée
- timestamp: Date de la vérification
- sources: Détails par source
- aggregated: Résultats agrégés
- is_proxy: bool
- is_hosting: bool
- is_vpn: bool
- is_tor: bool
- threat_score: 0-100
- threat_level: clean/low/medium/high/critical
- country: Pays
- asn: Numéro ASN
- asn_org: Organisation ASN
- org: ISP/Organisation
- warnings: Liste des alertes
"""
# Valide l'adresse IP
if not is_valid_ipv4(ip_address):
raise HTTPException(
status_code=400,
detail=f"Adresse IP invalide: {ip_address}. Format attendu: x.x.x.x"
)
try:
# Récupère le service de réputation
reputation_service = get_reputation_service()
# Interroge les sources
results = await reputation_service.get_reputation(ip_address)
return results
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Erreur lors de la vérification de réputation: {str(e)}"
)
@router.get("/ip/{ip_address}/summary", summary="Réputation simplifiée d'une IP")
async def get_ip_reputation_summary(
ip_address: str = Path(..., description="Adresse IP à vérifier")
) -> Dict[str, Any]:
"""
Version simplifiée de la réputation IP (juste les infos essentielles)
Utile pour affichage rapide dans les tableaux
"""
if not is_valid_ipv4(ip_address):
raise HTTPException(
status_code=400,
detail=f"Adresse IP invalide: {ip_address}"
)
try:
reputation_service = get_reputation_service()
full_results = await reputation_service.get_reputation(ip_address)
# Retourne juste l'essentiel
aggregated = full_results.get('aggregated', {})
return {
'ip': ip_address,
'threat_level': aggregated.get('threat_level', 'unknown'),
'threat_score': aggregated.get('threat_score', 0),
'is_proxy': aggregated.get('is_proxy', False),
'is_hosting': aggregated.get('is_hosting', False),
'country': aggregated.get('country'),
'country_code': aggregated.get('country_code'),
'asn': aggregated.get('asn'),
'org': aggregated.get('org'),
'warnings_count': len(aggregated.get('warnings', []))
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Erreur: {str(e)}"
)

View File

@ -1,219 +0,0 @@
"""
Endpoints pour la détection de la rotation de fingerprints JA4 et des menaces persistantes
"""
from fastapi import APIRouter, HTTPException, Query
from ..database import db
from ..config import settings
router = APIRouter(prefix="/api/rotation", tags=["rotation"])
@router.get("/ja4-rotators")
async def get_ja4_rotators(limit: int = Query(50, ge=1, le=500)):
"""IPs qui effectuent le plus de rotation de fingerprints JA4."""
try:
sql = f"""
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
distinct_ja4_count,
total_hits
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_host_ip_ja4_rotation
ORDER BY distinct_ja4_count DESC
LIMIT %(limit)s
"""
result = db.query(sql, {"limit": limit})
items = []
for row in result.result_rows:
distinct = int(row[1])
items.append({
"ip": str(row[0]),
"distinct_ja4_count":distinct,
"total_hits": int(row[2]),
"evasion_score": min(100, distinct * 15),
})
return {"items": items, "total": len(items)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/persistent-threats")
async def get_persistent_threats(limit: int = Query(100, ge=1, le=1000)):
"""Menaces persistantes triées par score de persistance."""
try:
sql = f"""
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
recurrence,
worst_score,
worst_threat_level,
first_seen,
last_seen
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_ip_recurrence
ORDER BY (least(100, recurrence * 20 + worst_score * 50)) DESC
LIMIT %(limit)s
"""
result = db.query(sql, {"limit": limit})
items = []
for row in result.result_rows:
recurrence = int(row[1])
worst_score = float(row[2] or 0)
items.append({
"ip": str(row[0]),
"recurrence": recurrence,
"worst_score": worst_score,
"worst_threat_level":str(row[3] or ""),
"first_seen": str(row[4]),
"last_seen": str(row[5]),
"persistence_score": min(100, recurrence * 20 + worst_score * 50),
})
return {"items": items, "total": len(items)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/ip/{ip}/ja4-history")
async def get_ip_ja4_history(ip: str):
"""Historique des JA4 utilisés par une IP donnée."""
try:
sql = f"""
SELECT
ja4,
sum(hits) AS hits,
min(window_start) AS first_seen,
max(window_start) AS last_seen
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h
WHERE src_ip = IPv4MappedToIPv6(toIPv4(%(ip)s))
GROUP BY ja4
ORDER BY hits DESC
"""
result = db.query(sql, {"ip": ip})
items = [
{
"ja4": str(row[0]),
"hits": int(row[1]),
"first_seen":str(row[2]),
"last_seen": str(row[3]),
}
for row in result.result_rows
]
return {"ip": ip, "ja4_history": items, "total": len(items)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/sophistication")
async def get_sophistication(limit: int = Query(50, ge=1, le=500)):
"""Score de sophistication adversaire par IP (rotation JA4 + récurrence + bruteforce).
Single SQL JOIN query — aucun traitement Python sur 34K entrées.
"""
try:
sql = f"""
SELECT
r.ip,
r.distinct_ja4_count,
coalesce(rec.recurrence, 0) AS recurrence,
coalesce(bf.bruteforce_hits, 0) AS bruteforce_hits,
round(least(100.0,
r.distinct_ja4_count * 10
+ coalesce(rec.recurrence, 0) * 20
+ least(30.0, log(coalesce(bf.bruteforce_hits, 0) + 1) * 5)
), 1) AS sophistication_score
FROM (
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
distinct_ja4_count
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_host_ip_ja4_rotation
) r
LEFT JOIN (
-- Utilise view_ip_recurrence (pré-agrégée) au lieu de ml_detected_anomalies FINAL
-- FINAL force une déduplication complète du ReplacingMergeTree — très coûteux
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
recurrence
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_ip_recurrence
) rec ON r.ip = rec.ip
LEFT JOIN (
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
sum(hits) AS bruteforce_hits
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_form_bruteforce_detected
GROUP BY ip
) bf ON r.ip = bf.ip
ORDER BY sophistication_score DESC
LIMIT %(limit)s
"""
result = db.query(sql, {"limit": limit})
items = []
for row in result.result_rows:
score = float(row[4] or 0)
if score > 80:
tier = "APT-like"
elif score > 50:
tier = "Advanced"
elif score > 20:
tier = "Automated"
else:
tier = "Basic"
items.append({
"ip": str(row[0]),
"ja4_rotation_count": int(row[1] or 0),
"recurrence": int(row[2] or 0),
"bruteforce_hits": int(row[3] or 0),
"sophistication_score":score,
"tier": tier,
})
return {"items": items, "total": len(items)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/proactive-hunt")
async def get_proactive_hunt(
min_recurrence: int = Query(2, ge=1, description="Récurrence minimale"),
min_days: int = Query(2, ge=0, description="Jours d'activité minimum"),
limit: int = Query(50, ge=1, le=500),
):
"""IPs volant sous le radar : récurrentes mais sous le seuil de détection normal."""
try:
sql = f"""
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
recurrence,
worst_score,
worst_threat_level,
first_seen,
last_seen,
dateDiff('day', first_seen, last_seen) AS days_active
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_ip_recurrence
WHERE recurrence >= %(min_recurrence)s
AND abs(worst_score) < 0.5
AND dateDiff('day', first_seen, last_seen) >= %(min_days)s
ORDER BY recurrence DESC, worst_score ASC
LIMIT %(limit)s
"""
result = db.query(sql, {
"min_recurrence": min_recurrence,
"min_days": min_days,
"limit": limit,
})
items = []
for row in result.result_rows:
recurrence = int(row[1])
worst_score = float(row[2] or 0)
days_active = int(row[6] or 0)
ratio = recurrence / (worst_score + 0.1)
risk = "Évadeur potentiel" if ratio > 10 else "Persistant modéré"
items.append({
"ip": str(row[0]),
"recurrence": recurrence,
"worst_score": round(worst_score, 4),
"worst_threat_level": str(row[3] or ""),
"first_seen": str(row[4]),
"last_seen": str(row[5]),
"days_active": days_active,
"risk_assessment": risk,
})
return {"items": items, "total": len(items)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -1,126 +0,0 @@
"""
Endpoint de recherche globale rapide — utilisé par la barre Cmd+K
"""
from fastapi import APIRouter, Query
from ..database import db
from ..config import settings
router = APIRouter(prefix="/api/search", tags=["search"])
IP_RE = r"^(\d{1,3}\.){0,3}\d{1,3}$"
@router.get("/quick")
async def quick_search(q: str = Query(..., min_length=1, max_length=100)):
"""
Recherche unifiée sur IPs, JA4, ASN, hosts.
Retourne jusqu'à 5 résultats par catégorie.
"""
q = q.strip()
pattern = f"%{q}%"
results = []
# ── IPs ──────────────────────────────────────────────────────────────────
ip_rows = db.query(
f"""
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS clean_ip,
count() AS hits,
max(detected_at) AS last_seen,
any(threat_level) AS threat_level
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE ilike(toString(src_ip), %(p)s)
AND detected_at >= now() - INTERVAL 24 HOUR
GROUP BY clean_ip
ORDER BY hits DESC
""",
{"p": pattern},
)
for r in ip_rows.result_rows:
ip = str(r[0])
results.append({
"type": "ip",
"value": ip,
"label": ip,
"meta": f"{r[1]} détections · {r[3]}",
"url": f"/detections/ip/{ip}",
"investigation_url": f"/investigation/{ip}",
})
# ── JA4 fingerprints ─────────────────────────────────────────────────────
ja4_rows = db.query(
f"""
SELECT
ja4,
count() AS hits,
uniq(src_ip) AS unique_ips
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE ilike(ja4, %(p)s)
AND ja4 != ''
AND detected_at >= now() - INTERVAL 24 HOUR
GROUP BY ja4
ORDER BY hits DESC
""",
{"p": pattern},
)
for r in ja4_rows.result_rows:
results.append({
"type": "ja4",
"value": str(r[0]),
"label": str(r[0]),
"meta": f"{r[1]} détections · {r[2]} IPs",
"url": f"/investigation/ja4/{r[0]}",
})
# ── Hosts ─────────────────────────────────────────────────────────────────
host_rows = db.query(
f"""
SELECT
host,
count() AS hits,
uniq(src_ip) AS unique_ips
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE ilike(host, %(p)s)
AND host != ''
AND detected_at >= now() - INTERVAL 24 HOUR
GROUP BY host
ORDER BY hits DESC
""",
{"p": pattern},
)
for r in host_rows.result_rows:
results.append({
"type": "host",
"value": str(r[0]),
"label": str(r[0]),
"meta": f"{r[1]} hits · {r[2]} IPs",
"url": f"/detections?search={r[0]}",
})
# ── ASN ───────────────────────────────────────────────────────────────────
asn_rows = db.query(
f"""
SELECT
asn_org,
asn_number,
count() AS hits,
uniq(src_ip) AS unique_ips
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE (ilike(asn_org, %(p)s) OR ilike(asn_number, %(p)s))
AND asn_org != '' AND asn_number != ''
AND detected_at >= now() - INTERVAL 24 HOUR
GROUP BY asn_org, asn_number
ORDER BY hits DESC
""",
{"p": pattern},
)
for r in asn_rows.result_rows:
results.append({
"type": "asn",
"value": str(r[1]),
"label": f"AS{r[1]}{r[0]}",
"meta": f"{r[2]} hits · {r[3]} IPs",
"url": f"/detections?asn={r[1]}",
})
return {"query": q, "results": results}

View File

@ -1,224 +0,0 @@
"""
Endpoints pour la détection du TCP spoofing / fingerprinting OS
Approche multi-signal (p0f-style) :
- TTL initial estimé → famille OS (Linux/Mac=64, Windows=128, Cisco/BSD=255)
- MSS → type de réseau (Ethernet=1460, PPPoE=1452, VPN=1380-1420)
- Taille de fenêtre → signature OS précise
- Facteur d'échelle → affine la version kernel/stack TCP
Détection bots : signatures connues (Masscan/ZMap/Mirai) identifiées par combinaison
win+scale+mss indépendamment de l'UA.
"""
from fastapi import APIRouter, HTTPException, Query
from ..database import db
from ..services.tcp_fingerprint import (
fingerprint_os,
detect_spoof,
declared_os_from_ua,
)
from ..config import settings
router = APIRouter(prefix="/api/tcp-spoofing", tags=["tcp_spoofing"])
@router.get("/overview")
async def get_tcp_spoofing_overview():
"""Statistiques globales avec fingerprinting multi-signal (TTL + MSS + fenêtre + scale)."""
try:
sql = f"""
SELECT
count() AS total_entries,
uniq(src_ip) AS unique_ips,
countIf(tcp_ttl_raw = 0) AS no_tcp_data,
countIf(tcp_ttl_raw > 0) AS with_tcp_data,
countIf(tcp_ttl_raw > 0 AND tcp_ttl_raw <= 64) AS linux_mac_fp,
countIf(tcp_ttl_raw > 64 AND tcp_ttl_raw <= 128) AS windows_fp,
countIf(tcp_ttl_raw > 128) AS cisco_bsd_fp,
countIf(tcp_win_raw = 5808 AND tcp_mss_raw = 1452 AND tcp_scale_raw = 4) AS bot_scanner_fp
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h
WHERE window_start >= now() - INTERVAL 24 HOUR
"""
result = db.query(sql)
row = result.result_rows[0]
# Distribution TTL (top 15)
ttl_sql = f"""
SELECT tcp_ttl_raw AS ttl, count() AS cnt, uniq(src_ip) AS ips
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h
WHERE window_start >= now() - INTERVAL 24 HOUR AND tcp_ttl_raw > 0
GROUP BY ttl ORDER BY cnt DESC
"""
ttl_res = db.query(ttl_sql)
# Distribution MSS — nouveau signal clé (top 12)
mss_sql = f"""
SELECT tcp_mss_raw AS mss, count() AS cnt, uniq(src_ip) AS ips
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h
WHERE window_start >= now() - INTERVAL 24 HOUR AND tcp_mss_raw > 0
GROUP BY mss ORDER BY cnt DESC
"""
mss_res = db.query(mss_sql)
# Distribution fenêtre (top 10)
win_sql = f"""
SELECT tcp_win_raw AS win, count() AS cnt
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h
WHERE window_start >= now() - INTERVAL 24 HOUR AND tcp_ttl_raw > 0
GROUP BY win ORDER BY cnt DESC
"""
win_res = db.query(win_sql)
return {
"total_entries": int(row[0]),
"unique_ips": int(row[1]),
"no_tcp_data": int(row[2]),
"with_tcp_data": int(row[3]),
"linux_mac_fingerprint": int(row[4]),
"windows_fingerprint": int(row[5]),
"cisco_bsd_fingerprint": int(row[6]),
"bot_scanner_fingerprint": int(row[7]),
"ttl_distribution": [
{"ttl": int(r[0]), "count": int(r[1]), "ips": int(r[2])}
for r in ttl_res.result_rows
],
"mss_distribution": [
{"mss": int(r[0]), "count": int(r[1]), "ips": int(r[2])}
for r in mss_res.result_rows
],
"window_size_distribution": [
{"window_size": int(r[0]), "count": int(r[1])}
for r in win_res.result_rows
],
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/list")
async def get_tcp_spoofing_list(
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
spoof_only: bool = Query(False, description="Retourner uniquement les spoofs/bots confirmés"),
):
"""Liste avec fingerprinting multi-signal (TTL + MSS + fenêtre + scale).
Inclut les champs enrichis : mss, win_scale, initial_ttl, hop_count, confidence, network_path, is_bot_tool.
"""
try:
count_sql = f"""
SELECT count() FROM (
SELECT src_ip, ja4
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h
WHERE window_start >= now() - INTERVAL 24 HOUR AND tcp_ttl_raw > 0
GROUP BY src_ip, ja4
)
"""
total = int(db.query(count_sql).result_rows[0][0])
sql = f"""
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS src_ip,
ja4,
any(tcp_ttl_raw) AS tcp_ttl,
any(tcp_win_raw) AS tcp_window_size,
any(tcp_scale_raw) AS tcp_win_scale,
any(tcp_mss_raw) AS tcp_mss,
any(first_ua) AS first_ua,
sum(hits) AS hits
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h
WHERE window_start >= now() - INTERVAL 24 HOUR AND tcp_ttl_raw > 0
GROUP BY src_ip, ja4
ORDER BY hits DESC
LIMIT %(limit)s OFFSET %(offset)s
"""
result = db.query(sql, {"limit": limit, "offset": offset})
items = []
for row in result.result_rows:
ip = str(row[0])
ja4 = str(row[1] or "")
ttl = int(row[2] or 0)
win = int(row[3] or 0)
scale = int(row[4] or 0)
mss = int(row[5] or 0)
ua = str(row[6] or "")
hits = int(row[7] or 0)
fp = fingerprint_os(ttl, win, scale, mss)
dec_os = declared_os_from_ua(ua)
spoof_res = detect_spoof(fp, dec_os)
if spoof_only and not spoof_res.is_spoof:
continue
items.append({
"ip": ip,
"ja4": ja4,
"tcp_ttl": ttl,
"tcp_window_size": win,
"tcp_win_scale": scale,
"tcp_mss": mss,
"hits": hits,
"first_ua": ua,
"suspected_os": fp.os_name,
"initial_ttl": fp.initial_ttl,
"hop_count": fp.hop_count,
"confidence": fp.confidence,
"network_path": fp.network_path,
"is_bot_tool": fp.is_bot_tool,
"declared_os": dec_os,
"spoof_flag": spoof_res.is_spoof,
"spoof_reason": spoof_res.reason,
})
return {"items": items, "total": total}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/matrix")
async def get_tcp_spoofing_matrix():
"""Matrice OS suspecté × OS déclaré avec fingerprinting multi-signal."""
try:
sql = f"""
SELECT
any(tcp_ttl_raw) AS ttl,
any(tcp_win_raw) AS win,
any(tcp_scale_raw) AS scale,
any(tcp_mss_raw) AS mss,
any(first_ua) AS ua,
count() AS cnt
FROM {settings.CLICKHOUSE_DB_PROCESSING}.agg_host_ip_ja4_1h
WHERE window_start >= now() - INTERVAL 24 HOUR AND tcp_ttl_raw > 0
GROUP BY src_ip, ja4
"""
result = db.query(sql)
counts: dict = {}
for row in result.result_rows:
ttl = int(row[0] or 0)
win = int(row[1] or 0)
scale = int(row[2] or 0)
mss = int(row[3] or 0)
ua = str(row[4] or "")
cnt = int(row[5] or 1)
fp = fingerprint_os(ttl, win, scale, mss)
dec_os = declared_os_from_ua(ua)
spoof_res = detect_spoof(fp, dec_os)
key = (fp.os_name, dec_os, spoof_res.is_spoof, fp.is_bot_tool)
counts[key] = counts.get(key, 0) + cnt
matrix = [
{
"suspected_os": k[0],
"declared_os": k[1],
"count": v,
"is_spoof": k[2],
"is_bot_tool": k[3],
}
for k, v in counts.items()
]
matrix.sort(key=lambda x: x["count"], reverse=True)
return {"matrix": matrix}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -1,707 +0,0 @@
"""
Endpoints pour la variabilité des attributs
"""
from fastapi import APIRouter, HTTPException, Query
from typing import Optional
from ..database import db
from ..models import (
VariabilityResponse, VariabilityAttributes, AttributeValue, Insight,
UserAgentsResponse, UserAgentValue
)
from ..config import settings
router = APIRouter(prefix="/api/variability", tags=["variability"])
# =============================================================================
# ROUTES SPÉCIFIQUES (doivent être avant les routes génériques)
# =============================================================================
@router.get("/{attr_type}/{value:path}/ips", response_model=dict)
async def get_associated_ips(
attr_type: str,
value: str,
limit: int = Query(100, ge=1, le=1000, description="Nombre maximum d'IPs")
):
"""
Récupère la liste des IPs associées à un attribut
"""
try:
# Mapping des types vers les colonnes
type_column_map = {
"ip": "src_ip",
"ja4": "ja4",
"country": "country_code",
"asn": "asn_number",
"host": "host",
}
if attr_type not in type_column_map:
raise HTTPException(
status_code=400,
detail=f"Type invalide. Types supportés: {', '.join(type_column_map.keys())}"
)
column = type_column_map[attr_type]
query = f"""
SELECT src_ip, count() AS hit_count
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE {column} = %(value)s
AND detected_at >= now() - INTERVAL 24 HOUR
GROUP BY src_ip
ORDER BY hit_count DESC
LIMIT %(limit)s
"""
result = db.query(query, {"value": value, "limit": limit})
total_hits = sum(row[1] for row in result.result_rows) or 1
ips = [
{"ip": str(row[0]), "count": row[1], "percentage": round(row[1] * 100.0 / total_hits, 2)}
for row in result.result_rows
]
# Compter le total
count_query = f"""
SELECT uniq(src_ip) AS total
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE {column} = %(value)s
AND detected_at >= now() - INTERVAL 24 HOUR
"""
count_result = db.query(count_query, {"value": value})
total = count_result.result_rows[0][0] if count_result.result_rows else 0
return {
"type": attr_type,
"value": value,
"ips": ips,
"total": total,
"showing": len(ips)
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
@router.get("/{attr_type}/{value:path}/attributes", response_model=dict)
async def get_associated_attributes(
attr_type: str,
value: str,
target_attr: str = Query(..., description="Type d'attribut à récupérer (user_agents, ja4, countries, asns, hosts)"),
limit: int = Query(50, ge=1, le=500, description="Nombre maximum de résultats")
):
"""
Récupère la liste des attributs associés (ex: User-Agents pour un pays)
"""
try:
# Mapping des types vers les colonnes
type_column_map = {
"ip": "src_ip",
"ja4": "ja4",
"country": "country_code",
"asn": "asn_number",
"host": "host",
}
# Mapping des attributs cibles
target_column_map = {
"user_agents": None, # handled separately via view_dashboard_entities
"ja4": "ja4",
"countries": "country_code",
"asns": "asn_number",
"hosts": "host",
}
if attr_type not in type_column_map:
raise HTTPException(status_code=400, detail=f"Type '{attr_type}' invalide")
if target_attr not in target_column_map:
raise HTTPException(
status_code=400,
detail=f"Attribut cible invalide. Supportés: {', '.join(target_column_map.keys())}"
)
column = type_column_map[attr_type]
target_column = target_column_map[target_attr]
# Pour user_agents: requête via view_dashboard_user_agents
# Colonnes: src_ip, ja4, hour, log_date, user_agents, requests
if target_column is None:
if attr_type == "ip":
ua_where = "toString(src_ip) = %(value)s"
elif attr_type == "ja4":
ua_where = "ja4 = %(value)s"
else:
# country/asn/host: pivot via ml_detected_anomalies
ua_where = f"""toString(src_ip) IN (
SELECT DISTINCT replaceRegexpAll(toString(src_ip), '^::ffff:', '')
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE {column} = %(value)s AND detected_at >= now() - INTERVAL 24 HOUR
)"""
ua_q = f"""
SELECT ua AS value, sum(requests) AS count,
round(sum(requests) * 100.0 / sum(sum(requests)) OVER (), 2) AS percentage
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_user_agents
ARRAY JOIN user_agents AS ua
WHERE {ua_where}
AND hour >= now() - INTERVAL 24 HOUR AND ua != ''
GROUP BY value ORDER BY count DESC LIMIT %(limit)s
"""
ua_result = db.query(ua_q, {"value": value, "limit": limit})
items = [{"value": str(r[0]), "count": r[1], "percentage": round(float(r[2]), 2) if r[2] else 0.0}
for r in ua_result.result_rows]
return {"type": attr_type, "value": value, "target": target_attr, "items": items, "total": len(items), "showing": len(items)}
query = f"""
SELECT
{target_column} AS value,
count() AS count,
round(count() * 100.0 / sum(count()) OVER (), 2) AS percentage
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE {column} = %(value)s
AND {target_column} != '' AND {target_column} IS NOT NULL
AND detected_at >= now() - INTERVAL 24 HOUR
GROUP BY value
ORDER BY count DESC
LIMIT %(limit)s
"""
result = db.query(query, {"value": value, "limit": limit})
items = [
{
"value": str(row[0]),
"count": row[1],
"percentage": round(float(row[2]), 2) if row[2] else 0.0
}
for row in result.result_rows
]
# Compter le total
count_query = f"""
SELECT uniq({target_column}) AS total
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE {column} = %(value)s
AND {target_column} != '' AND {target_column} IS NOT NULL
AND detected_at >= now() - INTERVAL 24 HOUR
"""
count_result = db.query(count_query, {"value": value})
total = count_result.result_rows[0][0] if count_result.result_rows else 0
return {
"type": attr_type,
"value": value,
"target": target_attr,
"items": items,
"total": total,
"showing": len(items)
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
@router.get("/{attr_type}/{value:path}/user_agents", response_model=UserAgentsResponse)
async def get_user_agents(
attr_type: str,
value: str,
limit: int = Query(100, ge=1, le=500, description="Nombre maximum de user-agents")
):
"""
Récupère la liste des User-Agents associés à un attribut (IP, JA4, pays, etc.)
Les données sont récupérées depuis la vue materialisée view_dashboard_user_agents
"""
try:
# Mapping des types vers les colonnes
type_column_map = {
"ip": "src_ip",
"ja4": "ja4",
"country": "country_code",
"asn": "asn_number",
"host": "host",
}
if attr_type not in type_column_map:
raise HTTPException(
status_code=400,
detail=f"Type invalide. Types supportés: {', '.join(type_column_map.keys())}"
)
column = type_column_map[attr_type]
# view_dashboard_user_agents colonnes: src_ip, ja4, hour, log_date, user_agents, requests
if attr_type == "ip":
where = "toString(src_ip) = %(value)s"
params: dict = {"value": value, "limit": limit}
elif attr_type == "ja4":
where = "ja4 = %(value)s"
params = {"value": value, "limit": limit}
else:
# country / asn / host: pivot via ml_detected_anomalies → IPs connus → vue par src_ip
ml_col = {"country": "country_code", "asn": "asn_number", "host": "host"}[attr_type]
where = f"""toString(src_ip) IN (
SELECT DISTINCT replaceRegexpAll(toString(src_ip), '^::ffff:', '')
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE {ml_col} = %(value)s
AND detected_at >= now() - INTERVAL 24 HOUR
)"""
params = {"value": value, "limit": limit}
query = f"""
SELECT
ua AS user_agent,
sum(requests) AS count,
round(sum(requests) * 100.0 / sum(sum(requests)) OVER (), 2) AS percentage,
min(log_date) AS first_seen,
max(log_date) AS last_seen
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_user_agents
ARRAY JOIN user_agents AS ua
WHERE {where}
AND hour >= now() - INTERVAL 24 HOUR
AND ua != ''
GROUP BY user_agent
ORDER BY count DESC
LIMIT %(limit)s
"""
result = db.query(query, params)
count_query = f"""
SELECT uniqExact(ua) AS total
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_user_agents
ARRAY JOIN user_agents AS ua
WHERE {where}
AND hour >= now() - INTERVAL 24 HOUR
AND ua != ''
"""
count_result = db.query(count_query, params)
user_agents = [
UserAgentValue(
value=str(row[0]),
count=row[1] or 0,
percentage=round(float(row[2]), 2) if row[2] else 0.0,
first_seen=row[3] if len(row) > 3 and row[3] else None,
last_seen=row[4] if len(row) > 4 and row[4] else None,
)
for row in result.result_rows
]
total = count_result.result_rows[0][0] if count_result.result_rows else 0
return {
"type": attr_type,
"value": value,
"user_agents": user_agents,
"total": total,
"showing": len(user_agents)
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
# =============================================================================
# ROUTE GÉNÉRIQUE (doit être en dernier)
# =============================================================================
def get_attribute_value(row, count_idx: int, percentage_idx: int,
first_seen_idx: Optional[int] = None,
last_seen_idx: Optional[int] = None,
threat_idx: Optional[int] = None,
unique_ips_idx: Optional[int] = None) -> AttributeValue:
"""Helper pour créer un AttributeValue depuis une ligne ClickHouse"""
return AttributeValue(
value=str(row[0]),
count=row[count_idx] or 0,
percentage=round(float(row[percentage_idx]), 2) if row[percentage_idx] else 0.0,
first_seen=row[first_seen_idx] if first_seen_idx is not None and len(row) > first_seen_idx else None,
last_seen=row[last_seen_idx] if last_seen_idx is not None and len(row) > last_seen_idx else None,
threat_levels=_parse_threat_levels(row[threat_idx]) if threat_idx is not None and len(row) > threat_idx and row[threat_idx] else None,
unique_ips=row[unique_ips_idx] if unique_ips_idx is not None and len(row) > unique_ips_idx else None,
primary_threat=_get_primary_threat(row[threat_idx]) if threat_idx is not None and len(row) > threat_idx and row[threat_idx] else None
)
def _parse_threat_levels(threat_str: str) -> dict:
"""Parse une chaîne de type 'CRITICAL:5,HIGH:10' en dict"""
if not threat_str:
return {}
result = {}
for part in str(threat_str).split(','):
if ':' in part:
level, count = part.strip().split(':')
result[level.strip()] = int(count.strip())
return result
def _get_primary_threat(threat_str: str) -> str:
"""Retourne le niveau de menace principal"""
if not threat_str:
return ""
levels_order = ["CRITICAL", "HIGH", "MEDIUM", "LOW"]
for level in levels_order:
if level in str(threat_str):
return level
return ""
def _generate_insights(attr_type: str, value: str, attributes: VariabilityAttributes,
total_detections: int, unique_ips: int) -> list:
"""Génère des insights basés sur les données de variabilité"""
insights = []
# User-Agent insights
if len(attributes.user_agents) > 1:
insights.append(Insight(
type="warning",
message=f"{len(attributes.user_agents)} User-Agents différents → Possible rotation/obfuscation"
))
# JA4 insights
if len(attributes.ja4) > 1:
insights.append(Insight(
type="warning",
message=f"{len(attributes.ja4)} JA4 fingerprints différents → Possible rotation de fingerprint"
))
# IP insights (pour les sélections non-IP)
if attr_type != "ip" and unique_ips > 10:
insights.append(Insight(
type="info",
message=f"{unique_ips} IPs différentes associées → Possible infrastructure distribuée"
))
# ASN insights
if len(attributes.asns) == 1 and attributes.asns[0].value:
asn_label_lower = ""
if attr_type == "asn":
asn_label_lower = value.lower()
# Vérifier si c'est un ASN de hosting/cloud
hosting_keywords = ["ovh", "amazon", "aws", "google", "azure", "digitalocean", "linode", "vultr"]
if any(kw in (attributes.asns[0].value or "").lower() for kw in hosting_keywords):
insights.append(Insight(
type="warning",
message="ASN de type hosting/cloud → Souvent utilisé pour des bots"
))
# Country insights
if len(attributes.countries) > 5:
insights.append(Insight(
type="info",
message=f"Présent dans {len(attributes.countries)} pays → Distribution géographique large"
))
# Threat level insights
critical_count = 0
high_count = 0
for tl in attributes.threat_levels:
if tl.value == "CRITICAL":
critical_count = tl.count
elif tl.value == "HIGH":
high_count = tl.count
if critical_count > total_detections * 0.3:
insights.append(Insight(
type="warning",
message=f"{round(critical_count * 100 / total_detections)}% de détections CRITICAL → Menace sévère"
))
elif high_count > total_detections * 0.5:
insights.append(Insight(
type="info",
message=f"{round(high_count * 100 / total_detections)}% de détections HIGH → Activité suspecte"
))
return insights
@router.get("/{attr_type}/{value:path}", response_model=VariabilityResponse)
async def get_variability(attr_type: str, value: str):
"""
Récupère la variabilité des attributs associés à une valeur
attr_type: ip, ja4, country, asn, host, user_agent
value: la valeur à investiguer
"""
try:
# Mapping des types vers les colonnes ClickHouse
type_column_map = {
"ip": "src_ip",
"ja4": "ja4",
"country": "country_code",
"asn": "asn_number",
"host": "host",
"user_agent": "header_user_agent"
}
if attr_type not in type_column_map:
raise HTTPException(
status_code=400,
detail=f"Type invalide. Types supportés: {', '.join(type_column_map.keys())}"
)
column = type_column_map[attr_type]
# Requête principale - Récupère toutes les détections pour cette valeur
# On utilise toStartOfHour pour le timeseries et on évite header_user_agent si inexistant
base_query = f"""
SELECT *
FROM (
SELECT
detected_at,
src_ip,
ja4,
host,
'' AS user_agent,
country_code,
asn_number,
asn_org,
threat_level,
model_name,
anomaly_score
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE {column} = %(value)s
AND detected_at >= now() - INTERVAL 24 HOUR
)
"""
# Stats globales
stats_query = f"""
SELECT
count() AS total_detections,
uniq(src_ip) AS unique_ips,
min(detected_at) AS first_seen,
max(detected_at) AS last_seen
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE {column} = %(value)s
AND detected_at >= now() - INTERVAL 24 HOUR
"""
stats_result = db.query(stats_query, {"value": value})
if not stats_result.result_rows or stats_result.result_rows[0][0] == 0:
raise HTTPException(status_code=404, detail="Aucune donnée trouvée")
stats_row = stats_result.result_rows[0]
total_detections = stats_row[0]
unique_ips = stats_row[1]
first_seen = stats_row[2]
last_seen = stats_row[3]
# User-Agents depuis http_logs pour des comptes exacts par requête
# (view_dashboard_user_agents déduplique par heure, ce qui sous-compte les hits)
_ua_params: dict = {"value": value}
if attr_type == "ip":
_ua_logs_where = "src_ip = toIPv4(%(value)s)"
ua_query_simple = f"""
SELECT
header_user_agent AS user_agent,
count() AS count,
round(count() * 100.0 / (
SELECT count() FROM {settings.CLICKHOUSE_DB_LOGS}.http_logs
WHERE {_ua_logs_where} AND time >= now() - INTERVAL 24 HOUR
), 2) AS percentage,
min(time) AS first_seen,
max(time) AS last_seen
FROM {settings.CLICKHOUSE_DB_LOGS}.http_logs
WHERE {_ua_logs_where}
AND time >= now() - INTERVAL 24 HOUR
AND header_user_agent != '' AND header_user_agent IS NOT NULL
GROUP BY user_agent
ORDER BY count DESC
"""
ua_result = db.query(ua_query_simple, _ua_params)
user_agents = [get_attribute_value(row, 1, 2, 3, 4) for row in ua_result.result_rows]
elif attr_type == "ja4":
_ua_logs_where = "ja4 = %(value)s"
ua_query_simple = f"""
SELECT
header_user_agent AS user_agent,
count() AS count,
round(count() * 100.0 / (
SELECT count() FROM {settings.CLICKHOUSE_DB_LOGS}.http_logs
WHERE {_ua_logs_where} AND time >= now() - INTERVAL 24 HOUR
), 2) AS percentage,
min(time) AS first_seen,
max(time) AS last_seen
FROM {settings.CLICKHOUSE_DB_LOGS}.http_logs
WHERE {_ua_logs_where}
AND time >= now() - INTERVAL 24 HOUR
AND header_user_agent != '' AND header_user_agent IS NOT NULL
GROUP BY user_agent
ORDER BY count DESC
"""
ua_result = db.query(ua_query_simple, _ua_params)
user_agents = [get_attribute_value(row, 1, 2, 3, 4) for row in ua_result.result_rows]
else:
# country / asn / host: pivot via ml_detected_anomalies → IPs, puis view UA
_ua_where = f"""toString(src_ip) IN (
SELECT DISTINCT replaceRegexpAll(toString(src_ip), '^::ffff:', '')
FROM {settings.CLICKHOUSE_DB_PROCESSING}.ml_detected_anomalies
WHERE {column} = %(value)s AND detected_at >= now() - INTERVAL 24 HOUR
)"""
ua_query_simple = f"""
SELECT
ua AS user_agent,
sum(requests) AS count,
round(sum(requests) * 100.0 / sum(sum(requests)) OVER (), 2) AS percentage,
min(log_date) AS first_seen,
max(log_date) AS last_seen
FROM {settings.CLICKHOUSE_DB_PROCESSING}.view_dashboard_user_agents
ARRAY JOIN user_agents AS ua
WHERE {_ua_where}
AND hour >= now() - INTERVAL 24 HOUR
AND ua != ''
GROUP BY user_agent
ORDER BY count DESC
"""
ua_result = db.query(ua_query_simple, _ua_params)
user_agents = [get_attribute_value(row, 1, 2, 3, 4) for row in ua_result.result_rows]
# JA4 fingerprints
ja4_query = f"""
SELECT
ja4,
count() AS count,
round(count() * 100.0 / (SELECT count() FROM ({base_query})), 2) AS percentage,
min(detected_at) AS first_seen,
max(detected_at) AS last_seen
FROM ({base_query})
WHERE ja4 != '' AND ja4 IS NOT NULL
GROUP BY ja4
ORDER BY count DESC
"""
ja4_result = db.query(ja4_query, {"value": value})
ja4s = [get_attribute_value(row, 1, 2, 3, 4) for row in ja4_result.result_rows]
# Pays
country_query = f"""
SELECT
country_code,
count() AS count,
round(count() * 100.0 / (SELECT count() FROM ({base_query})), 2) AS percentage
FROM ({base_query})
WHERE country_code != '' AND country_code IS NOT NULL
GROUP BY country_code
ORDER BY count DESC
"""
country_result = db.query(country_query, {"value": value})
countries = [get_attribute_value(row, 1, 2) for row in country_result.result_rows]
# ASN
asn_query = f"""
SELECT
concat('AS', toString(asn_number), ' - ', asn_org) AS asn_display,
asn_number,
count() AS count,
round(count() * 100.0 / (SELECT count() FROM ({base_query})), 2) AS percentage
FROM ({base_query})
WHERE asn_number != '' AND asn_number IS NOT NULL AND asn_number != '0'
GROUP BY asn_display, asn_number
ORDER BY count DESC
"""
asn_result = db.query(asn_query, {"value": value})
asns = [
AttributeValue(
value=str(row[0]),
count=row[2] or 0,
percentage=round(float(row[3]), 2) if row[3] else 0.0
)
for row in asn_result.result_rows
]
# Hosts
host_query = f"""
SELECT
host,
count() AS count,
round(count() * 100.0 / (SELECT count() FROM ({base_query})), 2) AS percentage
FROM ({base_query})
WHERE host != '' AND host IS NOT NULL
GROUP BY host
ORDER BY count DESC
"""
host_result = db.query(host_query, {"value": value})
hosts = [get_attribute_value(row, 1, 2) for row in host_result.result_rows]
# Threat levels
threat_query = f"""
SELECT
threat_level,
count() AS count,
round(count() * 100.0 / (SELECT count() FROM ({base_query})), 2) AS percentage
FROM ({base_query})
WHERE threat_level != '' AND threat_level IS NOT NULL
GROUP BY threat_level
ORDER BY
CASE threat_level
WHEN 'CRITICAL' THEN 1
WHEN 'HIGH' THEN 2
WHEN 'MEDIUM' THEN 3
WHEN 'LOW' THEN 4
ELSE 5
END
"""
threat_result = db.query(threat_query, {"value": value})
threat_levels = [get_attribute_value(row, 1, 2) for row in threat_result.result_rows]
# Model names
model_query = f"""
SELECT
model_name,
count() AS count,
round(count() * 100.0 / (SELECT count() FROM ({base_query})), 2) AS percentage
FROM ({base_query})
WHERE model_name != '' AND model_name IS NOT NULL
GROUP BY model_name
ORDER BY count DESC
"""
model_result = db.query(model_query, {"value": value})
model_names = [get_attribute_value(row, 1, 2) for row in model_result.result_rows]
# Construire la réponse
attributes = VariabilityAttributes(
user_agents=user_agents,
ja4=ja4s,
countries=countries,
asns=asns,
hosts=hosts,
threat_levels=threat_levels,
model_names=model_names
)
# Générer les insights
insights = _generate_insights(attr_type, value, attributes, total_detections, unique_ips)
return VariabilityResponse(
type=attr_type,
value=value,
total_detections=total_detections,
unique_ips=unique_ips,
date_range={
"first_seen": first_seen,
"last_seen": last_seen
},
attributes=attributes,
insights=insights
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")

View File

@ -1,493 +0,0 @@
"""
Moteur de clustering K-means++ multi-métriques (numpy + scipy vectorisé).
Ref:
Arthur & Vassilvitskii (2007) — k-means++: The Advantages of Careful Seeding
scipy.spatial.ConvexHull — enveloppe convexe (Graham/Qhull)
sklearn-style API — centroids, labels_, inertia_
Features (31 dimensions, normalisées [0,1]) :
0 ttl_n : TTL initial normalisé
1 mss_n : MSS normalisé → type réseau
2 scale_n : facteur de mise à l'échelle TCP
3 win_n : fenêtre TCP normalisée
4 score_n : score anomalie ML (abs)
5 velocity_n : vélocité de requêtes (log1p)
6 fuzzing_n : index de fuzzing (log1p)
7 headless_n : ratio sessions headless
8 post_n : ratio POST/total
9 ip_id_zero_n : ratio IP-ID=0 (Linux/spoofé)
10 entropy_n : entropie temporelle
11 browser_n : score navigateur moderne
12 alpn_n : mismatch ALPN/protocole
13 alpn_absent_n : ratio ALPN absent
14 h2_n : efficacité H2 multiplexing (log1p)
15 hdr_conf_n : confiance ordre headers
16 ua_ch_n : mismatch User-Agent-Client-Hints
17 asset_n : ratio assets statiques
18 direct_n : ratio accès directs
19 ja4_div_n : diversité JA4 (log1p)
20 ua_rot_n : UA rotatif (booléen)
21 country_risk_n : risque pays source (CN/RU/KP → 1.0, US/DE/FR → 0.0)
22 asn_cloud_n : hébergeur cloud/CDN/VPN (Cloudflare/AWS/OVH → 1.0)
23 hdr_accept_lang_n : présence header Accept-Language (0=absent=bot-like)
24 hdr_encoding_n : présence header Accept-Encoding (0=absent=bot-like)
25 hdr_sec_fetch_n : présence headers Sec-Fetch-* (1=navigateur réel)
26 hdr_count_n : nombre de headers HTTP normalisé (3=bot, 15=browser)
27 hfp_popular_n : popularité du fingerprint headers (log-normalisé)
fingerprint rare = suspect ; très populaire = browser légitime
28 hfp_rotating_n : rotation de fingerprint (distinct_header_orders)
plusieurs fingerprints distincts → bot en rotation
29 hfp_cookie_n : présence header Cookie (engagement utilisateur réel)
30 hfp_referer_n : présence header Referer (navigation HTTP normale)
"""
from __future__ import annotations
import math
import logging
import numpy as np
from dataclasses import dataclass, field
from scipy.spatial import ConvexHull
log = logging.getLogger(__name__)
# ─── Encodage pays (risque source) ───────────────────────────────────────────
# Source: MISP threat intel, Spamhaus DROP list, géographie offensive connue
_COUNTRY_RISK: dict[str, float] = {
# Très haut risque : infrastructure offensive documentée
"CN": 1.0, "RU": 1.0, "KP": 1.0, "IR": 1.0,
"BY": 0.9, "SY": 0.9, "CU": 0.8,
# Haut risque : transit/hébergement permissif, bulletproof hosters
"HK": 0.75, "VN": 0.7, "UA": 0.65,
"RO": 0.6, "PK": 0.6, "NG": 0.6,
"BG": 0.55, "TR": 0.55, "BR": 0.5,
"TH": 0.5, "IN": 0.45, "ID": 0.45,
# Risque faible : pays à faible tolérance envers activité malveillante
"US": 0.1, "DE": 0.1, "FR": 0.1, "GB": 0.1,
"CA": 0.1, "JP": 0.1, "AU": 0.1, "NL": 0.15,
"CH": 0.1, "SE": 0.1, "NO": 0.1, "DK": 0.1,
"FI": 0.1, "AT": 0.1, "BE": 0.1, "IT": 0.15,
"SG": 0.3, "TW": 0.2, "KR": 0.2, "RS": 0.4,
}
_DEFAULT_COUNTRY_RISK = 0.35 # pays inconnu → risque modéré
def country_risk(cc: str | None) -> float:
"""Score de risque [0,1] d'un code pays ISO-3166."""
return _COUNTRY_RISK.get((cc or "").upper(), _DEFAULT_COUNTRY_RISK)
# ─── Encodage ASN (type d'infrastructure) ────────────────────────────────────
# Cloud/CDN/hosting → fort corrélé avec scanners automatisés et bots
_ASN_CLOUD_KEYWORDS = [
# Hyperscalers
"amazon", "aws", "google", "microsoft", "azure", "alibaba", "tencent", "huawei",
# CDN / edge
"cloudflare", "akamai", "fastly", "cloudfront", "incapsula", "imperva",
"sucuri", "stackpath", "keycdn",
# Hébergeurs
"ovh", "hetzner", "digitalocean", "vultr", "linode", "akamai-linode",
"leaseweb", "choopa", "packet", "equinix", "serverius", "combahton",
"m247", "b2 net", "hostinger", "contabo",
# Bulletproof / transit permissif connus
"hwclouds", "multacom", "psychz", "serverius", "colocrossing",
"frantech", "sharktech", "tzulo",
# VPN / proxy commerciaux
"nordvpn", "expressvpn", "mullvad", "protonvpn", "surfshark",
"privateinternetaccess", "pia ", "cyberghost", "hotspot shield",
"ipvanish", "hide.me",
# Bots search engines / crawlers
"facebook", "meta ", "twitter", "linkedin", "semrush", "ahrefs",
"majestic", "moz ", "babbar", "sistrix", "criteo", "peer39",
]
def asn_cloud_score(asn_org: str | None) -> float:
"""
Score [0,1] : 1.0 = cloud/CDN/hébergement/VPN confirmé.
Correspond à une infrastructure typiquement utilisée par les bots.
"""
if not asn_org:
return 0.2 # inconnu → légèrement suspect
s = asn_org.lower()
for kw in _ASN_CLOUD_KEYWORDS:
if kw in s:
return 1.0
return 0.0
# ─── Définition des features ──────────────────────────────────────────────────
FEATURES: list[tuple[str, str, object]] = [
# TCP stack
("ttl", "TTL Initial", lambda v: min(1.0, (v or 0) / 255.0)),
("mss", "MSS Réseau", lambda v: min(1.0, (v or 0) / 1460.0)),
("scale", "Scale TCP", lambda v: min(1.0, (v or 0) / 14.0)),
("win", "Fenêtre TCP", lambda v: min(1.0, (v or 0) / 65535.0)),
# Anomalie ML
("avg_velocity", "Vélocité (rps)", lambda v: min(1.0, math.log1p(float(v or 0)) / math.log1p(100))), ("avg_fuzzing", "Fuzzing", lambda v: min(1.0, math.log1p(float(v or 0)) / math.log1p(300))),
("pct_headless", "Headless", lambda v: min(1.0, float(v or 0))),
("avg_post", "Ratio POST", lambda v: min(1.0, float(v or 0))),
# IP-ID
("ip_id_zero", "IP-ID Zéro", lambda v: min(1.0, float(v or 0))),
# Temporel
("entropy", "Entropie Temporelle", lambda v: min(1.0, math.log1p(float(v or 0)) / math.log1p(10))),
# Navigateur
("browser_score", "Score Navigateur", lambda v: min(1.0, float(v or 0) / 50.0)),
# TLS / Protocole
("alpn_mismatch", "ALPN Mismatch", lambda v: min(1.0, float(v or 0))),
("alpn_missing", "ALPN Absent", lambda v: min(1.0, float(v or 0))),
("h2_eff", "H2 Multiplexing", lambda v: min(1.0, math.log1p(float(v or 0)) / math.log1p(20))),
("hdr_conf", "Ordre Headers", lambda v: min(1.0, float(v or 0))),
("ua_ch_mismatch","UA-CH Mismatch", lambda v: min(1.0, float(v or 0))),
# Comportement HTTP
("asset_ratio", "Ratio Assets", lambda v: min(1.0, float(v or 0))),
("direct_ratio", "Accès Direct", lambda v: min(1.0, float(v or 0))),
# Diversité JA4
("ja4_count", "Diversité JA4", lambda v: min(1.0, math.log1p(float(v or 0)) / math.log1p(30))),
# UA rotatif
("ua_rotating", "UA Rotatif", lambda v: 1.0 if float(v or 0) > 0 else 0.0),
# ── Géographie & infrastructure (nouvelles features) ──────────────────
("country", "Risque Pays", lambda v: country_risk(str(v) if v else None)),
("asn_org", "Hébergeur Cloud/VPN", lambda v: asn_cloud_score(str(v) if v else None)),
# ── Headers HTTP (présence / profil de la requête) ────────────────────
# Absence d'Accept-Language ou Accept-Encoding = fort signal bot (bots simples l'omettent)
# Sec-Fetch-* = exclusif aux navigateurs réels (fetch metadata)
("hdr_accept_lang", "Accept-Language", lambda v: min(1.0, float(v or 0))),
("hdr_has_encoding", "Accept-Encoding", lambda v: 1.0 if float(v or 0) > 0 else 0.0),
("hdr_has_sec_fetch", "Sec-Fetch Headers", lambda v: 1.0 if float(v or 0) > 0 else 0.0),
("hdr_count_raw", "Nb Headers", lambda v: min(1.0, float(v or 0) / 20.0)),
# ── Fingerprint HTTP Headers (agg_header_fingerprint_1h) ──────────────
# header_order_shared_count : nb d'IPs partageant ce fingerprint
# élevé → populaire → browser légitime (normalisé log1p / log1p(500000))
("hfp_shared_count", "FP Popularité", lambda v: min(1.0, math.log1p(float(v or 0)) / math.log1p(500_000))),
# distinct_header_orders : nb de fingerprints distincts pour cette IP
# élevé → rotation de fingerprint → bot (normalisé log1p / log1p(10))
("hfp_distinct_orders", "FP Rotation", lambda v: min(1.0, math.log1p(float(v or 0)) / math.log1p(10))),
# Cookie et Referer : signaux de navigation légitime
("hfp_cookie", "Cookie Présent", lambda v: min(1.0, float(v or 0))),
("hfp_referer", "Referer Présent", lambda v: min(1.0, float(v or 0))),
]
FEATURE_KEYS = [f[0] for f in FEATURES]
FEATURE_NAMES = [f[1] for f in FEATURES]
FEATURE_NORMS = [f[2] for f in FEATURES]
N_FEATURES = len(FEATURES)
# ─── Construction du vecteur de features ─────────────────────────────────────
def build_feature_vector(row: dict) -> list[float]:
"""Construit le vecteur normalisé [0,1]^23 depuis un dict SQL."""
return [norm(row.get(key, 0)) for key, _, norm in FEATURES]
# ─── Standardisation z-score ──────────────────────────────────────────────────
def standardize(X: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Z-score standardisation : chaque feature est centrée et mise à l'échelle
par sa déviation standard.
Ref: Bishop (2006) PRML §9.1 — preprocessing recommandé pour K-means.
Retourne (X_std, mean, std) pour pouvoir projeter de nouveaux points.
"""
mean = X.mean(axis=0)
std = X.std(axis=0)
std[std < 1e-8] = 1.0 # évite la division par zéro pour features constantes
return (X - mean) / std, mean, std
# ─── K-means++ vectorisé (numpy) ─────────────────────────────────────────────
@dataclass
class KMeansResult:
centroids: np.ndarray # (k, n_features)
labels: np.ndarray # (n_points,) int32
inertia: float
n_iter: int
def kmeans_pp(X: np.ndarray, k: int, max_iter: int = 60, n_init: int = 3,
seed: int = 42) -> KMeansResult:
"""
K-means++ entièrement vectorisé avec numpy.
n_init exécutions, meilleure inertie conservée.
"""
rng = np.random.default_rng(seed)
n, d = X.shape
best: KMeansResult | None = None
for _ in range(n_init):
# ── Initialisation K-means++ ──────────────────────────────────────
centers = [X[rng.integers(n)].copy()]
for _ in range(k - 1):
D = _min_sq_dist(X, np.array(centers))
# Garantit des probabilités non-négatives (erreurs float, points dupliqués)
D = np.clip(D, 0.0, None)
total = D.sum()
if total < 1e-12:
# Tous les points sont confondus — tirage aléatoire
centers.append(X[rng.integers(n)].copy())
else:
probs = D / total
centers.append(X[rng.choice(n, p=probs)].copy())
centers_arr = np.array(centers) # (k, d)
# ── Iterations ───────────────────────────────────────────────────
labels = np.zeros(n, dtype=np.int32)
for it in range(max_iter):
# Assignation vectorisée : (n, k) distance²
dists = _sq_dists(X, centers_arr) # (n, k)
new_labels = np.argmin(dists, axis=1).astype(np.int32)
if it > 0 and np.all(new_labels == labels):
break # convergence
labels = new_labels
# Mise à jour des centroïdes
for j in range(k):
mask = labels == j
if mask.any():
centers_arr[j] = X[mask].mean(axis=0)
inertia = float(np.sum(np.min(_sq_dists(X, centers_arr), axis=1)))
result = KMeansResult(centers_arr, labels, inertia, it + 1)
if best is None or inertia < best.inertia:
best = result
return best # type: ignore[return-value]
def _sq_dists(X: np.ndarray, C: np.ndarray) -> np.ndarray:
"""Distance² entre chaque point de X et chaque centroïde de C. O(n·k·d)."""
# ||x - c||² = ||x||² + ||c||² - 2·x·cᵀ
X2 = np.sum(X ** 2, axis=1, keepdims=True) # (n, 1)
C2 = np.sum(C ** 2, axis=1, keepdims=True).T # (1, k)
return X2 + C2 - 2.0 * X @ C.T # (n, k)
def _min_sq_dist(X: np.ndarray, C: np.ndarray) -> np.ndarray:
"""Distance² minimale de chaque point aux centroïdes existants."""
return np.min(_sq_dists(X, C), axis=1)
# ─── PCA 2D (numpy) ──────────────────────────────────────────────────────────
def pca_2d(X: np.ndarray) -> np.ndarray:
"""
PCA-2D vectorisée. Retourne les coordonnées normalisées [0,1] × [0,1].
"""
mean = X.mean(axis=0)
Xc = X - mean
# Power iteration pour les 2 premières composantes
rng = np.random.default_rng(0)
v1 = _power_iter(Xc, rng.standard_normal(Xc.shape[1]))
proj1 = Xc @ v1
# Déflation (Hotelling)
Xc2 = Xc - np.outer(proj1, v1)
v2 = _power_iter(Xc2, rng.standard_normal(Xc.shape[1]))
proj2 = Xc2 @ v2
coords = np.column_stack([proj1, proj2])
# Normalisation [0,1]
mn, mx = coords.min(axis=0), coords.max(axis=0)
rng_ = mx - mn
rng_[rng_ == 0] = 1.0
return (coords - mn) / rng_
def _power_iter(X: np.ndarray, v: np.ndarray, n_iter: int = 30) -> np.ndarray:
"""Power iteration : trouve le premier vecteur propre de XᵀX."""
for _ in range(n_iter):
v = X.T @ (X @ v)
norm = np.linalg.norm(v)
if norm < 1e-12:
break
v /= norm
return v
# ─── Enveloppe convexe (hull) par cluster ────────────────────────────────────
def compute_hulls(coords_2d: np.ndarray, labels: np.ndarray,
k: int, min_pts: int = 4) -> dict[int, list[list[float]]]:
"""
Calcule l'enveloppe convexe (convex hull) des points PCA pour chaque cluster.
Retourne {cluster_idx: [[x,y], ...]} (polygone fermé).
"""
hulls: dict[int, list[list[float]]] = {}
for j in range(k):
pts = coords_2d[labels == j]
if len(pts) < min_pts:
# Pas assez de points : bounding box
if len(pts) > 0:
mx_, my_ = pts.mean(axis=0)
r = max(0.01, pts.std(axis=0).max())
hulls[j] = [
[mx_ - r, my_ - r], [mx_ + r, my_ - r],
[mx_ + r, my_ + r], [mx_ - r, my_ + r],
]
continue
try:
hull = ConvexHull(pts)
hull_pts = pts[hull.vertices].tolist()
# Fermer le polygone
hull_pts.append(hull_pts[0])
hulls[j] = hull_pts
except Exception:
hulls[j] = []
return hulls
# ─── Nommage et scoring ───────────────────────────────────────────────────────
def name_cluster(centroid: np.ndarray, raw_stats: dict) -> str:
"""Nom lisible basé sur les features dominantes du centroïde [0,1]."""
s = centroid
n = len(s)
ttl_raw = float(raw_stats.get("mean_ttl", 0))
mss_raw = float(raw_stats.get("mean_mss", 0))
country_risk_v = s[20] if n > 20 else 0.0
asn_cloud = s[21] if n > 21 else 0.0
accept_lang = s[22] if n > 22 else 1.0
accept_enc = s[23] if n > 23 else 1.0
sec_fetch = s[24] if n > 24 else 0.0
hdr_count = s[25] if n > 25 else 0.5
hfp_popular = s[26] if n > 26 else 0.5
hfp_rotating = s[27] if n > 27 else 0.0
# Scanner pur : aucun header browser, fingerprint rare, peu de headers
if accept_lang < 0.15 and accept_enc < 0.15 and hdr_count < 0.25:
return "🤖 Scanner pur (no headers)"
# Fingerprint tournant : bot qui change de profil headers
if hfp_rotating > 0.6:
return "🔄 Bot fingerprint tournant"
# Fingerprint très rare : bot artisanal unique
if hfp_popular < 0.15:
return "🕵️ Fingerprint rare suspect"
# Scanners Masscan
if s[0] > 0.16 and s[0] < 0.25 and mss_raw in range(1440, 1460) and s[2] > 0.25:
return "🤖 Masscan Scanner"
# Bots offensifs agressifs (fuzzing élevé)
if s[4] > 0.40 and s[5] > 0.3:
return "🤖 Bot agressif"
# Bot qui simule un navigateur mais sans les vrais headers
if s[15] > 0.40 and sec_fetch < 0.2 and accept_lang < 0.3:
return "🤖 Bot UA simulé"
# Pays à très haut risque avec infrastructure cloud
if country_risk_v > 0.75 and asn_cloud > 0.5:
return "🌏 Source pays risqué"
# Cloud + UA-CH mismatch
if s[15] > 0.50 and asn_cloud > 0.70:
return "☁️ Bot cloud UA-CH"
if s[15] > 0.60:
return "🤖 UA-CH Mismatch"
# Headless browser (Puppeteer/Playwright) : a les headers Sec-Fetch mais headless
if s[6] > 0.50 and sec_fetch > 0.5:
return "🤖 Headless Browser"
if s[6] > 0.50:
return "🤖 Headless (no Sec-Fetch)"
# Cloud pur (CDN/crawler légitime ?)
if asn_cloud > 0.85:
return "☁️ Infrastructure cloud"
# Pays à risque élevé sans autre signal
if country_risk_v > 0.60:
return "🌏 Trafic suspect (pays)"
# Navigateur légitime : tous les signaux positifs y compris fingerprint populaire
if (accept_lang > 0.7 and accept_enc > 0.7 and sec_fetch > 0.5
and hdr_count > 0.5 and hfp_popular > 0.5):
return "🌐 Navigateur légitime"
# OS fingerprinting
if s[3] > 0.85 and ttl_raw > 120:
return "🖥️ Windows"
if s[0] > 0.22 and s[0] < 0.28 and mss_raw > 1400:
return "🐧 Linux"
if mss_raw < 1380 and mss_raw > 0:
return "🌐 Tunnel réseau"
if s[4] > 0.40:
return "⚡ Trafic rapide"
if s[4] < 0.10 and asn_cloud < 0.30:
return "✅ Trafic sain"
return "📊 Cluster mixte"
def risk_score_from_centroid(centroid: np.ndarray) -> float:
"""
Score de risque [0,1] depuis le centroïde (espace original [0,1]).
30 features (avg_score supprimé) — poids calibrés pour sommer à 1.0.
Indices décalés de -1 après suppression de avg_score (ancien idx 4).
"""
s = centroid
n = len(s)
country_risk_v = s[20] if n > 20 else 0.0
asn_cloud = s[21] if n > 21 else 0.0
no_accept_lang = 1.0 - (s[22] if n > 22 else 1.0)
no_encoding = 1.0 - (s[23] if n > 23 else 1.0)
no_sec_fetch = 1.0 - (s[24] if n > 24 else 0.0)
few_headers = 1.0 - (s[25] if n > 25 else 0.5)
hfp_rare = 1.0 - (s[26] if n > 26 else 0.5)
hfp_rotating = s[27] if n > 27 else 0.0
# [4]=vélocité [5]=fuzzing [6]=headless [8]=ip_id_zero [15]=ua_ch_mismatch
# Poids redistribués depuis l'ancien score ML anomalie (0.25) vers les signaux restants
return float(np.clip(
0.14 * s[5] + # fuzzing
0.17 * s[15] + # UA-CH mismatch (fort signal impersonation navigateur)
0.10 * s[6] + # headless
0.09 * s[4] + # vélocité (rps)
0.07 * s[8] + # IP-ID zéro
0.09 * country_risk_v+ # risque pays source
0.06 * asn_cloud + # infrastructure cloud/VPN
0.04 * no_accept_lang+ # absence Accept-Language
0.04 * no_encoding + # absence Accept-Encoding
0.04 * no_sec_fetch + # absence Sec-Fetch
0.04 * few_headers + # très peu de headers
0.06 * hfp_rare + # fingerprint rare = suspect
0.06 * hfp_rotating, # rotation de fingerprint = bot
0.0, 1.0
))
# ─── Gradient de couleur basé sur le score de non-humanité ──────────────────
# Le score [0,1] est mappé sur un dégradé HSL traversant tout le spectre :
# bleu (humain) → cyan → vert → jaune-vert → jaune → orange → rouge (bot pur)
# Hue : 220° (bleu froid) → 0° (rouge vif) en passant par tout l'arc chromatique.
def _hsl_to_hex(h: float, s: float, l: float) -> str:
"""Convertit HSL (h:0-360, s:0-100, l:0-100) en chaîne '#rrggbb'."""
s /= 100.0
l /= 100.0
c = (1.0 - abs(2.0 * l - 1.0)) * s
x = c * (1.0 - abs((h / 60.0) % 2.0 - 1.0))
m = l - c / 2.0
if h < 60: r, g, b = c, x, 0.0
elif h < 120: r, g, b = x, c, 0.0
elif h < 180: r, g, b = 0.0, c, x
elif h < 240: r, g, b = 0.0, x, c
elif h < 300: r, g, b = x, 0.0, c
else: r, g, b = c, 0.0, x
ri, gi, bi = int((r + m) * 255), int((g + m) * 255), int((b + m) * 255)
return f"#{ri:02x}{gi:02x}{bi:02x}"
def risk_to_gradient_color(risk: float) -> str:
"""
Mappe un score de non-humanité [0,1] sur un dégradé HSL continu multi-stop.
risk = 0.0 → hue 220° (bleu froid — trafic humain légitime)
risk = 0.25 → hue 165° (cyan-vert — léger signal suspect)
risk = 0.50 → hue 110° (vert-jaune — comportement mixte)
risk = 0.75 → hue 55° (jaune-orange — probable bot)
risk = 1.0 → hue 0° (rouge vif — bot confirmé)
La saturation monte légèrement avec le risque pour accentuer la lisibilité.
"""
r = float(np.clip(risk, 0.0, 1.0))
hue = (1.0 - r) * 220.0 # 220° → 0°
saturation = 70.0 + r * 20.0 # 70% → 90%
lightness = 58.0 - r * 10.0 # 58% → 48% (plus sombre = plus alarmant)
return _hsl_to_hex(hue, saturation, lightness)

View File

@ -1,312 +0,0 @@
"""
Services de réputation IP - Bases de données publiques sans clé API
"""
import httpx
from typing import Optional, Dict, Any
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
# Timeout pour les requêtes HTTP
HTTP_TIMEOUT = 10.0
class IPReputationService:
"""
Service de réputation IP utilisant des bases de données publiques gratuites
"""
def __init__(self):
self.http_client = httpx.AsyncClient(timeout=HTTP_TIMEOUT)
# Sources de réputation (sans clé API)
self.sources = {
'ip_api': 'http://ip-api.com/json/{ip}',
'ipinfo': 'https://ipinfo.io/{ip}/json',
}
async def get_reputation(self, ip: str) -> Dict[str, Any]:
"""
Récupère la réputation d'une IP depuis toutes les sources disponibles
Args:
ip: Adresse IP à vérifier
Returns:
Dict avec les informations de réputation agrégées
"""
results = {
'ip': ip,
'timestamp': datetime.utcnow().isoformat(),
'sources': {},
'aggregated': {
'is_proxy': False,
'is_hosting': False,
'is_vpn': False,
'is_tor': False,
'threat_score': 0,
'threat_level': 'unknown',
'country': None,
'asn': None,
'org': None,
'warnings': []
}
}
# Interroge chaque source
for source_name, url_template in self.sources.items():
try:
url = url_template.format(ip=ip)
response = await self.http_client.get(url)
if response.status_code == 200:
data = response.json()
results['sources'][source_name] = self._parse_source_data(source_name, data)
else:
logger.warning(f"Source {source_name} returned status {response.status_code}")
results['sources'][source_name] = {'error': f'Status {response.status_code}'}
except httpx.TimeoutException:
logger.warning(f"Timeout for source {source_name}")
results['sources'][source_name] = {'error': 'Timeout'}
except Exception as e:
logger.error(f"Error fetching from {source_name}: {str(e)}")
results['sources'][source_name] = {'error': str(e)}
# Agrège les résultats
results['aggregated'] = self._aggregate_results(results['sources'])
return results
def _parse_source_data(self, source: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""
Parse les données d'une source spécifique
"""
if source == 'ip_api':
return self._parse_ip_api(data)
elif source == 'ipinfo':
return self._parse_ipinfo(data)
return data
def _parse_ip_api(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""
Parse les données de IP-API.com
Response example:
{
"status": "success",
"country": "France",
"countryCode": "FR",
"region": "IDF",
"regionName": "Île-de-France",
"city": "Paris",
"zip": "75001",
"lat": 48.8534,
"lon": 2.3488,
"timezone": "Europe/Paris",
"isp": "OVH SAS",
"org": "OVH SAS",
"as": "AS16276 OVH SAS",
"asname": "OVH",
"mobile": false,
"proxy": false,
"hosting": true,
"query": "51.15.0.1"
}
"""
if data.get('status') != 'success':
return {'error': data.get('message', 'Unknown error')}
# Extraire l'ASN
asn_full = data.get('as', '')
asn_number = None
asn_org = None
if asn_full:
parts = asn_full.split(' ', 1)
if len(parts) >= 1:
asn_number = parts[0].replace('AS', '')
if len(parts) >= 2:
asn_org = parts[1]
return {
'country': data.get('country'),
'country_code': data.get('countryCode'),
'region': data.get('regionName'),
'city': data.get('city'),
'isp': data.get('isp'),
'org': data.get('org'),
'asn': asn_number,
'asn_org': asn_org,
'is_proxy': data.get('proxy', False),
'is_hosting': data.get('hosting', False),
'is_mobile': data.get('mobile', False),
'timezone': data.get('timezone'),
'lat': data.get('lat'),
'lon': data.get('lon'),
'query': data.get('query')
}
def _parse_ipinfo(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""
Parse les données de IPinfo.io
Response example:
{
"ip": "51.15.0.1",
"city": "Paris",
"region": "Île-de-France",
"country": "FR",
"loc": "48.8534,2.3488",
"org": "AS16276 OVH SAS",
"postal": "75001",
"timezone": "Europe/Paris",
"readme": "https://ipinfo.io/missingauth"
}
"""
# Extraire l'ASN
org_full = data.get('org', '')
asn_number = None
asn_org = None
if org_full:
parts = org_full.split(' ', 1)
if len(parts) >= 1:
asn_number = parts[0].replace('AS', '')
if len(parts) >= 2:
asn_org = parts[1]
# Extraire lat/lon
loc = data.get('loc', '')
lat = None
lon = None
if loc:
coords = loc.split(',')
if len(coords) == 2:
lat = float(coords[0])
lon = float(coords[1])
return {
'ip': data.get('ip'),
'city': data.get('city'),
'region': data.get('region'),
'country': data.get('country'),
'postal': data.get('postal'),
'timezone': data.get('timezone'),
'asn': asn_number,
'asn_org': asn_org,
'org': data.get('org'),
'lat': lat,
'lon': lon
}
def _aggregate_results(self, sources: Dict[str, Any]) -> Dict[str, Any]:
"""
Agrège les résultats de toutes les sources
Logique d'agrégation:
- is_proxy: true si au moins une source le détecte
- is_hosting: true si au moins une source le détecte
- threat_score: basé sur les détections proxy/hosting/vpn/tor
- threat_level: low/medium/high/critical basé sur le score
"""
aggregated = {
'is_proxy': False,
'is_hosting': False,
'is_vpn': False,
'is_tor': False,
'threat_score': 0,
'threat_level': 'unknown',
'country': None,
'country_code': None,
'asn': None,
'asn_org': None,
'org': None,
'city': None,
'warnings': []
}
threat_score = 0
for source_name, source_data in sources.items():
if 'error' in source_data:
continue
# Proxy detection
if source_data.get('is_proxy'):
aggregated['is_proxy'] = True
threat_score += 30
aggregated['warnings'].append(f'{source_name}: Proxy détecté')
# Hosting detection
if source_data.get('is_hosting'):
aggregated['is_hosting'] = True
threat_score += 20
aggregated['warnings'].append(f'{source_name}: Hébergement cloud/datacenter')
# VPN detection (si disponible)
if source_data.get('is_vpn'):
aggregated['is_vpn'] = True
threat_score += 40
aggregated['warnings'].append(f'{source_name}: VPN détecté')
# Tor detection (si disponible)
if source_data.get('is_tor'):
aggregated['is_tor'] = True
threat_score += 50
aggregated['warnings'].append(f'{source_name}: Exit node Tor détecté')
# Infos géographiques (prend la première disponible)
if not aggregated['country'] and source_data.get('country'):
aggregated['country'] = source_data.get('country')
if not aggregated['country_code'] and source_data.get('country_code'):
aggregated['country_code'] = source_data.get('country_code')
# ASN (prend la première disponible)
if not aggregated['asn'] and source_data.get('asn'):
aggregated['asn'] = source_data.get('asn')
if not aggregated['asn_org'] and source_data.get('asn_org'):
aggregated['asn_org'] = source_data.get('asn_org')
# Organisation/ISP
if not aggregated['org'] and source_data.get('org'):
aggregated['org'] = source_data.get('org')
# Ville
if not aggregated['city'] and source_data.get('city'):
aggregated['city'] = source_data.get('city')
# Calcul du niveau de menace
aggregated['threat_score'] = min(100, threat_score)
if threat_score >= 80:
aggregated['threat_level'] = 'critical'
elif threat_score >= 60:
aggregated['threat_level'] = 'high'
elif threat_score >= 40:
aggregated['threat_level'] = 'medium'
elif threat_score >= 20:
aggregated['threat_level'] = 'low'
else:
aggregated['threat_level'] = 'clean'
return aggregated
async def close(self):
"""Ferme le client HTTP"""
await self.http_client.aclose()
# Singleton pour réutiliser le service
_reputation_service: Optional[IPReputationService] = None
def get_reputation_service() -> IPReputationService:
"""Retourne l'instance singleton du service de réputation"""
global _reputation_service
if _reputation_service is None:
_reputation_service = IPReputationService()
return _reputation_service

View File

@ -1,436 +0,0 @@
"""
Service de fingerprinting OS par signature TCP — approche multi-signal inspirée de p0f.
Signaux utilisés (par ordre de poids) :
1. TTL initial estimé (→ famille OS : Linux/Mac=64, Windows=128, Cisco/BSD=255)
2. MSS (→ type de réseau : Ethernet=1460, PPPoE=1452, VPN=1380-1420)
3. Taille de fenêtre (→ signature OS précise)
4. Facteur d'échelle (→ affine la version du kernel/stack TCP)
Références :
- p0f v3 (Michal Zalewski) — passive OS fingerprinting
- Nmap OS detection (Gordon Lyon)
- "OS Fingerprinting Revisited" (Beverly, 2004)
- "Passive OS fingerprinting" (Orebaugh, Ramirez)
- Recherche sur Masscan/ZMap : signatures SYN craftées connues
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
# ─── Constantes ───────────────────────────────────────────────────────────────
_INITIAL_TTLS = (64, 128, 255)
# MSS → type de chemin réseau (MTU - 40 octets d'en-têtes IP+TCP)
_MSS_PATH: list[tuple[range, str]] = [
(range(1461, 9001), "Ethernet/Jumbo"), # jumbo frames (CDN/datacenter)
(range(1460, 1461), "Ethernet directe"), # MTU 1500 standard
(range(1453, 1460), "Ethernet directe"), # légèrement réduit (padding)
(range(1452, 1453), "PPPoE/DSL"), # MTU 1492
(range(1436, 1452), "PPPoE/DSL ajusté"), # variations DSL
(range(1420, 1436), "VPN léger"), # WireGuard / IPsec transport
(range(1380, 1420), "VPN/Tunnel"), # OpenVPN / L2TP
(range(1300, 1380), "VPN double ou mobile"),
(range(0, 1300), "Lien bas débit / GPRS"),
]
# ─── Base de signatures OS ─────────────────────────────────────────────────────
#
# Format : chaque entrée est un dict avec :
# ttl : int — TTL initial attendu (64 | 128 | 255)
# win : set[int]|None — tailles de fenêtre attendues (None = ignorer)
# scale : set[int]|None — facteurs d'échelle attendus (None = ignorer)
# mss : set[int]|None — valeurs MSS attendues (None = ignorer)
# name : str — libellé affiché
# conf : float — poids de confiance de base (01)
# bot : bool — outil de scan/bot connu
_SIGNATURES: list[dict] = [
# ══════════════════════════════════════════════════════
# OUTILS DE SCAN ET BOTS CONNUS (priorité maximale)
# ══════════════════════════════════════════════════════
# Masscan / scanner personnalisé avec stack Linux modifiée (PPPoE MSS=1452)
# Pattern très présent dans les données : ~111k requêtes, UA spoofé macOS/Windows
{
"ttl": 64, "win": {5808}, "scale": {4}, "mss": {1452},
"name": "Bot-Scanner/Masscan", "conf": 0.97, "bot": True,
},
# Masscan TTL=255 (mode direct, pas de hop)
{
"ttl": 255, "win": {1024}, "scale": {0}, "mss": None,
"name": "Bot-ZMap/Masscan", "conf": 0.96, "bot": True,
},
# Mirai variant (petite fenêtre, pas de scale, TTL Linux)
{
"ttl": 64, "win": {1024, 2048}, "scale": {0}, "mss": {1460},
"name": "Bot-Mirai", "conf": 0.92, "bot": True,
},
# Mirai variant (petite fenêtre Windows)
{
"ttl": 128, "win": {1024, 2048}, "scale": {0}, "mss": {1460},
"name": "Bot-Mirai/Win", "conf": 0.92, "bot": True,
},
# Scapy / forge manuelle (fenêtre 8192 exactement + TTL 64 + pas de scale)
{
"ttl": 64, "win": {8192}, "scale": {0}, "mss": {1460},
"name": "Bot-Scapy/Forge", "conf": 0.85, "bot": True,
},
# Nmap SYN scan (window=1024, MSS=1460, TTL=64 ou 128)
{
"ttl": 64, "win": {1}, "scale": None, "mss": None,
"name": "Bot-ZMap", "conf": 0.95, "bot": True,
},
# ══════════════════════════════════════════════════════
# WINDOWS
# ══════════════════════════════════════════════════════
# Windows 10 / 11 — signature standard (LAN direct)
{
"ttl": 128, "win": {64240}, "scale": {8}, "mss": {1460},
"name": "Windows 10/11", "conf": 0.93, "bot": False,
},
# Windows 10/11 — derrière VPN/proxy (MSS réduit)
{
"ttl": 128, "win": {64240}, "scale": {8}, "mss": {1380, 1400, 1412, 1420, 1440},
"name": "Windows 10/11 (VPN)", "conf": 0.90, "bot": False,
},
# Windows Server 2019/2022 — grande fenêtre
{
"ttl": 128, "win": {65535, 131072}, "scale": {8, 9}, "mss": {1460},
"name": "Windows Server", "conf": 0.88, "bot": False,
},
# Windows 7/8.1
{
"ttl": 128, "win": {8192, 65535}, "scale": {4, 8}, "mss": {1460},
"name": "Windows 7/8", "conf": 0.83, "bot": False,
},
# Windows générique (TTL=128, scale=8, tout MSS)
{
"ttl": 128, "win": None, "scale": {8}, "mss": None,
"name": "Windows", "conf": 0.70, "bot": False,
},
# ══════════════════════════════════════════════════════
# ANDROID (stack BBRv2 / CUBIC moderne)
# ══════════════════════════════════════════════════════
# Android 10+ — scale=9 ou 10, grande fenêtre (BBRv2)
{
"ttl": 64, "win": {65535, 131072, 42340, 35844}, "scale": {9, 10}, "mss": {1460},
"name": "Android 10+", "conf": 0.82, "bot": False,
},
# Android via proxy TTL=128 (app Facebook, TikTok etc. passant par infra)
{
"ttl": 128, "win": {62727, 65535}, "scale": {7}, "mss": {1460},
"name": "Android/App (proxy)", "conf": 0.75, "bot": False,
},
# Android derrière VPN (MSS réduit)
{
"ttl": 64, "win": {65535, 59640, 63940}, "scale": {8, 9, 10}, "mss": {1380, 1390, 1400, 1418, 1420},
"name": "Android (VPN/mobile)", "conf": 0.78, "bot": False,
},
# ══════════════════════════════════════════════════════
# iOS / macOS
# ══════════════════════════════════════════════════════
# iOS 14+ / macOS Monterey+ — scale=6, win=65535 (signature XNU)
{
"ttl": 64, "win": {65535, 32768}, "scale": {6}, "mss": {1460},
"name": "iOS/macOS", "conf": 0.87, "bot": False,
},
# macOS Sonoma+ / iOS 17+ (scale=9, fenêtre plus grande)
{
"ttl": 64, "win": {65535, 32768}, "scale": {9}, "mss": {1460},
"name": "macOS Sonoma+/iOS 17+", "conf": 0.83, "bot": False,
},
# macOS derrière VPN (MSS réduit)
{
"ttl": 64, "win": {65535}, "scale": {6, 9}, "mss": {1380, 1400, 1412, 1436},
"name": "iOS/macOS (VPN)", "conf": 0.80, "bot": False,
},
# ══════════════════════════════════════════════════════
# LINUX (desktop/serveur)
# ══════════════════════════════════════════════════════
# Linux 5.x+ — scale=7, win=64240 ou 65535 (kernel ≥ 4.19)
{
"ttl": 64, "win": {64240, 65320}, "scale": {7}, "mss": {1460},
"name": "Linux 5.x+", "conf": 0.86, "bot": False,
},
# Linux 4.x / ChromeOS
{
"ttl": 64, "win": {29200, 65535, 43690, 32120}, "scale": {7}, "mss": {1460},
"name": "Linux 4.x/ChromeOS", "conf": 0.83, "bot": False,
},
# Linux derrière VPN (MSS réduit)
{
"ttl": 64, "win": {64240, 65535, 42600}, "scale": {7}, "mss": {1380, 1400, 1420, 1436},
"name": "Linux (VPN)", "conf": 0.80, "bot": False,
},
# Linux 2.6.x (ancien — win=5840/14600)
{
"ttl": 64, "win": {5840, 14600, 16384}, "scale": {4, 5}, "mss": {1460},
"name": "Linux 2.6", "conf": 0.78, "bot": False,
},
# ══════════════════════════════════════════════════════
# BSD / ÉQUIPEMENTS RÉSEAU / CDN
# ══════════════════════════════════════════════════════
# FreeBSD / OpenBSD (initial TTL=64)
{
"ttl": 64, "win": {65535}, "scale": {6}, "mss": {512, 1460},
"name": "FreeBSD/OpenBSD", "conf": 0.74, "bot": False,
},
# Cisco IOS / équipements réseau (initial TTL=255, fenêtre petite)
{
"ttl": 255, "win": {4096, 4128, 8760}, "scale": {0, 1, 2}, "mss": {512, 1460},
"name": "Cisco/Réseau", "conf": 0.87, "bot": False,
},
# CDN / Applebot (TTL=255, jumbo MSS, fenêtre élevée)
{
"ttl": 255, "win": {26883, 65535, 59640}, "scale": {7, 8}, "mss": {8373, 8365, 1460},
"name": "CDN/Applebot (jumbo)", "conf": 0.85, "bot": False,
},
# BSD/Unix générique (TTL=255)
{
"ttl": 255, "win": None, "scale": {6, 7, 8}, "mss": {1460},
"name": "BSD/Unix", "conf": 0.68, "bot": False,
},
]
# ─── Data classes ──────────────────────────────────────────────────────────────
@dataclass
class OsFingerprint:
os_name: str
initial_ttl: int
hop_count: int
confidence: float
is_bot_tool: bool
network_path: str
@dataclass
class SpoofResult:
is_spoof: bool
is_bot_tool: bool
reason: str
# ─── Fonctions utilitaires ─────────────────────────────────────────────────────
def _estimate_initial_ttl(observed_ttl: int) -> tuple[int, int]:
"""Retourne (initial_ttl, hop_count).
Cherche le TTL standard le plus bas >= observed_ttl.
Rejette les hop counts > 45 (réseau légitimement long = peu probable).
"""
if observed_ttl <= 0:
return 0, -1
for initial in _INITIAL_TTLS:
if observed_ttl <= initial:
hop = initial - observed_ttl
if hop <= 45:
return initial, hop
return 255, 255 - observed_ttl # TTL > 255 impossible, fallback
def _infer_network_path(mss: int) -> str:
"""Retourne le type de chemin réseau probable à partir du MSS."""
if mss <= 0:
return "Inconnu"
for rng, label in _MSS_PATH:
if mss in rng:
return label
return "Inconnu"
def _os_family(os_name: str) -> str:
"""Réduit un nom OS détaillé à sa famille pour comparaison avec l'UA."""
n = os_name.lower()
if "windows" in n:
return "Windows"
if "android" in n:
return "Android"
if "ios" in n or "macos" in n or "iphone" in n or "ipad" in n:
return "Apple"
if "linux" in n or "chromeos" in n:
return "Linux"
if "bsd" in n or "cisco" in n or "cdn" in n or "réseau" in n:
return "Network"
if "bot" in n or "scanner" in n or "mirai" in n or "zmap" in n:
return "Bot"
return "Unknown"
def _ua_os_family(declared_os: str) -> str:
"""Réduit l'OS déclaré (UA) à sa famille."""
mapping = {
"Windows": "Windows",
"Android": "Android",
"iOS": "Apple",
"macOS": "Apple",
"Linux": "Linux",
"ChromeOS": "Linux",
"BSD": "Network",
}
return mapping.get(declared_os, "Unknown")
# ─── Fonctions publiques ───────────────────────────────────────────────────────
def declared_os_from_ua(ua: str) -> str:
"""Infère l'OS déclaré à partir du User-Agent."""
ua = ua or ""
ul = ua.lower()
if not ul:
return "Unknown"
if "windows nt" in ul:
return "Windows"
if "android" in ul:
return "Android"
if "iphone" in ul or "ipad" in ul or "cpu iphone" in ul or "cpu ipad" in ul:
return "iOS"
if "mac os x" in ul or "macos" in ul:
return "macOS"
if "cros" in ul or "chromeos" in ul:
return "ChromeOS"
if "linux" in ul:
return "Linux"
if "freebsd" in ul or "openbsd" in ul or "netbsd" in ul:
return "BSD"
return "Unknown"
def fingerprint_os(ttl: int, win: int, scale: int, mss: int) -> OsFingerprint:
"""Fingerprint OS multi-signal avec scoring pondéré.
Poids des signaux :
- TTL initial 40 % (discriminant principal : famille OS)
- MSS 30 % (type de réseau ET OS)
- Fenêtre TCP 20 % (version/distrib précise)
- Scale 10 % (affine la version kernel)
"""
initial_ttl, hop_count = _estimate_initial_ttl(ttl)
network_path = _infer_network_path(mss)
if initial_ttl == 0:
return OsFingerprint(
os_name="Unknown", initial_ttl=0, hop_count=-1,
confidence=0.0, is_bot_tool=False, network_path=network_path,
)
best_score: float = -1.0
best_sig: Optional[dict] = None
for sig in _SIGNATURES:
# Le TTL est un filtre strict — pas de correspondance, on passe
if sig["ttl"] != initial_ttl:
continue
score: float = 0.40 # Score de base pour correspondance TTL
# MSS (poids 0.30)
if sig["mss"] is not None:
score += 0.30 if mss in sig["mss"] else -0.12
# Fenêtre (poids 0.20)
if sig["win"] is not None:
score += 0.20 if win in sig["win"] else -0.08
# Scale (poids 0.10)
if sig["scale"] is not None:
score += 0.10 if scale in sig["scale"] else -0.04
# Pénalité si hop count anormalement élevé (>30 hops)
if hop_count > 30:
score -= 0.05
if score > best_score:
best_score = score
best_sig = sig
if best_sig and best_score >= 0.38:
# Pondérer la confiance finale par le score et le conf de la signature
raw_conf = best_score * best_sig["conf"]
confidence = round(min(max(raw_conf, 0.0), 1.0), 2)
return OsFingerprint(
os_name=best_sig["name"],
initial_ttl=initial_ttl,
hop_count=hop_count,
confidence=confidence,
is_bot_tool=best_sig["bot"],
network_path=network_path,
)
# Repli : classification TTL seule (confiance minimale)
fallback = {64: "Linux/macOS", 128: "Windows", 255: "Cisco/BSD"}
return OsFingerprint(
os_name=fallback.get(initial_ttl, "Unknown"),
initial_ttl=initial_ttl,
hop_count=hop_count,
confidence=round(0.40 * 0.65, 2), # confiance faible
is_bot_tool=False,
network_path=network_path,
)
def detect_spoof(fp: OsFingerprint, declared_os: str) -> SpoofResult:
"""Détecte les incohérences OS entre TCP et UA.
Règles :
1. Outil de scan connu → spoof/bot, quelle que soit l'UA
2. Confiance < 0.50 → indéterminable
3. OS incompatibles → spoof confirmé
4. Cohérent → OK
"""
if fp.is_bot_tool:
return SpoofResult(
is_spoof=True,
is_bot_tool=True,
reason=f"Outil de scan détecté ({fp.os_name})",
)
if fp.confidence < 0.50 or fp.os_name == "Unknown" or declared_os == "Unknown":
return SpoofResult(
is_spoof=False,
is_bot_tool=False,
reason="Corrélation insuffisante",
)
tcp_family = _os_family(fp.os_name)
ua_family = _ua_os_family(declared_os)
# Les familles Network/Bot sont toujours suspectes si l'UA prétend être un navigateur
if tcp_family == "Network" and ua_family not in ("Network", "Unknown"):
return SpoofResult(
is_spoof=True,
is_bot_tool=False,
reason=f"Équipement réseau/CDN (TCP) vs {declared_os} (UA)",
)
if tcp_family == "Unknown" or ua_family == "Unknown":
return SpoofResult(is_spoof=False, is_bot_tool=False, reason="OS indéterminé")
# Android passant par un proxy infra (ex: Facebook app → proxy Windows)
# → pas forcément un spoof, noté mais non flaggé
if declared_os == "Android" and tcp_family == "Windows" and "proxy" in fp.os_name.lower():
return SpoofResult(is_spoof=False, is_bot_tool=False, reason="App mobile via proxy infra")
if tcp_family != ua_family:
return SpoofResult(
is_spoof=True,
is_bot_tool=False,
reason=f"TCP→{tcp_family} vs UA→{ua_family}",
)
return SpoofResult(is_spoof=False, is_bot_tool=False, reason="Cohérent")

View File

@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="fr" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}JA4 SOC Dashboard{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: { 50:'#eef2ff',100:'#e0e7ff',500:'#6366f1',600:'#4f46e5',700:'#4338ca',900:'#312e81' },
}
}
}
}
</script>
<style>
body { font-family: 'Inter', system-ui, -apple-system, sans-serif; }
.threat-critical { color: #ef4444; font-weight: 700; }
.threat-high { color: #f97316; font-weight: 600; }
.threat-medium { color: #eab308; }
.threat-low { color: #22c55e; }
.threat-normal { color: #6b7280; }
.kpi-card { @apply bg-gray-800 rounded-xl p-5 border border-gray-700; }
.data-table { @apply w-full text-sm text-left; }
.data-table th { @apply px-4 py-3 bg-gray-800 text-gray-300 font-medium border-b border-gray-700 sticky top-0; }
.data-table td { @apply px-4 py-2.5 border-b border-gray-800 text-gray-300; }
.data-table tbody tr:hover { @apply bg-gray-800/50; }
.nav-link { @apply px-4 py-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800 transition-colors text-sm font-medium; }
.nav-link.active { @apply bg-brand-600 text-white; }
.badge { @apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium; }
.badge-critical { @apply bg-red-500/20 text-red-400; }
.badge-high { @apply bg-orange-500/20 text-orange-400; }
.badge-medium { @apply bg-yellow-500/20 text-yellow-400; }
.badge-low { @apply bg-green-500/20 text-green-400; }
.badge-normal { @apply bg-gray-500/20 text-gray-400; }
.badge-known { @apply bg-blue-500/20 text-blue-400; }
.htmx-request .htmx-indicator { display: inline-block; }
.htmx-indicator { display: none; }
.filter-btn { @apply px-3 py-1.5 text-xs rounded-lg border border-gray-700 text-gray-400 hover:border-brand-500 hover:text-brand-500 transition-colors cursor-pointer; }
.filter-btn.active { @apply border-brand-500 bg-brand-500/20 text-brand-500; }
</style>
{% block head %}{% endblock %}
</head>
<body class="bg-gray-950 text-gray-200 min-h-screen">
<!-- Top Nav -->
<nav class="bg-gray-900 border-b border-gray-800 sticky top-0 z-50">
<div class="max-w-[1600px] mx-auto px-4 flex items-center h-14 gap-2">
<a href="/" class="flex items-center gap-2 mr-6">
<div class="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold text-sm">J4</div>
<span class="text-white font-semibold hidden sm:inline">JA4 SOC</span>
</a>
<a href="/" class="nav-link {% if active_page == 'overview' %}active{% endif %}">Overview</a>
<a href="/detections" class="nav-link {% if active_page == 'detections' %}active{% endif %}">Détections</a>
<a href="/scores" class="nav-link {% if active_page == 'scores' %}active{% endif %}">Scores</a>
<a href="/traffic" class="nav-link {% if active_page == 'traffic' %}active{% endif %}">Trafic</a>
<a href="/features" class="nav-link {% if active_page == 'features' %}active{% endif %}">Features</a>
<a href="/models" class="nav-link {% if active_page == 'models' %}active{% endif %}">Modèles</a>
<a href="/classify" class="nav-link {% if active_page == 'classify' %}active{% endif %}">Classifier</a>
<div class="flex-1"></div>
<span class="text-xs text-gray-500" id="clock"></span>
</div>
</nav>
<!-- Content -->
<main class="max-w-[1600px] mx-auto px-4 py-6">
{% block content %}{% endblock %}
</main>
<script>
function updateClock() {
document.getElementById('clock').textContent = new Date().toLocaleString('fr-FR');
}
updateClock(); setInterval(updateClock, 1000);
function threatBadge(level) {
const map = {
'CRITICAL':'badge-critical','HIGH':'badge-high','MEDIUM':'badge-medium',
'LOW':'badge-low','NORMAL':'badge-normal','KNOWN_BOT':'badge-known','ANUBIS_DENY':'badge-critical'
};
return `<span class="badge ${map[level]||'badge-normal'}">${level}</span>`;
}
function fmtIP(ip) {
if (!ip) return '';
let s = String(ip).replace('::ffff:','');
return `<a href="/ip/${encodeURIComponent(s)}" class="text-brand-500 hover:underline">${s}</a>`;
}
function fmtScore(v) {
let n = parseFloat(v);
if (isNaN(n)) return '-';
let color = n > 0.7 ? 'text-red-400' : n > 0.4 ? 'text-orange-400' : n > 0.1 ? 'text-yellow-400' : 'text-green-400';
return `<span class="${color}">${n.toFixed(4)}</span>`;
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block title %}JA4 SOC — Classifier{% endblock %}
{% block content %}
<div class="space-y-6 max-w-2xl">
<h2 class="text-lg font-semibold text-white">Classification SOC</h2>
<div class="bg-gray-900 rounded-xl p-6 border border-gray-800 space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-1">Adresse IP</label>
<input type="text" id="cls-ip" placeholder="ex: 192.168.1.100" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 focus:border-brand-500 focus:outline-none">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Classification</label>
<select id="cls-type" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300">
<option value="bot">🤖 Bot malveillant</option>
<option value="legitimate">✅ Trafic légitime</option>
<option value="suspicious">⚠️ Suspect (à surveiller)</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Commentaire</label>
<textarea id="cls-comment" rows="3" placeholder="Raison de la classification..." class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 focus:border-brand-500 focus:outline-none resize-none"></textarea>
</div>
<button id="cls-submit" class="px-6 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 transition-colors">Envoyer la classification</button>
<div id="cls-result" class="text-sm"></div>
</div>
<!-- Recent classifications -->
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<h3 class="text-sm font-medium text-gray-400 px-5 py-3 border-b border-gray-800">Classifications récentes</h3>
<div class="overflow-x-auto">
<table class="data-table"><thead><tr>
<th>Date</th><th>IP</th><th>Classification</th><th>Commentaire</th>
</tr></thead><tbody id="cls-history"></tbody></table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('cls-submit').onclick = async () => {
const ip = document.getElementById('cls-ip').value.trim();
if (!ip) { alert('Veuillez saisir une IP'); return; }
try {
const r = await fetch('/api/classify', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({src_ip:ip, classification:document.getElementById('cls-type').value, comment:document.getElementById('cls-comment').value})});
const d = await r.json();
document.getElementById('cls-result').innerHTML = r.ok
? `<span class="text-green-400">✓ ${ip} classifié : ${d.classification}</span>`
: `<span class="text-red-400">✗ Erreur : ${d.detail||'unknown'}</span>`;
if (r.ok) loadHistory();
} catch(e) { document.getElementById('cls-result').innerHTML = `<span class="text-red-400">✗ ${e}</span>`; }
};
async function loadHistory() {
try {
const r = await fetch('/api/classifications'); const d = await r.json();
document.getElementById('cls-history').innerHTML = (d.data||[]).map(row => `<tr>
<td class="text-xs">${row.created_at||''}</td>
<td>${fmtIP(row.src_ip)}</td>
<td><span class="badge ${row.classification==='bot'?'badge-critical':row.classification==='legitimate'?'badge-low':'badge-medium'}">${row.classification}</span></td>
<td class="text-xs max-w-[300px] truncate">${row.comment||''}</td>
</tr>`).join('') || '<tr><td colspan="4" class="text-center text-gray-500 py-4">Aucune classification</td></tr>';
} catch(e) {}
}
loadHistory();
</script>
{% endblock %}

View File

@ -0,0 +1,97 @@
{% extends "base.html" %}
{% block title %}JA4 SOC — Détections{% endblock %}
{% block content %}
<div class="space-y-4">
<div class="flex items-center gap-3 flex-wrap">
<h2 class="text-lg font-semibold text-white">Anomalies détectées</h2>
<div class="flex gap-1.5" id="threat-filters">
<button class="filter-btn active" data-filter="">Tous</button>
<button class="filter-btn" data-filter="CRITICAL">Critical</button>
<button class="filter-btn" data-filter="HIGH">High</button>
<button class="filter-btn" data-filter="MEDIUM">Medium</button>
<button class="filter-btn" data-filter="KNOWN_BOT">Known Bot</button>
<button class="filter-btn" data-filter="ANUBIS_DENY">Anubis Deny</button>
</div>
<div class="flex-1"></div>
<input type="text" id="search-input" placeholder="Rechercher IP, host..."
class="px-3 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 w-64 focus:border-brand-500 focus:outline-none">
</div>
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div class="overflow-x-auto max-h-[70vh] overflow-y-auto">
<table class="data-table">
<thead><tr>
<th class="cursor-pointer" data-sort="detected_at">Date ↕</th>
<th>IP</th>
<th class="cursor-pointer" data-sort="anomaly_score">Score ↕</th>
<th>Threat</th>
<th>JA4</th>
<th>Host</th>
<th>Hits</th>
<th>ASN</th>
<th>Pays</th>
<th>Récurrence</th>
<th>Raison</th>
</tr></thead>
<tbody id="detections-body"></tbody>
</table>
</div>
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-800">
<span class="text-xs text-gray-500" id="det-info"></span>
<div class="flex gap-2">
<button id="prev-btn" class="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400 hover:text-white disabled:opacity-30">&larr; Précédent</button>
<button id="next-btn" class="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400 hover:text-white disabled:opacity-30">Suivant &rarr;</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let dPage=1, dSort='detected_at', dOrder='DESC', dThreat='', dSearch='';
async function loadDetections() {
const params = new URLSearchParams({page:dPage,per_page:50,sort:dSort,order:dOrder});
if(dThreat) params.set('threat_level',dThreat);
if(dSearch) params.set('search',dSearch);
try {
const r = await fetch('/api/detections?'+params);
const d = await r.json();
const tbody = document.getElementById('detections-body');
tbody.innerHTML = (d.data||[]).map(row => `<tr>
<td class="text-xs whitespace-nowrap">${row.detected_at||''}</td>
<td class="whitespace-nowrap">${fmtIP(row.src_ip)}</td>
<td>${fmtScore(row.anomaly_score)}</td>
<td>${threatBadge(row.threat_level)}</td>
<td class="text-xs font-mono max-w-[120px] truncate" title="${row.ja4||''}">${row.ja4||''}</td>
<td class="text-xs max-w-[150px] truncate" title="${row.host||''}">${row.host||''}</td>
<td>${row.hits||0}</td>
<td class="text-xs max-w-[150px] truncate">${row.asn_org||''}</td>
<td>${row.country_code||''}</td>
<td>${row.recurrence||0}</td>
<td class="text-xs max-w-[200px] truncate" title="${row.reason||''}">${row.reason||''}</td>
</tr>`).join('') || '<tr><td colspan="11" class="text-center text-gray-500 py-8">Aucune détection</td></tr>';
const total = d.total||0;
document.getElementById('det-info').textContent = `${total} résultats — page ${dPage}/${Math.max(1,Math.ceil(total/50))}`;
document.getElementById('prev-btn').disabled = dPage <= 1;
document.getElementById('next-btn').disabled = dPage * 50 >= total;
} catch(e) { console.error(e); }
}
document.getElementById('prev-btn').onclick = () => { if(dPage>1){dPage--;loadDetections();} };
document.getElementById('next-btn').onclick = () => { dPage++;loadDetections(); };
document.querySelectorAll('[data-sort]').forEach(th => th.onclick = () => {
const s = th.dataset.sort;
if(dSort===s) dOrder = dOrder==='DESC'?'ASC':'DESC'; else { dSort=s; dOrder='DESC'; }
dPage=1; loadDetections();
});
document.querySelectorAll('[data-filter]').forEach(btn => btn.onclick = () => {
document.querySelectorAll('[data-filter]').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
dThreat = btn.dataset.filter; dPage=1; loadDetections();
});
let searchTimeout;
document.getElementById('search-input').oninput = (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => { dSearch=e.target.value; dPage=1; loadDetections(); }, 300);
};
loadDetections();
</script>
{% endblock %}

View File

@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}JA4 SOC — Features ML{% endblock %}
{% block content %}
<div class="space-y-6">
<h2 class="text-lg font-semibold text-white">Features ML — Statistiques agrégées</h2>
<!-- AI Features -->
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Features AI (view_ai_features_1h)</h3>
<div id="ai-stats" class="text-gray-500 text-sm">Chargement...</div>
</div>
<!-- Thesis Features -->
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Features Thèse §5 (view_thesis_features_1h)</h3>
<div id="thesis-stats" class="text-gray-500 text-sm">Chargement...</div>
</div>
<!-- Score distribution chart -->
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Distribution des scores d'anomalie</h3>
<canvas id="score-dist-chart" height="200"></canvas>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function renderStats(data, containerId) {
const el = document.getElementById(containerId);
if (!data || Object.keys(data).length === 0) { el.textContent = 'Aucune donnée disponible'; return; }
el.innerHTML = '<div class="grid grid-cols-2 md:grid-cols-4 gap-3">' +
Object.entries(data).map(([k,v]) => {
let val = typeof v === 'number' ? v.toFixed(4) : v;
return `<div class="bg-gray-800 rounded-lg p-3"><div class="text-[10px] text-gray-500 truncate">${k}</div><div class="text-sm text-gray-200 font-mono">${val}</div></div>`;
}).join('') + '</div>';
}
async function loadFeatures() {
try {
const r = await fetch('/api/features'); const d = await r.json();
renderStats(d.ai_features, 'ai-stats');
renderStats(d.thesis_features, 'thesis-stats');
} catch(e) { console.error(e); }
}
loadFeatures();
</script>
{% endblock %}

View File

@ -0,0 +1,131 @@
{% extends "base.html" %}
{% block title %}JA4 SOC — IP {{ ip }}{% endblock %}
{% block content %}
<div class="space-y-6">
<div class="flex items-center gap-3">
<a href="/detections" class="text-gray-500 hover:text-gray-300">&larr; Retour</a>
<h2 class="text-lg font-semibold text-white">Investigation IP : <span class="text-brand-500">{{ ip }}</span></h2>
</div>
<!-- KPI Row -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4" id="ip-kpis">
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Détections</div><div class="text-xl font-bold text-red-400" id="ip-det-count"></div></div>
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Pire score</div><div class="text-xl font-bold" id="ip-worst-score"></div></div>
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Récurrence</div><div class="text-xl font-bold text-yellow-400" id="ip-recurrence"></div></div>
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Requêtes HTTP</div><div class="text-xl font-bold text-gray-200" id="ip-http-count"></div></div>
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Scores ML</div><div class="text-xl font-bold text-brand-500" id="ip-score-count"></div></div>
</div>
<!-- Score timeline -->
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Scores ML dans le temps</h3>
<canvas id="score-chart" height="150"></canvas>
</div>
<!-- Detections -->
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<h3 class="text-sm font-medium text-gray-400 px-5 py-3 border-b border-gray-800">Détections</h3>
<div class="overflow-x-auto max-h-[40vh] overflow-y-auto">
<table class="data-table"><thead><tr>
<th>Date</th><th>Score</th><th>Raw</th><th>Threat</th><th>JA4</th><th>Host</th><th>Hits</th><th>Raison</th>
</tr></thead><tbody id="det-body"></tbody></table>
</div>
</div>
<!-- AI Features -->
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden" id="features-section" style="display:none">
<h3 class="text-sm font-medium text-gray-400 px-5 py-3 border-b border-gray-800">Features AI</h3>
<div class="p-5 grid grid-cols-2 md:grid-cols-4 gap-3 text-sm" id="features-grid"></div>
</div>
<!-- HTTP Logs -->
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<h3 class="text-sm font-medium text-gray-400 px-5 py-3 border-b border-gray-800">Dernières requêtes HTTP (100 max)</h3>
<div class="overflow-x-auto max-h-[40vh] overflow-y-auto">
<table class="data-table"><thead><tr>
<th>Time</th><th>Method</th><th>Host</th><th>Path</th><th>HTTP Ver</th><th>User-Agent</th><th>JA4</th>
</tr></thead><tbody id="http-body"></tbody></table>
</div>
</div>
<!-- Classify -->
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Classifier cette IP</h3>
<div class="flex gap-3 items-center">
<select id="cls-select" class="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300">
<option value="bot">🤖 Bot</option><option value="legitimate">✅ Légitime</option><option value="suspicious">⚠️ Suspect</option>
</select>
<input type="text" id="cls-comment" placeholder="Commentaire (optionnel)" class="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 focus:border-brand-500 focus:outline-none">
<button id="cls-btn" class="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700">Envoyer</button>
</div>
<div id="cls-result" class="mt-2 text-sm"></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const IP = "{{ ip }}";
let scoreChart;
async function loadIP() {
try {
const r = await fetch(`/api/ip/${encodeURIComponent(IP)}`); const d = await r.json();
document.getElementById('ip-det-count').textContent = d.detections?.length ?? 0;
document.getElementById('ip-http-count').textContent = d.http_logs?.length ?? 0;
document.getElementById('ip-score-count').textContent = d.scores?.length ?? 0;
if (d.recurrence?.length) {
const rec = d.recurrence[0];
document.getElementById('ip-recurrence').textContent = rec.recurrence || 0;
document.getElementById('ip-worst-score').innerHTML = fmtScore(rec.worst_score);
}
// Detections table
document.getElementById('det-body').innerHTML = (d.detections||[]).map(row => `<tr>
<td class="text-xs whitespace-nowrap">${row.detected_at||''}</td>
<td>${fmtScore(row.anomaly_score)}</td>
<td>${fmtScore(row.raw_anomaly_score)}</td>
<td>${threatBadge(row.threat_level)}</td>
<td class="text-xs font-mono max-w-[100px] truncate">${row.ja4||''}</td>
<td class="text-xs max-w-[120px] truncate">${row.host||''}</td>
<td>${row.hits||0}</td>
<td class="text-xs max-w-[200px] truncate">${row.reason||''}</td>
</tr>`).join('') || '<tr><td colspan="8" class="text-center text-gray-500 py-4">Aucune détection</td></tr>';
// Score chart
if (d.scores?.length) {
const labels = d.scores.map(s => (s.detected_at||'').substring(11,16));
const data = d.scores.map(s => s.anomaly_score);
if (scoreChart) scoreChart.destroy();
scoreChart = new Chart(document.getElementById('score-chart'), {
type:'line', data:{labels:labels.reverse(), datasets:[{label:'Score',data:data.reverse(),
borderColor:'#6366f1',backgroundColor:'rgba(99,102,241,0.1)',fill:true,tension:0.3,pointRadius:2}]},
options:{responsive:true,plugins:{legend:{display:false}},scales:{y:{min:0,max:1,ticks:{color:'#9ca3af'}},x:{ticks:{color:'#9ca3af',maxTicksLimit:12}}}}
});
}
// AI Features
if (d.ai_features?.length) {
const f = d.ai_features[0];
const grid = document.getElementById('features-grid');
const skip = new Set(['src_ip','window_start','ja4','host','bot_name','src_ip_str']);
grid.innerHTML = Object.entries(f).filter(([k])=>!skip.has(k)).map(([k,v]) => {
let val = typeof v === 'number' ? v.toFixed(4) : v;
return `<div class="bg-gray-800 rounded-lg p-2"><div class="text-[10px] text-gray-500 truncate">${k}</div><div class="text-sm text-gray-200 font-mono">${val}</div></div>`;
}).join('');
document.getElementById('features-section').style.display = '';
}
// HTTP logs
document.getElementById('http-body').innerHTML = (d.http_logs||[]).map(row => `<tr>
<td class="text-xs whitespace-nowrap">${row.time||''}</td>
<td class="font-mono text-xs">${row.method||''}</td>
<td class="text-xs max-w-[120px] truncate">${row.host||''}</td>
<td class="text-xs max-w-[250px] truncate font-mono" title="${row.path||''}">${row.path||''}</td>
<td class="font-mono text-xs">${row.http_version||''}</td>
<td class="text-xs max-w-[200px] truncate">${row.header_user_agent||''}</td>
<td class="text-xs font-mono">${row.ja4||''}</td>
</tr>`).join('') || '<tr><td colspan="7" class="text-center text-gray-500 py-4">Aucun log</td></tr>';
} catch(e) { console.error(e); }
}
document.getElementById('cls-btn').onclick = async () => {
try {
const r = await fetch('/api/classify', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({src_ip:IP, classification:document.getElementById('cls-select').value, comment:document.getElementById('cls-comment').value})});
const d = await r.json();
document.getElementById('cls-result').innerHTML = r.ok
? `<span class="text-green-400">✓ Classifié : ${d.classification}</span>`
: `<span class="text-red-400">✗ Erreur : ${d.detail||'unknown'}</span>`;
} catch(e) { document.getElementById('cls-result').innerHTML = `<span class="text-red-400">✗ ${e}</span>`; }
};
loadIP();
</script>
{% endblock %}

View File

@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block title %}JA4 SOC — Modèles{% endblock %}
{% block content %}
<div class="space-y-6">
<h2 class="text-lg font-semibold text-white">État des modèles ML</h2>
<!-- Scoring stats from ClickHouse -->
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<h3 class="text-sm font-medium text-gray-400 px-5 py-3 border-b border-gray-800">Statistiques de scoring (7 derniers jours)</h3>
<div class="overflow-x-auto">
<table class="data-table"><thead><tr>
<th>Modèle</th><th>Sessions scorées</th><th>Premier scoring</th><th>Dernier scoring</th>
</tr></thead><tbody id="scoring-body"></tbody></table>
</div>
</div>
<!-- Model metadata files -->
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<h3 class="text-sm font-medium text-gray-400 px-5 py-3 border-b border-gray-800">Métadonnées des modèles</h3>
<div id="model-cards" class="p-5 space-y-4">
<span class="text-sm text-gray-500">Chargement...</span>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function loadModels() {
try {
const r = await fetch('/api/models'); const d = await r.json();
// Scoring stats table
document.getElementById('scoring-body').innerHTML = (d.scoring_stats||[]).map(row => `<tr>
<td class="font-medium text-gray-200">${row.model_name||''}</td>
<td>${(row.scored||0).toLocaleString()}</td>
<td class="text-xs">${row.first_seen||''}</td>
<td class="text-xs">${row.last_seen||''}</td>
</tr>`).join('') || '<tr><td colspan="4" class="text-center text-gray-500 py-4">Aucun scoring récent</td></tr>';
// Model metadata cards
const cards = document.getElementById('model-cards');
if (d.models?.length) {
cards.innerHTML = d.models.map(m => `
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div class="flex items-center gap-3 mb-2">
<span class="text-sm font-semibold text-white">${m.model_name||'?'} v${m.version_id||'?'}</span>
<span class="badge badge-low">${m.algorithm||'?'}</span>
${m.autoencoder ? '<span class="badge badge-medium">+AE</span>' : ''}
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-gray-400">
<div>Entraîné : <span class="text-gray-300">${m.trained_at||'?'}</span></div>
<div>Échantillons : <span class="text-gray-300">${m.human_samples||'?'}</span></div>
<div>Contamination : <span class="text-gray-300">${m.contamination||'?'}</span></div>
<div>Seuil : <span class="text-gray-300">${m.threshold||'?'}</span></div>
${m.validation ? `<div>Val anomaly rate : <span class="text-gray-300">${(m.validation.val_anomaly_rate*100).toFixed(1)}%</span></div>
<div>Val mean score : <span class="text-gray-300">${m.validation.val_mean_score?.toFixed(4)||'?'}</span></div>
<div>Train size : <span class="text-gray-300">${m.validation.train_size||'?'}</span></div>
<div>Val size : <span class="text-gray-300">${m.validation.val_size||'?'}</span></div>` : ''}
</div>
</div>
`).join('');
} else {
cards.innerHTML = '<span class="text-sm text-gray-500">Aucun fichier de métadonnées trouvé (les modèles sont dans /data/models/)</span>';
}
} catch(e) { console.error(e); }
}
loadModels();
</script>
{% endblock %}

View File

@ -0,0 +1,82 @@
{% extends "base.html" %}
{% block title %}JA4 SOC — Overview{% endblock %}
{% block content %}
<div class="space-y-6">
<!-- KPI Row -->
<div class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4" id="kpi-grid">
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Détections 24h</div><div class="text-2xl font-bold text-red-400" id="kpi-detections"></div></div>
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Sessions scorées 24h</div><div class="text-2xl font-bold text-brand-500" id="kpi-scored"></div></div>
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Trafic total 24h</div><div class="text-2xl font-bold text-gray-200" id="kpi-traffic"></div></div>
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">IPs uniques</div><div class="text-2xl font-bold text-yellow-400" id="kpi-ips"></div></div>
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Critical/High</div><div class="text-2xl font-bold text-orange-400" id="kpi-critical"></div></div>
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Modèles actifs</div><div class="text-2xl font-bold text-green-400" id="kpi-models"></div></div>
</div>
<!-- Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Détections par heure (24h)</h3>
<canvas id="chart-timeline" height="200"></canvas>
</div>
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Distribution des threat levels</h3>
<canvas id="chart-threats" height="200"></canvas>
</div>
</div>
<!-- Top IPs -->
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Top 10 IPs détectées (24h)</h3>
<div class="overflow-x-auto">
<table class="data-table" id="top-ips-table">
<thead><tr><th>IP</th><th>Détections</th><th>Pire score</th><th>Threat Level</th><th>ASN</th><th>Pays</th></tr></thead>
<tbody id="top-ips-body"></tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let timelineChart, threatsChart;
async function loadOverview() {
try {
const r = await fetch('/api/overview');
const d = await r.json();
document.getElementById('kpi-detections').textContent = (d.detections_24h ?? 0).toLocaleString();
document.getElementById('kpi-scored').textContent = (d.scored_24h ?? 0).toLocaleString();
document.getElementById('kpi-traffic').textContent = (d.traffic_24h ?? 0).toLocaleString();
document.getElementById('kpi-ips').textContent = (d.unique_ips ?? 0).toLocaleString();
document.getElementById('kpi-critical').textContent = ((d.critical_count ?? 0) + (d.high_count ?? 0)).toLocaleString();
document.getElementById('kpi-models').textContent = d.models?.length ?? 0;
// Timeline chart
if (d.timeline && d.timeline.length) {
const labels = d.timeline.map(t => t.hour?.substring(11,16) || '');
const data = d.timeline.map(t => t.cnt);
if (timelineChart) timelineChart.destroy();
timelineChart = new Chart(document.getElementById('chart-timeline'), {
type: 'bar', data: { labels, datasets: [{ label:'Détections', data, backgroundColor:'rgba(99,102,241,0.6)', borderColor:'#6366f1', borderWidth:1 }] },
options: { responsive:true, plugins:{legend:{display:false}}, scales:{ y:{beginAtZero:true,ticks:{color:'#9ca3af'}}, x:{ticks:{color:'#9ca3af'}} } }
});
}
// Threats donut
if (d.threat_distribution && d.threat_distribution.length) {
const labels = d.threat_distribution.map(t => t.threat_level);
const data = d.threat_distribution.map(t => t.cnt);
const colors = labels.map(l => ({CRITICAL:'#ef4444',HIGH:'#f97316',MEDIUM:'#eab308',LOW:'#22c55e',NORMAL:'#6b7280',KNOWN_BOT:'#3b82f6',ANUBIS_DENY:'#dc2626'}[l]||'#6b7280'));
if (threatsChart) threatsChart.destroy();
threatsChart = new Chart(document.getElementById('chart-threats'), {
type:'doughnut', data:{labels,datasets:[{data,backgroundColor:colors}]},
options:{responsive:true,plugins:{legend:{position:'right',labels:{color:'#9ca3af',font:{size:11}}}}}
});
}
// Top IPs table
const tbody = document.getElementById('top-ips-body');
tbody.innerHTML = (d.top_ips||[]).map(ip => `<tr>
<td>${fmtIP(ip.src_ip)}</td><td>${ip.cnt}</td><td>${fmtScore(ip.worst_score)}</td>
<td>${threatBadge(ip.threat_level||'')}</td><td class="text-xs">${ip.asn_org||''}</td><td>${ip.country_code||''}</td>
</tr>`).join('');
} catch(e) { console.error('Overview load error:', e); }
}
loadOverview();
setInterval(loadOverview, 30000);
</script>
{% endblock %}

View File

@ -0,0 +1,89 @@
{% extends "base.html" %}
{% block title %}JA4 SOC — Scores ML{% endblock %}
{% block content %}
<div class="space-y-4">
<div class="flex items-center gap-3 flex-wrap">
<h2 class="text-lg font-semibold text-white">Toutes les classifications ML</h2>
<div class="flex gap-1.5" id="threat-filters">
<button class="filter-btn active" data-filter="">Tous</button>
<button class="filter-btn" data-filter="CRITICAL">Critical</button>
<button class="filter-btn" data-filter="HIGH">High</button>
<button class="filter-btn" data-filter="NORMAL">Normal</button>
<button class="filter-btn" data-filter="KNOWN_BOT">Known Bot</button>
</div>
</div>
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div class="overflow-x-auto max-h-[70vh] overflow-y-auto">
<table class="data-table">
<thead><tr>
<th class="cursor-pointer" data-sort="detected_at">Date ↕</th>
<th>IP</th>
<th class="cursor-pointer" data-sort="anomaly_score">Score ↕</th>
<th class="cursor-pointer" data-sort="raw_anomaly_score">Raw ↕</th>
<th>AE Error</th>
<th>XGB Prob</th>
<th>Threat</th>
<th>Model</th>
<th>JA4</th>
<th>Host</th>
<th>Hits</th>
<th>Pays</th>
</tr></thead>
<tbody id="scores-body"></tbody>
</table>
</div>
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-800">
<span class="text-xs text-gray-500" id="scores-info"></span>
<div class="flex gap-2">
<button id="prev-btn" class="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400 hover:text-white disabled:opacity-30">&larr;</button>
<button id="next-btn" class="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400 hover:text-white disabled:opacity-30">&rarr;</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let sPage=1, sSort='detected_at', sOrder='DESC', sThreat='';
async function loadScores() {
const params = new URLSearchParams({page:sPage,per_page:50,sort:sSort,order:sOrder});
if(sThreat) params.set('threat_level',sThreat);
try {
const r = await fetch('/api/scores?'+params);
const d = await r.json();
const tbody = document.getElementById('scores-body');
tbody.innerHTML = (d.data||[]).map(row => `<tr>
<td class="text-xs whitespace-nowrap">${row.detected_at||''}</td>
<td class="whitespace-nowrap">${fmtIP(row.src_ip)}</td>
<td>${fmtScore(row.anomaly_score)}</td>
<td>${fmtScore(row.raw_anomaly_score)}</td>
<td class="text-xs">${(row.ae_recon_error||0).toFixed(6)}</td>
<td class="text-xs">${(row.xgb_prob||0).toFixed(4)}</td>
<td>${threatBadge(row.threat_level)}</td>
<td class="text-xs">${row.model_name||''}</td>
<td class="text-xs font-mono max-w-[100px] truncate">${row.ja4||''}</td>
<td class="text-xs max-w-[120px] truncate">${row.host||''}</td>
<td>${row.hits||0}</td>
<td>${row.country_code||''}</td>
</tr>`).join('') || '<tr><td colspan="12" class="text-center text-gray-500 py-8">Aucun score</td></tr>';
const total = d.total||0;
document.getElementById('scores-info').textContent = `${total} résultats — page ${sPage}/${Math.max(1,Math.ceil(total/50))}`;
document.getElementById('prev-btn').disabled = sPage <= 1;
document.getElementById('next-btn').disabled = sPage * 50 >= total;
} catch(e) { console.error(e); }
}
document.getElementById('prev-btn').onclick = () => { if(sPage>1){sPage--;loadScores();} };
document.getElementById('next-btn').onclick = () => { sPage++;loadScores(); };
document.querySelectorAll('[data-sort]').forEach(th => th.onclick = () => {
const s = th.dataset.sort;
if(sSort===s) sOrder = sOrder==='DESC'?'ASC':'DESC'; else { sSort=s; sOrder='DESC'; }
sPage=1; loadScores();
});
document.querySelectorAll('[data-filter]').forEach(btn => btn.onclick = () => {
document.querySelectorAll('[data-filter]').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
sThreat = btn.dataset.filter; sPage=1; loadScores();
});
loadScores();
</script>
{% endblock %}

View File

@ -0,0 +1,70 @@
{% extends "base.html" %}
{% block title %}JA4 SOC — Trafic HTTP{% endblock %}
{% block content %}
<div class="space-y-4">
<div class="flex items-center gap-3 flex-wrap">
<h2 class="text-lg font-semibold text-white">Logs HTTP (24h)</h2>
<select id="method-filter" class="px-2 py-1 bg-gray-800 border border-gray-700 rounded text-sm text-gray-300">
<option value="">Toutes méthodes</option>
<option>GET</option><option>POST</option><option>PUT</option><option>DELETE</option><option>HEAD</option><option>OPTIONS</option>
</select>
<input type="text" id="host-filter" placeholder="Filtrer host..." class="px-3 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 w-48 focus:border-brand-500 focus:outline-none">
<input type="number" id="status-filter" placeholder="Status" class="px-3 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 w-28 focus:border-brand-500 focus:outline-none">
</div>
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div class="overflow-x-auto max-h-[70vh] overflow-y-auto">
<table class="data-table"><thead><tr>
<th>Time</th><th>IP</th><th>Method</th><th>Host</th><th>Path</th>
<th>HTTP Ver</th><th>User-Agent</th><th>JA4</th><th>Pays</th>
</tr></thead><tbody id="traffic-body"></tbody></table>
</div>
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-800">
<span class="text-xs text-gray-500" id="traffic-info"></span>
<div class="flex gap-2">
<button id="prev-btn" class="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400 hover:text-white disabled:opacity-30">&larr;</button>
<button id="next-btn" class="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400 hover:text-white disabled:opacity-30">&rarr;</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let tPage=1;
async function loadTraffic() {
const params = new URLSearchParams({page:tPage,per_page:100});
const m=document.getElementById('method-filter').value;
const h=document.getElementById('host-filter').value;
const s=document.getElementById('status-filter').value;
if(m) params.set('method',m); if(h) params.set('host',h); if(s) params.set('status',s);
try {
const r = await fetch('/api/traffic?'+params); const d = await r.json();
const tbody = document.getElementById('traffic-body');
const sc = s => s>=500?'text-red-400':s>=400?'text-orange-400':s>=300?'text-yellow-400':'text-green-400';
const mc = m => ({GET:'text-green-400',POST:'text-blue-400',PUT:'text-yellow-400',DELETE:'text-red-400'}[m]||'text-gray-400');
tbody.innerHTML = (d.data||[]).map(row => `<tr>
<td class="text-xs whitespace-nowrap">${row.time||''}</td>
<td class="whitespace-nowrap">${fmtIP(row.src_ip)}</td>
<td class="${mc(row.method)} font-mono text-xs">${row.method||''}</td>
<td class="text-xs max-w-[150px] truncate">${row.host||''}</td>
<td class="text-xs max-w-[250px] truncate font-mono" title="${row.path||''}">${row.path||''}</td>
<td class="font-mono text-xs">${row.http_version||''}</td>
<td class="text-xs max-w-[200px] truncate" title="${row.header_user_agent||''}">${row.header_user_agent||''}</td>
<td class="text-xs font-mono max-w-[100px] truncate">${row.ja4||''}</td>
<td>${row.src_country_code||''}</td>
</tr>`).join('') || '<tr><td colspan="9" class="text-center text-gray-500 py-8">Aucun log</td></tr>';
const total=d.total||0;
document.getElementById('traffic-info').textContent=`${total} logs — page ${tPage}/${Math.max(1,Math.ceil(total/100))}`;
document.getElementById('prev-btn').disabled=tPage<=1;
document.getElementById('next-btn').disabled=tPage*100>=total;
} catch(e) { console.error(e); }
}
document.getElementById('prev-btn').onclick=()=>{if(tPage>1){tPage--;loadTraffic();}};
document.getElementById('next-btn').onclick=()=>{tPage++;loadTraffic();};
['method-filter','host-filter','status-filter'].forEach(id=>{
let el=document.getElementById(id);
el.addEventListener(el.tagName==='SELECT'?'change':'input',()=>{tPage=1;loadTraffic();});
});
loadTraffic();
</script>
{% endblock %}

View File

@ -1,18 +0,0 @@
import pytest
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
@pytest.fixture
def mock_db():
db = MagicMock()
db.query.return_value = MagicMock(result_rows=[])
return db
@pytest.fixture
def client(mock_db):
with patch("backend.database.db", mock_db):
from backend.main import app
with TestClient(app) as c:
yield c, mock_db

View File

@ -1,10 +0,0 @@
def test_audit_log_post(client):
c, _ = client
resp = c.post("/api/audit/logs?action=test_action&user=testuser")
assert resp.status_code in (200, 422, 404)
def test_audit_log_get(client):
c, _ = client
resp = c.get("/api/audit/logs?hours=1")
assert resp.status_code in (200, 404)

View File

@ -1,70 +0,0 @@
"""Tests for the detections routes and helper functions."""
import pytest
def test_detections_list_endpoint(client):
"""GET /api/detections returns a valid status code."""
c, mock_db = client
mock_db.query.return_value.result_rows = [(50,)] # count query
resp = c.get("/api/detections")
assert resp.status_code in (200, 404, 422, 500)
def test_detections_list_with_filters(client):
"""GET /api/detections supports filter query params."""
c, mock_db = client
mock_db.query.return_value.result_rows = [(0,)]
resp = c.get("/api/detections?threat_level=CRITICAL&page=1&page_size=10")
assert resp.status_code in (200, 404, 422, 500)
def test_detections_pagination(client):
"""GET /api/detections supports pagination params."""
c, mock_db = client
mock_db.query.return_value.result_rows = [(0,)]
resp = c.get("/api/detections?page=2&page_size=10")
assert resp.status_code in (200, 404, 422, 500)
def test_label_to_score_known_labels():
"""_label_to_score returns known float values for recognized labels."""
from backend.routes.detections import _label_to_score
assert _label_to_score("human") == pytest.approx(0.9)
assert _label_to_score("bot") == pytest.approx(0.05)
assert _label_to_score("tor") == pytest.approx(0.1)
assert _label_to_score("proxy") == pytest.approx(0.25)
def test_label_to_score_unknown_label():
"""_label_to_score returns 0.5 for unrecognized labels."""
from backend.routes.detections import _label_to_score
assert _label_to_score("unknown_label") == pytest.approx(0.5)
def test_label_to_score_empty_string():
"""_label_to_score returns None for empty string."""
from backend.routes.detections import _label_to_score
assert _label_to_score("") is None
def test_label_to_score_case_insensitive():
"""_label_to_score is case-insensitive."""
from backend.routes.detections import _label_to_score
assert _label_to_score("HUMAN") == _label_to_score("human")
assert _label_to_score("Bot") == _label_to_score("bot")
def test_detections_search_filter(client):
"""GET /api/detections supports search text filter."""
c, mock_db = client
mock_db.query.return_value.result_rows = [(0,)]
resp = c.get("/api/detections?search=1.2.3")
assert resp.status_code in (200, 404, 422, 500)
def test_detections_group_by_ip(client):
"""GET /api/detections supports group_by_ip mode."""
c, mock_db = client
mock_db.query.return_value.result_rows = [(0,)]
resp = c.get("/api/detections?group_by_ip=true")
assert resp.status_code in (200, 404, 422, 500)

View File

@ -1,26 +0,0 @@
def test_health_returns_200(client):
c, _ = client
resp = c.get("/health")
assert resp.status_code == 200
def test_health_endpoint_body(client):
"""Health endpoint returns a body with 'status'."""
c, _ = client
resp = c.get("/health")
assert resp.status_code == 200
# Body may be JSON or plain text
try:
data = resp.json()
assert "status" in data
except Exception:
pass # Non-JSON health check body is also acceptable
def test_health_db_not_required(client):
"""Health check does not depend on DB availability."""
c, mock_db = client
mock_db.query.side_effect = Exception("DB down")
resp = c.get("/health")
# Health should still return 200 even if DB throws
assert resp.status_code == 200

View File

@ -1,34 +0,0 @@
def test_metrics_endpoint(client):
c, mock_db = client
mock_db.query.return_value.result_rows = [
("1.2.3.4", "t1234567890abc", "UA/5.0", "FR", 100)
]
resp = c.get("/api/metrics/top-ips?hours=1&limit=10")
assert resp.status_code in (200, 404, 422) # endpoint may not exist in all versions
def test_metrics_main_endpoint(client):
"""GET /api/metrics returns 200 when DB returns data."""
c, mock_db = client
# Summary row: total, critical, high, medium, low, known_bots, anomalies, unique_ips
mock_db.query.return_value.result_rows = [
(100, 5, 10, 20, 65, 15, 85, 50)
]
resp = c.get("/api/metrics")
assert resp.status_code in (200, 404, 422, 500)
def test_metrics_main_no_data(client):
"""GET /api/metrics returns 404 when DB returns no rows."""
c, mock_db = client
mock_db.query.return_value.result_rows = []
resp = c.get("/api/metrics")
assert resp.status_code in (404, 500)
def test_threats_endpoint(client):
"""GET /api/metrics/threats returns acceptable status code."""
c, mock_db = client
mock_db.query.return_value.result_rows = [("CRITICAL", 5), ("HIGH", 10)]
resp = c.get("/api/metrics/threats")
assert resp.status_code in (200, 404, 422, 500)

View File

@ -1,25 +0,0 @@
import pytest
PRIVATE_RANGES = [
"127.0.0.1", "10.0.0.1", "192.168.1.1", "172.16.0.1",
"169.254.0.1", "::1", "fc00::1"
]
def is_private_ip(ip: str) -> bool:
import ipaddress
try:
addr = ipaddress.ip_address(ip)
return addr.is_private or addr.is_loopback or addr.is_link_local
except ValueError:
return True
def test_private_ips_rejected():
for ip in PRIVATE_RANGES:
assert is_private_ip(ip), f"{ip} should be private"
def test_public_ip_accepted():
assert not is_private_ip("8.8.8.8")
assert not is_private_ip("1.1.1.1")

View File

@ -1,34 +0,0 @@
version: '3.8'
services:
# ───────────────────────────────────────────────────────────────────────────
# DASHBOARD WEB
# ───────────────────────────────────────────────────────────────────────────
dashboard_web:
build: .
container_name: dashboard_web
restart: unless-stopped
network_mode: "host"
env_file:
- .env
environment:
# ClickHouse
CLICKHOUSE_HOST: ${CLICKHOUSE_HOST:-clickhouse}
CLICKHOUSE_DB: ${CLICKHOUSE_DB:-ja4_processing}
CLICKHOUSE_DB_LOGS: ${CLICKHOUSE_DB_LOGS:-ja4_logs}
CLICKHOUSE_DB_PROCESSING: ${CLICKHOUSE_DB_PROCESSING:-ja4_processing}
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-admin}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-}
# API
API_PORT: 8000
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s

View File

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bot Detector Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -1,33 +0,0 @@
{
"name": "bot-detector-dashboard",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.0",
"axios": "^1.6.0",
"recharts": "^2.10.0",
"@tanstack/react-table": "^8.11.0",
"date-fns": "^3.0.0",
"reactflow": "^11.10.0",
"@deck.gl/react": "^9.0.0",
"@deck.gl/core": "^9.0.0",
"@deck.gl/layers": "^9.0.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0"
}
}

View File

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -1,436 +0,0 @@
import { BrowserRouter, Routes, Route, Link, Navigate, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { DetectionsList } from './components/DetectionsList';
import { DetailsView } from './components/DetailsView';
import { InvestigationView } from './components/InvestigationView';
import { JA4InvestigationView } from './components/JA4InvestigationView';
import { EntityInvestigationView } from './components/EntityInvestigationView';
import { IncidentsView } from './components/IncidentsView';
import { QuickSearch } from './components/QuickSearch';
import { ThreatIntelView } from './components/ThreatIntelView';
import { CorrelationGraph } from './components/CorrelationGraph';
import { InteractiveTimeline } from './components/InteractiveTimeline';
import { SubnetInvestigation } from './components/SubnetInvestigation';
import { BulkClassification } from './components/BulkClassification';
import { PivotView } from './components/PivotView';
import { FingerprintsView } from './components/FingerprintsView';
import { CampaignsView } from './components/CampaignsView';
import { BruteForceView } from './components/BruteForceView';
import { TcpSpoofingView } from './components/TcpSpoofingView';
import { HeaderFingerprintView } from './components/HeaderFingerprintView';
import { MLFeaturesView } from './components/MLFeaturesView';
import ClusteringView from './components/ClusteringView';
import { useTheme } from './ThemeContext';
import { useMetrics } from './hooks/useMetrics';
import { Tooltip } from './components/ui/Tooltip';
import { TIPS } from './components/ui/tooltips';
// ─── Types ────────────────────────────────────────────────────────────────────
interface AlertCounts {
critical: number;
high: number;
medium: number;
total: number;
}
interface RecentItem {
type: 'ip' | 'ja4' | 'subnet';
value: string;
ts: number;
}
// ─── Recent investigations (localStorage) ────────────────────────────────────
const RECENTS_KEY = 'soc_recent_investigations';
const MAX_RECENTS = 8;
function loadRecents(): RecentItem[] {
try {
return JSON.parse(localStorage.getItem(RECENTS_KEY) || '[]');
} catch {
return [];
}
}
function saveRecent(item: Omit<RecentItem, 'ts'>) {
const all = loadRecents().filter(r => !(r.type === item.type && r.value === item.value));
all.unshift({ ...item, ts: Date.now() });
localStorage.setItem(RECENTS_KEY, JSON.stringify(all.slice(0, MAX_RECENTS)));
}
// ─── Sidebar ─────────────────────────────────────────────────────────────────
function Sidebar({ counts }: { counts: AlertCounts | null }) {
const location = useLocation();
const { theme, setTheme } = useTheme();
const [recents, setRecents] = useState<RecentItem[]>(loadRecents());
// Refresh recents when location changes
useEffect(() => {
setRecents(loadRecents());
}, [location.pathname]);
const navLinks = [
{ path: '/', label: 'Dashboard', icon: '📊', aliases: ['/incidents'] },
{ path: '/detections', label: 'Détections', icon: '🎯', aliases: ['/investigate'] },
{ path: '/campaigns', label: 'Campagnes / Botnets', icon: '🕸️', aliases: [] },
{ path: '/fingerprints', label: 'Fingerprints JA4', icon: '🔏', aliases: [] },
{ path: '/pivot', label: 'Pivot / Corrélation', icon: '🔗', aliases: [] },
{ path: '/threat-intel', label: 'Threat Intel', icon: '📚', aliases: [] },
];
const advancedLinks = [
{ path: '/bruteforce', label: 'Brute Force', icon: '🔥', aliases: [] },
{ path: '/tcp-spoofing', label: 'TCP Spoofing', icon: '🧬', aliases: [] },
{ path: '/clustering', label: 'Clustering IPs', icon: '🔬', aliases: [] },
{ path: '/headers', label: 'Header Fingerprint', icon: '📡', aliases: [] },
{ path: '/ml-features', label: 'Features ML', icon: '🤖', aliases: [] },
];
const isActive = (link: { path: string; aliases: string[] }) =>
location.pathname === link.path ||
link.aliases.some(a => location.pathname.startsWith(a)) ||
(link.path !== '/' && location.pathname.startsWith(`${link.path}/`));
const themeOptions: { value: typeof theme; icon: string; label: string }[] = [
{ value: 'dark', icon: '🌙', label: 'Sombre' },
{ value: 'light', icon: '☀️', label: 'Clair' },
{ value: 'auto', icon: '🔄', label: 'Auto' },
];
return (
<aside className="fixed inset-y-0 left-0 w-56 bg-background-secondary border-r border-background-card flex flex-col z-30">
{/* Logo */}
<div className="h-14 flex items-center px-5 border-b border-background-card shrink-0">
<span className="text-lg font-bold text-text-primary">🛡 SOC</span>
<span className="ml-2 text-xs text-text-disabled font-mono bg-background-card px-1.5 py-0.5 rounded">v2</span>
</div>
{/* Main nav */}
<nav className="px-3 pt-4 space-y-0.5">
{navLinks.map(link => (
<Link
key={link.path}
to={link.path}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-sm font-medium ${
isActive(link)
? 'bg-accent-primary text-white'
: 'text-text-secondary hover:text-text-primary hover:bg-background-card'
}`}
>
<span className="text-base">{link.icon}</span>
<span className="flex-1">{link.label}</span>
</Link>
))}
</nav>
{/* Advanced analysis nav */}
<nav className="px-3 pt-4 space-y-0.5">
<div className="text-xs font-semibold text-text-disabled uppercase tracking-wider px-3 pb-1">Analyse Avancée</div>
{advancedLinks.map(link => (
<Link
key={link.path}
to={link.path}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-sm font-medium ${
isActive(link)
? 'bg-accent-primary text-white'
: 'text-text-secondary hover:text-text-primary hover:bg-background-card'
}`}
>
<span className="text-base">{link.icon}</span>
<span className="flex-1">{link.label}</span>
</Link>
))}
</nav>
{/* Alert stats */}
{counts && (
<div className="mx-3 mt-5 bg-background-card rounded-lg p-3 space-y-2">
<Tooltip content={TIPS.alertes_24h}>
<div className="text-xs font-semibold text-text-disabled uppercase tracking-wider mb-2 cursor-help">
Alertes 24h
</div>
</Tooltip>
{counts.critical > 0 && (
<div className="flex justify-between items-center">
<Tooltip content={TIPS.risk_critical}>
<span className="text-xs text-red-400 flex items-center gap-1 cursor-help"><span className="w-1.5 h-1.5 rounded-full bg-red-500 inline-block animate-pulse" /> CRITICAL</span>
</Tooltip>
<span className="text-xs font-bold text-red-400 bg-red-500/20 px-1.5 py-0.5 rounded">{counts.critical}</span>
</div>
)}
<div className="flex justify-between items-center">
<Tooltip content={TIPS.risk_high}>
<span className="text-xs text-orange-400 flex items-center gap-1 cursor-help"><span className="w-1.5 h-1.5 rounded-full bg-orange-500 inline-block" /> HIGH</span>
</Tooltip>
<span className="text-xs font-bold text-orange-400 bg-orange-500/20 px-1.5 py-0.5 rounded">{counts.high}</span>
</div>
<div className="flex justify-between items-center">
<Tooltip content={TIPS.risk_medium}>
<span className="text-xs text-yellow-400 flex items-center gap-1 cursor-help"><span className="w-1.5 h-1.5 rounded-full bg-yellow-500 inline-block" /> MEDIUM</span>
</Tooltip>
<span className="text-xs font-bold text-yellow-400 bg-yellow-500/20 px-1.5 py-0.5 rounded">{counts.medium}</span>
</div>
<div className="border-t border-background-secondary pt-1.5 flex justify-between items-center mt-1">
<span className="text-xs text-text-secondary">Total détections</span>
<span className="text-xs font-bold text-text-primary">{counts.total.toLocaleString()}</span>
</div>
</div>
)}
{/* Recent investigations */}
{recents.length > 0 && (
<div className="mx-3 mt-4 flex-1 min-h-0 overflow-hidden">
<div className="text-xs font-semibold text-text-disabled uppercase tracking-wider px-1 mb-2">Récents</div>
<div className="space-y-0.5 overflow-y-auto max-h-44">
{recents.map((r, i) => (
<Link
key={i}
to={r.type === 'ip' ? `/investigation/${r.value}` : r.type === 'ja4' ? `/investigation/ja4/${r.value}` : `/entities/subnet/${r.value}`}
className="flex items-center gap-2 px-2 py-1.5 rounded text-xs text-text-secondary hover:text-text-primary hover:bg-background-card transition-colors"
>
<span className="shrink-0 text-text-disabled">
{r.type === 'ip' ? '🌐' : r.type === 'ja4' ? '🔐' : '🔷'}
</span>
<span className="font-mono truncate">{r.value}</span>
</Link>
))}
</div>
</div>
)}
{/* Spacer */}
<div className="flex-1" />
{/* Theme toggle */}
<div className="mx-3 mb-3 bg-background-card rounded-lg p-2">
<div className="text-xs font-semibold text-text-disabled uppercase tracking-wider px-1 mb-2">Thème</div>
<div className="flex gap-1">
{themeOptions.map(opt => (
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
title={opt.label}
className={`flex-1 flex flex-col items-center gap-0.5 py-1.5 rounded transition-colors text-xs ${
theme === opt.value
? 'bg-accent-primary text-white'
: 'text-text-secondary hover:text-text-primary hover:bg-background-secondary'
}`}
>
<span>{opt.icon}</span>
<span className="text-[10px]">{opt.label}</span>
</button>
))}
</div>
</div>
{/* Footer */}
<div className="px-4 py-3 border-t border-background-card shrink-0">
<div className="text-xs text-text-disabled">Analyste SOC</div>
</div>
</aside>
);
}
// ─── Top header ───────────────────────────────────────────────────────────────
function TopHeader({ counts }: { counts: AlertCounts | null }) {
const location = useLocation();
const getBreadcrumb = () => {
const p = location.pathname;
if (p === '/' || p === '/incidents') return 'Dashboard';
if (p.startsWith('/investigation/ja4/')) return `JA4 · ${decodeURIComponent(p.split('/investigation/ja4/')[1] || '')}`;
if (p.startsWith('/investigation/')) return `IP · ${decodeURIComponent(p.split('/investigation/')[1] || '')}`;
if (p.startsWith('/detections/')) return `Détection · ${decodeURIComponent(p.split('/').pop() || '')}`;
if (p.startsWith('/detections')) return 'Détections';
if (p.startsWith('/entities/subnet/')) return `Subnet · ${decodeURIComponent(p.split('/entities/subnet/')[1] || '')}`;
if (p.startsWith('/entities/')) return `Entité · ${decodeURIComponent(p.split('/').pop() || '')}`;
if (p.startsWith('/fingerprints')) return 'Fingerprints JA4';
if (p.startsWith('/campaigns')) return 'Campagnes / Botnets';
if (p.startsWith('/pivot')) return 'Pivot / Corrélation';
if (p.startsWith('/bulk-classify')) return 'Classification en masse';
if (p.startsWith('/bruteforce')) return 'Brute Force & Credential Stuffing';
if (p.startsWith('/tcp-spoofing')) return 'Spoofing TCP/OS';
if (p.startsWith('/clustering')) return 'Clustering IPs';
if (p.startsWith('/headers')) return 'Header Fingerprint Clustering';
if (p.startsWith('/ml-features')) return 'Features ML / Radar';
return '';
};
return (
<header className="fixed top-0 right-0 left-56 h-14 bg-background-secondary border-b border-background-card flex items-center gap-4 px-5 z-20">
{/* Breadcrumb */}
<div className="text-sm font-medium text-text-secondary shrink-0">{getBreadcrumb()}</div>
{/* Search */}
<div className="flex-1 max-w-xl">
<QuickSearch />
</div>
{/* Critical alert badge */}
{counts && counts.critical > 0 && (
<Link
to="/"
className="shrink-0 flex items-center gap-1.5 bg-red-500/20 border border-red-500/40 text-red-400 px-3 py-1 rounded-lg text-xs font-bold animate-pulse"
>
🔴 {counts.critical} CRITICAL
</Link>
)}
</header>
);
}
// ─── Route helpers ────────────────────────────────────────────────────────────
function CorrelationGraphRoute() {
const { ip } = useParams<{ ip: string }>();
return <CorrelationGraph ip={ip || ''} height="600px" />;
}
function TimelineRoute() {
const { ip } = useParams<{ ip?: string }>();
return <InteractiveTimeline ip={ip} height="400px" />;
}
function InvestigateRoute() {
const { type, value } = useParams<{ type?: string; value?: string }>();
if (!type || !value) return <Navigate to="/detections" replace />;
const decodedValue = decodeURIComponent(value);
if (type === 'ip') return <Navigate to={`/investigation/${encodeURIComponent(decodedValue)}`} replace />;
if (type === 'ja4') return <Navigate to={`/investigation/ja4/${encodeURIComponent(decodedValue)}`} replace />;
return <Navigate to={`/detections/${type}/${encodeURIComponent(decodedValue)}`} replace />;
}
/** Redirige /detections/ip/:ip → /investigation/:ip */
function IpDetectionPageRedirect() {
const { ip } = useParams<{ ip: string }>();
return <Navigate to={`/investigation/${encodeURIComponent(ip || '')}`} replace />;
}
/** Redirige /investigation/ip/:ip → /investigation/:ip */
function IpInvestigationRedirect() {
const { ip } = useParams<{ ip: string }>();
return <Navigate to={`/investigation/${encodeURIComponent(ip || '')}`} replace />;
}
function BulkClassificationRoute() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const ipsParam = searchParams.get('ips') || '';
const selectedIPs = ipsParam.split(',').map(ip => ip.trim()).filter(Boolean);
if (selectedIPs.length === 0) return <Navigate to="/" replace />;
return (
<BulkClassification
selectedIPs={selectedIPs}
onClose={() => navigate('/')}
onSuccess={() => navigate('/threat-intel')}
/>
);
}
// Track investigations for the recents list
function RouteTracker() {
const location = useLocation();
useEffect(() => {
const p = location.pathname;
if (p.startsWith('/investigation/ja4/')) {
saveRecent({ type: 'ja4', value: decodeURIComponent(p.split('/investigation/ja4/')[1] || '') });
} else if (p.startsWith('/investigation/ip/')) {
// Redirigé — ne pas sauvegarder l'alias (la route finale /investigation/:ip sera sauvegardée)
} else if (p.startsWith('/investigation/')) {
saveRecent({ type: 'ip', value: decodeURIComponent(p.split('/investigation/')[1] || '') });
} else if (p.startsWith('/entities/subnet/')) {
saveRecent({ type: 'subnet', value: decodeURIComponent(p.split('/entities/subnet/')[1] || '') });
}
}, [location.pathname]);
return null;
}
// ─── MainContent : layout adaptatif selon la route ───────────────────────────
// Les vues "canvas" ont besoin d'une hauteur fixe sans padding
// pour que leurs colonnes scroll indépendamment.
const FULLHEIGHT_ROUTES = ['/clustering'];
function MainContent({ counts: _counts }: { counts: AlertCounts | null }) {
const location = useLocation();
const isFullHeight = FULLHEIGHT_ROUTES.some(r => location.pathname.startsWith(r));
if (isFullHeight) {
return (
<main className="mt-14 overflow-hidden" style={{ height: 'calc(100vh - 3.5rem)' }}>
<Routes>
<Route path="/clustering" element={<ClusteringView />} />
</Routes>
</main>
);
}
return (
<main className="flex-1 px-4 py-3 mt-14 overflow-auto">
<Routes>
<Route path="/" element={<IncidentsView />} />
<Route path="/incidents" element={<IncidentsView />} />
<Route path="/pivot" element={<PivotView />} />
<Route path="/fingerprints" element={<FingerprintsView />} />
<Route path="/campaigns" element={<CampaignsView />} />
<Route path="/threat-intel" element={<ThreatIntelView />} />
<Route path="/bruteforce" element={<BruteForceView />} />
<Route path="/tcp-spoofing" element={<TcpSpoofingView />} />
<Route path="/headers" element={<HeaderFingerprintView />} />
<Route path="/heatmap" element={<Navigate to="/" replace />} />
<Route path="/botnets" element={<Navigate to="/campaigns" replace />} />
<Route path="/rotation" element={<Navigate to="/fingerprints" replace />} />
<Route path="/ml-features" element={<MLFeaturesView />} />
<Route path="/detections" element={<DetectionsList />} />
<Route path="/detections/ip/:ip" element={<IpDetectionPageRedirect />} />
<Route path="/detections/:type/:value" element={<DetailsView />} />
<Route path="/investigate" element={<DetectionsList />} />
<Route path="/investigate/:type/:value" element={<InvestigateRoute />} />
<Route path="/investigation/ja4/:ja4" element={<JA4InvestigationView />} />
<Route path="/investigation/ip/:ip" element={<IpInvestigationRedirect />} />
<Route path="/investigation/:ip" element={<InvestigationView />} />
<Route path="/entities/subnet/:subnet" element={<SubnetInvestigation />} />
<Route path="/entities/:type/:value" element={<EntityInvestigationView />} />
<Route path="/bulk-classify" element={<BulkClassificationRoute />} />
<Route path="/tools/correlation-graph/:ip" element={<CorrelationGraphRoute />} />
<Route path="/tools/timeline/:ip?" element={<TimelineRoute />} />
</Routes>
</main>
);
}
// ─── App ──────────────────────────────────────────────────────────────────────
export default function App() {
const { data: metricsData } = useMetrics();
const counts = metricsData
? {
critical: metricsData.summary.critical_count ?? 0,
high: metricsData.summary.high_count ?? 0,
medium: metricsData.summary.medium_count ?? 0,
total: metricsData.summary.total_detections ?? 0,
}
: null;
return (
<BrowserRouter>
<RouteTracker />
<div className="min-h-screen bg-background flex">
{/* Fixed sidebar */}
<Sidebar counts={counts} />
{/* Main area (offset by sidebar width) */}
<div className="flex-1 flex flex-col min-h-screen" style={{ marginLeft: '14rem' }}>
{/* Fixed top header */}
<TopHeader counts={counts} />
{/* Page content — full-height sans padding pour les vues canvas */}
<MainContent counts={counts} />
</div>
</div>
</BrowserRouter>
);
}

View File

@ -1,74 +0,0 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { CONFIG } from './config';
export type Theme = 'dark' | 'light' | 'auto';
interface ThemeContextValue {
theme: Theme;
resolved: 'dark' | 'light';
setTheme: (t: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue>({
theme: CONFIG.DEFAULT_THEME,
resolved: 'dark',
setTheme: () => {},
});
const STORAGE_KEY = CONFIG.THEME_STORAGE_KEY;
function resolveTheme(theme: Theme): 'dark' | 'light' {
if (theme === 'auto') {
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
return theme;
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
return stored ?? CONFIG.DEFAULT_THEME;
});
const [resolved, setResolved] = useState<'dark' | 'light'>(() => resolveTheme(
(localStorage.getItem(STORAGE_KEY) as Theme | null) ?? CONFIG.DEFAULT_THEME
));
const applyTheme = (t: Theme) => {
const r = resolveTheme(t);
setResolved(r);
document.documentElement.setAttribute('data-theme', r);
};
const setTheme = (t: Theme) => {
setThemeState(t);
localStorage.setItem(STORAGE_KEY, t);
applyTheme(t);
};
// Apply on mount
useEffect(() => {
applyTheme(theme);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Watch system preference changes when in 'auto' mode
useEffect(() => {
if (theme !== 'auto') return;
const mq = window.matchMedia('(prefers-color-scheme: light)');
const handler = () => applyTheme('auto');
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, resolved, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}

View File

@ -1,152 +0,0 @@
import axios from 'axios';
import { CONFIG } from '../config';
export const api = axios.create({
baseURL: CONFIG.API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Types
export interface MetricsSummary {
total_detections: number;
critical_count: number;
high_count: number;
medium_count: number;
low_count: number;
known_bots_count: number;
anomalies_count: number;
unique_ips: number;
}
export interface TimeSeriesPoint {
hour: string;
total: number;
critical: number;
high: number;
medium: number;
low: number;
}
export interface MetricsResponse {
summary: MetricsSummary;
timeseries: TimeSeriesPoint[];
threat_distribution: Record<string, number>;
}
export interface Detection {
detected_at: string;
src_ip: string;
ja4: string;
host: string;
bot_name: string;
anomaly_score: number;
threat_level: string;
model_name: string;
recurrence: number;
asn_number: string;
asn_org: string;
asn_detail: string;
asn_domain: string;
country_code: string;
asn_label: string;
hits: number;
hit_velocity: number;
fuzzing_index: number;
post_ratio: number;
reason: string;
client_headers: string;
asn_score?: number | null;
asn_rep_label?: string;
anubis_bot_name?: string;
anubis_bot_action?: string;
anubis_bot_category?: string;
}
export interface DetectionsListResponse {
items: Detection[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
export interface AttributeValue {
value: string;
count: number;
percentage: number;
first_seen?: string;
last_seen?: string;
threat_levels?: Record<string, number>;
unique_ips?: number;
primary_threat?: string;
}
export interface VariabilityAttributes {
user_agents: AttributeValue[];
ja4: AttributeValue[];
countries: AttributeValue[];
asns: AttributeValue[];
hosts: AttributeValue[];
threat_levels: AttributeValue[];
model_names: AttributeValue[];
}
export interface Insight {
type: 'warning' | 'info' | 'success';
message: string;
}
export interface VariabilityResponse {
type: string;
value: string;
total_detections: number;
unique_ips: number;
date_range: {
first_seen: string;
last_seen: string;
};
attributes: VariabilityAttributes;
insights: Insight[];
}
export interface AttributeListItem {
value: string;
count: number;
}
export interface AttributeListResponse {
type: string;
items: AttributeListItem[];
total: number;
}
// API Functions
export const metricsApi = {
getMetrics: () => api.get<MetricsResponse>('/metrics'),
getThreatDistribution: () => api.get('/metrics/threats'),
};
export const detectionsApi = {
getDetections: (params?: {
page?: number;
page_size?: number;
threat_level?: string;
model_name?: string;
country_code?: string;
asn_number?: string;
search?: string;
sort_by?: string;
sort_order?: string;
group_by_ip?: boolean;
score_type?: string;
}) => api.get<DetectionsListResponse>('/detections', { params }),
getDetails: (id: string) => api.get(`/detections/${encodeURIComponent(id)}`),
};
export const variabilityApi = {
getVariability: (type: string, value: string) =>
api.get<VariabilityResponse>(`/variability/${type}/${encodeURIComponent(value)}`),
};

View File

@ -1,465 +0,0 @@
import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import DataTable, { Column } from './ui/DataTable';
import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips';
import { formatNumber } from '../utils/dateUtils';
import { LoadingSpinner, ErrorMessage } from './ui/Feedback';
// ─── Types ────────────────────────────────────────────────────────────────────
interface BruteForceTarget {
host: string;
unique_ips: number;
total_hits: number;
total_params: number;
attack_type: string;
top_ja4s: string[];
}
interface BruteForceAttacker {
ip: string;
distinct_hosts: number;
total_hits: number;
total_params: number;
ja4: string;
}
interface TimelineHour {
hour: number;
hits: number;
ips: number;
}
type ActiveTab = 'targets' | 'attackers' | 'timeline';
// ─── Helpers ──────────────────────────────────────────────────────────────────
// ─── Sub-components ───────────────────────────────────────────────────────────
function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
return (
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
<span className="text-text-secondary text-sm">{label}</span>
<span className={`text-2xl font-bold ${accent ?? 'text-text-primary'}`}>{value}</span>
</div>
);
}
// ─── Attackers DataTable ─────────────────────────────────────────────────────
function AttackersTable({
attackers,
navigate,
}: {
attackers: BruteForceAttacker[];
navigate: (path: string) => void;
}) {
const columns = useMemo((): Column<BruteForceAttacker>[] => [
{
key: 'ip',
label: 'IP',
render: (v: string) => <span className="font-mono text-xs text-text-primary">{v}</span>,
},
{ key: 'distinct_hosts', label: 'Hosts ciblés', align: 'right' },
{
key: 'total_hits',
label: 'Hits',
align: 'right',
render: (v: number) => formatNumber(v),
},
{
key: 'total_params',
label: 'Params',
tooltip: TIPS.params_combos,
align: 'right',
render: (v: number) => formatNumber(v),
},
{
key: 'ja4',
label: 'JA4',
tooltip: TIPS.ja4,
render: (v: string) => (
<span className="font-mono text-xs text-text-secondary">
{v ? `${v.slice(0, 16)}` : '—'}
</span>
),
},
{
key: '_actions',
label: '',
sortable: false,
render: (_: unknown, row: BruteForceAttacker) => (
<button
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.ip}`); }}
className="text-xs bg-threat-high/10 text-threat-high px-3 py-1 rounded hover:bg-threat-high/20 transition-colors"
>
Investiguer
</button>
),
},
], [navigate]);
return (
<DataTable
data={attackers}
columns={columns}
rowKey="ip"
defaultSortKey="total_hits"
emptyMessage="Aucun attaquant trouvé"
compact
/>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
interface HostAttacker { ip: string; total_hits: number; total_params: number; ja4: string; attack_type: string; }
function TargetRow({ t, navigate }: { t: BruteForceTarget; navigate: (path: string) => void }) {
const [expanded, setExpanded] = useState(false);
const [hostAttackers, setHostAttackers] = useState<HostAttacker[]>([]);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const toggle = async () => {
setExpanded(prev => !prev);
if (!loaded && !expanded) {
setLoading(true);
try {
const res = await fetch(`/api/bruteforce/host/${encodeURIComponent(t.host)}/attackers?limit=20`);
if (!res.ok) throw new Error('Erreur chargement');
const data: { items: HostAttacker[] } = await res.json();
setHostAttackers(data.items ?? []);
setLoaded(true);
} catch { /* ignore */ }
finally { setLoading(false); }
}
};
return (
<>
<tr className="border-b border-border hover:bg-background-card transition-colors cursor-pointer" onClick={toggle}>
<td className="px-4 py-3 font-mono text-text-primary text-xs flex items-center gap-2">
<span className="text-accent-primary">{expanded ? '▾' : '▸'}</span>
{t.host}
</td>
<td className="px-4 py-3 text-text-primary">{formatNumber(t.unique_ips)}</td>
<td className="px-4 py-3 text-text-primary">{formatNumber(t.total_hits)}</td>
<td className="px-4 py-3 text-text-primary">{formatNumber(t.total_params)}</td>
<td className="px-4 py-3">
{t.attack_type === 'credential_stuffing' ? (
<span className="bg-threat-critical/20 text-threat-critical text-xs px-2 py-1 rounded-full" title={TIPS.credential_stuffing}>💳 Credential Stuffing</span>
) : (
<span className="bg-threat-high/20 text-threat-high text-xs px-2 py-1 rounded-full" title={TIPS.enumeration}>🔍 Énumération</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{(t.top_ja4s ?? []).slice(0, 2).map((ja4, i) => (
<span key={i} className="font-mono text-xs bg-background-card px-1.5 py-0.5 rounded border border-border text-text-secondary">
{ja4.slice(0, 12)}
</span>
))}
</div>
</td>
</tr>
{expanded && (
<tr className="border-b border-border bg-background-card">
<td colSpan={6} className="px-6 py-3">
{loading ? (
<div className="flex items-center gap-2 text-text-secondary text-sm py-2">
<div className="w-4 h-4 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
Chargement des attaquants
</div>
) : hostAttackers.length === 0 ? (
<p className="text-text-disabled text-sm py-2">Aucun attaquant trouvé.</p>
) : (
<div>
<p className="text-text-secondary text-xs mb-2 font-medium">
Top {hostAttackers.length} IP attaquant <span className="text-accent-primary font-mono">{t.host}</span>
</p>
<table className="w-full text-xs">
<thead>
<tr className="text-text-disabled border-b border-border">
<th className="text-left py-1 pr-4">IP</th>
<th className="text-left py-1 pr-4">Hits</th>
<th className="text-left py-1 pr-4">Params</th>
<th className="text-left py-1 pr-4">JA4</th>
<th className="text-left py-1 pr-4">Type</th>
<th className="text-left py-1"></th>
</tr>
</thead>
<tbody>
{hostAttackers.map((a) => (
<tr key={a.ip} className="border-b border-border/50 hover:bg-background-secondary transition-colors">
<td className="py-1.5 pr-4 font-mono text-text-primary">{a.ip}</td>
<td className="py-1.5 pr-4 text-text-primary">{formatNumber(a.total_hits)}</td>
<td className="py-1.5 pr-4 text-text-secondary">{formatNumber(a.total_params)}</td>
<td className="py-1.5 pr-4 font-mono text-text-secondary">{a.ja4 ? a.ja4.slice(0, 16) + '…' : '—'}</td>
<td className="py-1.5 pr-4">
{a.attack_type === 'credential_stuffing'
? <span className="text-threat-critical">💳</span>
: <span className="text-threat-medium">🔍</span>
}
</td>
<td className="py-1.5">
<button
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${a.ip}`); }}
className="text-accent-primary hover:underline text-xs"
>
Investiguer
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</td>
</tr>
)}
</>
);
}
export function BruteForceView() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<ActiveTab>('targets');
const [targets, setTargets] = useState<BruteForceTarget[]>([]);
const [targetsTotal, setTargetsTotal] = useState(0);
const [targetsLoading, setTargetsLoading] = useState(true);
const [targetsError, setTargetsError] = useState<string | null>(null);
const [attackers, setAttackers] = useState<BruteForceAttacker[]>([]);
const [attackersLoading, setAttackersLoading] = useState(false);
const [attackersError, setAttackersError] = useState<string | null>(null);
const [attackersLoaded, setAttackersLoaded] = useState(false);
const [timeline, setTimeline] = useState<TimelineHour[]>([]);
const [timelineLoading, setTimelineLoading] = useState(false);
const [timelineError, setTimelineError] = useState<string | null>(null);
const [timelineLoaded, setTimelineLoaded] = useState(false);
useEffect(() => {
const fetchTargets = async () => {
setTargetsLoading(true);
try {
const res = await fetch('/api/bruteforce/targets');
if (!res.ok) throw new Error('Erreur chargement des cibles');
const data: { items: BruteForceTarget[]; total: number } = await res.json();
setTargets(data.items ?? []);
setTargetsTotal(data.total ?? 0);
} catch (err) {
setTargetsError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setTargetsLoading(false);
}
};
fetchTargets();
}, []);
const loadAttackers = async () => {
if (attackersLoaded) return;
setAttackersLoading(true);
try {
const res = await fetch('/api/bruteforce/attackers?limit=50');
if (!res.ok) throw new Error('Erreur chargement des attaquants');
const data: { items: BruteForceAttacker[] } = await res.json();
setAttackers(data.items ?? []);
setAttackersLoaded(true);
} catch (err) {
setAttackersError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setAttackersLoading(false);
}
};
const loadTimeline = async () => {
if (timelineLoaded) return;
setTimelineLoading(true);
try {
const res = await fetch('/api/bruteforce/timeline');
if (!res.ok) throw new Error('Erreur chargement de la timeline');
const data: { hours: TimelineHour[] } = await res.json();
setTimeline(data.hours ?? []);
setTimelineLoaded(true);
} catch (err) {
setTimelineError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setTimelineLoading(false);
}
};
const handleTabChange = (tab: ActiveTab) => {
setActiveTab(tab);
if (tab === 'attackers') loadAttackers();
if (tab === 'timeline') loadTimeline();
};
const totalHits = targets.reduce((s, t) => s + t.total_hits, 0);
const maxHits = timeline.length > 0 ? Math.max(...timeline.map((h) => h.hits)) : 1;
const peakHour = timeline.reduce(
(best, h) => (h.hits > best.hits ? h : best),
{ hour: 0, hits: 0, ips: 0 }
);
const tabs: { id: ActiveTab; label: string }[] = [
{ id: 'targets', label: '🎯 Cibles' },
{ id: 'attackers', label: '⚔️ Attaquants' },
{ id: 'timeline', label: '⏱️ Timeline' },
];
return (
<div className="p-6 space-y-6 animate-fade-in">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-text-primary">🔥 Brute Force & Credential Stuffing</h1>
<p className="text-text-secondary mt-1">
Détection des attaques par force brute, credential stuffing et énumération de paramètres.
</p>
</div>
{/* Stat cards */}
<div className="grid grid-cols-3 gap-4">
<StatCard label="Cibles détectées" value={formatNumber(targetsTotal)} accent="text-threat-high" />
<StatCard
label="IPs attaquantes"
value={attackersLoaded ? formatNumber(attackers.length) : '—'}
accent="text-threat-critical"
/>
<StatCard label="Total hits" value={formatNumber(totalHits)} accent="text-text-primary" />
</div>
{/* Tabs */}
<div className="flex gap-2 border-b border-border">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab.id
? 'text-accent-primary border-b-2 border-accent-primary'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Cibles tab */}
{activeTab === 'targets' && (
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
{targetsLoading ? (
<LoadingSpinner />
) : targetsError ? (
<div className="p-4"><ErrorMessage message={targetsError} /></div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-text-secondary text-left">
<th className="px-4 py-3">Host (cliquer pour détails)</th>
<th className="px-4 py-3">IPs distinctes</th>
<th className="px-4 py-3">Total hits</th>
<th className="px-4 py-3 whitespace-nowrap">
Params combos
<InfoTip content={TIPS.params_combos} />
</th>
<th className="px-4 py-3"><span className="flex items-center gap-1">Type d'attaque<InfoTip content={TIPS.attack_brute_force} /></span></th>
<th className="px-4 py-3 whitespace-nowrap">
Top JA4
<InfoTip content={TIPS.ja4} />
</th>
</tr>
</thead>
<tbody>
{targets.map((t) => (
<TargetRow key={t.host} t={t} navigate={navigate} />
))}
</tbody>
</table>
)}
</div>
)}
{/* Attaquants tab */}
{activeTab === 'attackers' && (
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
{attackersLoading ? (
<LoadingSpinner />
) : attackersError ? (
<div className="p-4"><ErrorMessage message={attackersError} /></div>
) : (
<AttackersTable attackers={attackers} navigate={navigate} />
)}
</div>
)}
{/* Timeline tab */}
{activeTab === 'timeline' && (
<div className="bg-background-secondary rounded-lg border border-border p-6">
{timelineLoading ? (
<LoadingSpinner />
) : timelineError ? (
<ErrorMessage message={timelineError} />
) : (
<>
<div className="flex items-center justify-between mb-4">
<h2 className="text-text-primary font-semibold">Activité par heure</h2>
{peakHour.hits > 0 && (
<span className="text-xs text-text-secondary">
Pic : <span className="text-threat-critical font-medium">{peakHour.hour}h</span> ({formatNumber(peakHour.hits)} hits)
</span>
)}
</div>
<div className="flex items-end gap-1 h-48">
{Array.from({ length: 24 }, (_, i) => {
const entry = timeline.find((h) => h.hour === i) ?? { hour: i, hits: 0, ips: 0 };
const pct = maxHits > 0 ? (entry.hits / maxHits) * 100 : 0;
const isPeak = entry.hour === peakHour.hour && entry.hits > 0;
return (
<div key={i} className="flex flex-col items-center flex-1 gap-1">
<div className="w-full flex flex-col justify-end" style={{ height: '160px' }}>
<div
title={`${i}h: ${entry.hits} hits, ${entry.ips} IPs`}
style={{ height: `${Math.max(pct, 1)}%` }}
className={`w-full rounded-t transition-all ${
isPeak
? 'bg-threat-critical'
: pct >= 70
? 'bg-threat-high'
: pct >= 30
? 'bg-threat-medium'
: 'bg-accent-primary/60'
}`}
/>
</div>
<span className="text-text-disabled text-xs">{i}</span>
</div>
);
})}
</div>
<div className="flex gap-4 mt-4 text-xs text-text-secondary">
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-threat-critical rounded-sm inline-block" /> Pic</span>
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-threat-high rounded-sm inline-block" /> Élevé (≥70%)</span>
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-threat-medium rounded-sm inline-block" /> Moyen (≥30%)</span>
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-accent-primary/60 rounded-sm inline-block" /> Faible</span>
</div>
</>
)}
</div>
)}
{/* Summary */}
{activeTab === 'targets' && !targetsLoading && !targetsError && (
<p className="text-text-secondary text-xs">{formatNumber(targetsTotal)} cible(s) détectée(s)</p>
)}
</div>
);
}

View File

@ -1,295 +0,0 @@
import { useState } from 'react';
import { PREDEFINED_TAGS } from '../utils/classifications';
interface BulkClassificationProps {
selectedIPs: string[];
onClose: () => void;
onSuccess: () => void;
}
export function BulkClassification({ selectedIPs, onClose, onSuccess }: BulkClassificationProps) {
const [selectedLabel, setSelectedLabel] = useState<string>('suspicious');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [comment, setComment] = useState('');
const [confidence, setConfidence] = useState(0.7);
const [processing, setProcessing] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: selectedIPs.length });
const toggleTag = (tag: string) => {
setSelectedTags(prev =>
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
);
};
const handleBulkClassify = async () => {
setProcessing(true);
try {
// Process in batches of 10
const batchSize = 10;
for (let i = 0; i < selectedIPs.length; i += batchSize) {
const batch = selectedIPs.slice(i, i + batchSize);
await Promise.all(
batch.map(ip =>
fetch('/api/analysis/classifications', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ip,
label: selectedLabel,
tags: selectedTags,
comment: `${comment} (Classification en masse - ${selectedIPs.length} IPs)`,
confidence,
analyst: 'soc_user',
bulk_operation: true,
bulk_id: `bulk-${Date.now()}`
})
})
)
);
setProgress({ current: Math.min(i + batchSize, selectedIPs.length), total: selectedIPs.length });
}
// Log the bulk operation
await fetch('/api/audit/logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'BULK_CLASSIFICATION',
entity_type: 'ip',
entity_count: selectedIPs.length,
details: {
label: selectedLabel,
tags: selectedTags,
confidence
}
})
});
onSuccess();
} catch (error) {
console.error('Bulk classification error:', error);
alert('Erreur lors de la classification en masse');
} finally {
setProcessing(false);
}
};
const handleExportCSV = () => {
const csv = selectedIPs.map(ip =>
`${ip},${selectedLabel},"${selectedTags.join(';')}",${confidence},"${comment}"`
).join('\n');
const header = 'ip,label,tags,confidence,comment\n';
const blob = new Blob([header + csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bulk_classification_${Date.now()}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background-secondary rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-text-primary">
🏷 Classification en Masse
</h2>
<p className="text-sm text-text-secondary mt-1">
{selectedIPs.length} IPs sélectionnées
</p>
</div>
<button
onClick={onClose}
className="text-text-secondary hover:text-text-primary"
>
</button>
</div>
{/* Progress Bar */}
{processing && (
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-text-secondary">Progression</span>
<span className="text-sm text-text-primary font-bold">
{progress.current} / {progress.total}
</span>
</div>
<div className="w-full bg-background-card rounded-full h-3">
<div
className="h-3 rounded-full bg-accent-primary transition-all"
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
</div>
</div>
)}
{/* Classification Label */}
<div className="mb-6">
<label className="text-sm font-semibold text-text-primary mb-3 block">
Niveau de Menace
</label>
<div className="grid grid-cols-3 gap-3">
<button
onClick={() => setSelectedLabel('legitimate')}
disabled={processing}
className={`py-4 px-4 rounded-lg font-medium transition-colors ${
selectedLabel === 'legitimate'
? 'bg-threat-low text-white ring-2 ring-threat-low'
: 'bg-background-card text-text-secondary hover:text-text-primary'
}`}
>
<div className="text-2xl mb-1"></div>
<div className="text-sm">Légitime</div>
</button>
<button
onClick={() => setSelectedLabel('suspicious')}
disabled={processing}
className={`py-4 px-4 rounded-lg font-medium transition-colors ${
selectedLabel === 'suspicious'
? 'bg-threat-medium text-white ring-2 ring-threat-medium'
: 'bg-background-card text-text-secondary hover:text-text-primary'
}`}
>
<div className="text-2xl mb-1"></div>
<div className="text-sm">Suspect</div>
</button>
<button
onClick={() => setSelectedLabel('malicious')}
disabled={processing}
className={`py-4 px-4 rounded-lg font-medium transition-colors ${
selectedLabel === 'malicious'
? 'bg-threat-high text-white ring-2 ring-threat-high'
: 'bg-background-card text-text-secondary hover:text-text-primary'
}`}
>
<div className="text-2xl mb-1"></div>
<div className="text-sm">Malveillant</div>
</button>
</div>
</div>
{/* Tags */}
<div className="mb-6">
<label className="text-sm font-semibold text-text-primary mb-3 block">
Tags
</label>
<div className="flex flex-wrap gap-2 max-h-40 overflow-y-auto p-2 bg-background-card rounded-lg">
{PREDEFINED_TAGS.map(tag => (
<button
key={tag}
onClick={() => toggleTag(tag)}
disabled={processing}
className={`px-3 py-1.5 rounded text-xs transition-colors ${
selectedTags.includes(tag)
? 'bg-accent-primary text-white'
: 'bg-background-secondary text-text-secondary hover:text-text-primary'
} disabled:opacity-50`}
>
{tag}
</button>
))}
</div>
{selectedTags.length > 0 && (
<div className="mt-2 text-xs text-text-secondary">
{selectedTags.length} tag(s) sélectionné(s)
</div>
)}
</div>
{/* Confidence Slider */}
<div className="mb-6">
<label className="text-sm font-semibold text-text-primary mb-3 block">
Confiance: {(confidence * 100).toFixed(0)}%
</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={confidence}
onChange={(e) => setConfidence(parseFloat(e.target.value))}
disabled={processing}
className="w-full h-2 bg-background-card rounded-lg appearance-none cursor-pointer"
/>
<div className="flex justify-between text-xs text-text-secondary mt-1">
<span>0%</span>
<span>50%</span>
<span>100%</span>
</div>
</div>
{/* Comment */}
<div className="mb-6">
<label className="text-sm font-semibold text-text-primary mb-3 block">
Commentaire
</label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
disabled={processing}
placeholder="Notes d'analyse..."
className="w-full bg-background-card border border-background-card rounded-lg p-3 text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary"
rows={3}
/>
</div>
{/* Summary */}
<div className="bg-background-card rounded-lg p-4 mb-6">
<div className="text-sm font-semibold text-text-primary mb-2">
📋 Résumé
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-text-secondary">IPs:</span>{' '}
<span className="text-text-primary font-bold">{selectedIPs.length}</span>
</div>
<div>
<span className="text-text-secondary">Label:</span>{' '}
<span className={`font-bold ${
selectedLabel === 'legitimate' ? 'text-threat-low' :
selectedLabel === 'suspicious' ? 'text-threat-medium' :
'text-threat-high'
}`}>
{selectedLabel.toUpperCase()}
</span>
</div>
<div>
<span className="text-text-secondary">Tags:</span>{' '}
<span className="text-text-primary">{selectedTags.length}</span>
</div>
<div>
<span className="text-text-secondary">Confiance:</span>{' '}
<span className="text-text-primary">{(confidence * 100).toFixed(0)}%</span>
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-3">
<button
onClick={handleExportCSV}
disabled={processing}
className="flex-1 py-3 px-4 bg-background-card text-text-primary rounded-lg font-medium hover:bg-background-card/80 transition-colors disabled:opacity-50"
>
📄 Export CSV
</button>
<button
onClick={handleBulkClassify}
disabled={processing || !selectedLabel}
className="flex-1 py-3 px-4 bg-accent-primary text-white rounded-lg font-medium hover:bg-accent-primary/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{processing ? '⏳ Traitement...' : `💾 Classifier ${selectedIPs.length} IPs`}
</button>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,734 +0,0 @@
/**
* ClusteringView — Visualisation WebGL des clusters d'IPs via deck.gl
*
* Architecture LOD :
* - Vue globale : PolygonLayer (hulls) + ScatterplotLayer (centroïdes)
* - Sur sélection : ScatterplotLayer dense (toutes les IPs du cluster)
* - Sidebar : profil radar, stats, liste IPs paginée
*
* Rendu WebGL via @deck.gl/react + OrthographicView
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import DeckGL from '@deck.gl/react';
import { OrthographicView } from '@deck.gl/core';
import { ScatterplotLayer, PolygonLayer, TextLayer, LineLayer } from '@deck.gl/layers';
import { RadarChart, PolarGrid, PolarAngleAxis, Radar, ResponsiveContainer, Tooltip } from 'recharts';
import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips';
import axios from 'axios';
// ─── Types ────────────────────────────────────────────────────────────────────
interface RadarEntry { feature: string; value: number; }
interface ClusterNode {
id: string;
cluster_idx: number;
label: string;
pca_x: number;
pca_y: number;
radius: number;
color: string;
risk_score: number;
ip_count: number;
hit_count: number;
mean_ttl: number;
mean_mss: number;
mean_velocity: number;
mean_fuzzing: number;
mean_headless: number;
mean_ua_ch: number;
top_threat: string;
top_countries: string[];
top_orgs: string[];
sample_ips: string[];
sample_ua: string;
radar: RadarEntry[];
hull: [number, number][];
}
interface ClusterEdge {
id: string;
source: string;
target: string;
similarity: number;
}
interface ClusterStats {
total_clusters: number;
total_ips: number;
total_hits: number;
n_samples: number;
k: number;
elapsed_s: number;
}
interface ClusterResult {
status: string;
nodes: ClusterNode[];
edges: ClusterEdge[];
stats: ClusterStats;
feature_names: string[];
message?: string;
}
interface IPPoint { ip: string; ja4: string; pca_x: number; pca_y: number; risk: number; }
interface IPDetail { ip: string; ja4: string; tcp_ttl: number; tcp_mss: number; hits: number; ua: string; avg_score: number; threat_level: string; country_code: string; asn_org: string; }
// ─── Coordonnées deck.gl ─────────────────────────────────────────────────────
// PCA normalisé [0,1] → world [0, WORLD]
const WORLD = 1000;
function toWorld(v: number): number { return v * WORLD; }
// Couleur hex → [r,g,b,a]
function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return [r, g, b, alpha];
}
// ─── Composant principal ──────────────────────────────────────────────────────
// Persistence des paramètres dans localStorage
const LS_KEY = 'soc_clustering_params';
function loadParams() {
try {
const s = localStorage.getItem(LS_KEY);
if (s) return JSON.parse(s) as { k: number; hours: number; sensitivity: number };
} catch { /* ignore */ }
return { k: 20, hours: 24, sensitivity: 1.0 };
}
export default function ClusteringView() {
const init = loadParams();
const [k, setK] = useState(init.k);
const [hours, setHours] = useState(init.hours);
const [sensitivity, setSensitivity] = useState(init.sensitivity);
const [data, setData] = useState<ClusterResult | null>(null);
const [loading, setLoading] = useState(false);
const [computing, setComputing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<ClusterNode | null>(null);
const [clusterPoints, setClusterPoints] = useState<IPPoint[]>([]);
const [ipDetails, setIpDetails] = useState<IPDetail[]>([]);
const [ipPage, setIpPage] = useState(0);
const [ipTotal, setIpTotal] = useState(0);
const [showEdges, setShowEdges] = useState(false);
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Viewport deck.gl — centré à [WORLD/2, WORLD/2]
const [viewState, setViewState] = useState({
target: [WORLD / 2, WORLD / 2, 0] as [number, number, number],
zoom: -0.5, // montre légèrement plus que le monde [0,WORLD]
minZoom: -3,
maxZoom: 6,
});
// ── Persistence des paramètres ──────────────────────────────────────────
useEffect(() => {
localStorage.setItem(LS_KEY, JSON.stringify({ k, hours, sensitivity }));
}, [k, hours, sensitivity]);
// ── Chargement / polling ─────────────────────────────────────────────────
const fetchClusters = useCallback(async (force = false) => {
if (pollRef.current) { clearTimeout(pollRef.current); pollRef.current = null; }
setLoading(true);
setError(null);
try {
const res = await axios.get<ClusterResult>('/api/clustering/clusters', {
params: { k, hours, sensitivity, force },
});
if (res.data.status === 'computing' || res.data.status === 'idle') {
setComputing(true);
// Polling toutes les 3s
pollRef.current = setTimeout(() => fetchClusters(), 3000);
} else {
setComputing(false);
setData(res.data);
// Fit viewport
if (res.data.nodes?.length) {
const xs = res.data.nodes.map(n => toWorld(n.pca_x));
const ys = res.data.nodes.map(n => toWorld(n.pca_y));
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minY = Math.min(...ys), maxY = Math.max(...ys);
const pad = 0.18;
const fitW = (maxX - minX) * (1 + 2 * pad) || WORLD;
const fitH = (maxY - minY) * (1 + 2 * pad) || WORLD;
const canvasW = window.innerWidth - 288 - (selected ? 384 : 0);
const canvasH = window.innerHeight - 60;
setViewState(v => ({
...v,
target: [(minX + maxX) / 2, (minY + maxY) / 2, 0],
zoom: Math.min(
Math.log2(canvasW / fitW),
Math.log2(canvasH / fitH),
),
}));
}
}
} catch (e: unknown) {
setError((e as Error).message);
setComputing(false);
} finally {
setLoading(false);
}
}, [k, hours, sensitivity]); // sensitivity inclus pour éviter la stale closure
useEffect(() => {
fetchClusters();
return () => { if (pollRef.current) clearTimeout(pollRef.current); };
}, []); // eslint-disable-line
// ── Drill-down : chargement des points du cluster sélectionné ───────────
const loadClusterPoints = useCallback(async (node: ClusterNode) => {
try {
const res = await axios.get<{ points: IPPoint[]; total: number }>(
`/api/clustering/cluster/${node.id}/points`,
{ params: { limit: 10000, offset: 0 } }
);
setClusterPoints(res.data.points);
} catch { setClusterPoints([]); }
}, []);
const loadClusterIPs = useCallback(async (node: ClusterNode, page = 0) => {
try {
const res = await axios.get<{ ips: IPDetail[]; total: number }>(
`/api/clustering/cluster/${node.id}/ips`,
{ params: { limit: 50, offset: page * 50 } }
);
setIpDetails(res.data.ips);
setIpTotal(res.data.total);
setIpPage(page);
} catch { setIpDetails([]); }
}, []);
const handleSelectCluster = useCallback((node: ClusterNode) => {
setSelected(node);
setClusterPoints([]);
setIpDetails([]);
loadClusterPoints(node);
loadClusterIPs(node, 0);
}, [loadClusterPoints, loadClusterIPs]);
// ── Layers deck.gl ─────────────────────────────────────────────────────
const layers = React.useMemo(() => {
if (!data?.nodes) return [];
const nodes = data.nodes;
const nodeMap = Object.fromEntries(nodes.map(n => [n.id, n]));
const layerList: object[] = [];
// 1. Hulls (enveloppes convexes) — toujours visibles
const hullData = nodes
.filter(n => n.hull && n.hull.length >= 3)
.map(n => ({
...n,
polygon: n.hull.map(([x, y]) => [toWorld(x), toWorld(y)]),
}));
layerList.push(new PolygonLayer({
id: 'hulls',
data: hullData,
getPolygon: (d: typeof hullData[number]) => d.polygon,
getFillColor: (d: typeof hullData[number]) => hexToRgba(d.color, d.id === selected?.id ? 55 : 28),
getLineColor: (d: typeof hullData[number]) => hexToRgba(d.color, d.id === selected?.id ? 220 : 130),
getLineWidth: (d: typeof hullData[number]) => d.id === selected?.id ? 3 : 1.5,
lineWidthUnits: 'pixels',
stroked: true,
filled: true,
pickable: true,
autoHighlight: true,
highlightColor: [255, 255, 255, 30],
onClick: ({ object }: { object?: typeof hullData[number] }) => {
if (object) handleSelectCluster(object as ClusterNode);
},
updateTriggers: { getFillColor: [selected?.id], getLineColor: [selected?.id], getLineWidth: [selected?.id] },
}));
// 2. Arêtes inter-clusters (optionnelles)
if (showEdges && data.edges) {
const edgeData = data.edges
.map(e => {
const s = nodeMap[e.source];
const t = nodeMap[e.target];
if (!s || !t) return null;
return { source: [toWorld(s.pca_x), toWorld(s.pca_y)], target: [toWorld(t.pca_x), toWorld(t.pca_y)], sim: e.similarity };
})
.filter(Boolean) as { source: [number, number]; target: [number, number]; sim: number }[];
layerList.push(new LineLayer({
id: 'edges',
data: edgeData,
getSourcePosition: d => d.source,
getTargetPosition: d => d.target,
getColor: [100, 100, 120, 80],
getWidth: 1,
widthUnits: 'pixels',
}));
}
// 3. Points IPs du cluster sélectionné
if (selected && clusterPoints.length > 0) {
layerList.push(new ScatterplotLayer({
id: 'ip-points',
data: clusterPoints,
getPosition: (d: IPPoint) => [toWorld(d.pca_x), toWorld(d.pca_y), 0],
getRadius: 3,
radiusUnits: 'pixels',
getFillColor: (d: IPPoint) => {
const r = d.risk;
if (r > 0.70) return [220, 38, 38, 200];
if (r > 0.45) return [249, 115, 22, 200];
if (r > 0.25) return [234, 179, 8, 200];
return [34, 197, 94, 180];
},
pickable: false,
updateTriggers: { getPosition: [clusterPoints.length] },
}));
}
// 4. Centroïdes (cercles de taille ∝ ip_count)
layerList.push(new ScatterplotLayer({
id: 'centroids',
data: nodes,
getPosition: (d: ClusterNode) => [toWorld(d.pca_x), toWorld(d.pca_y), 0],
getRadius: (d: ClusterNode) => d.radius,
radiusUnits: 'pixels',
getFillColor: (d: ClusterNode) => hexToRgba(d.color, d.id === selected?.id ? 255 : 180),
getLineColor: [255, 255, 255, 180],
getLineWidth: (d: ClusterNode) => d.id === selected?.id ? 3 : 1,
lineWidthUnits: 'pixels',
stroked: true,
filled: true,
pickable: true,
autoHighlight: true,
highlightColor: [255, 255, 255, 60],
onClick: ({ object }: { object?: ClusterNode }) => {
if (object) handleSelectCluster(object);
},
updateTriggers: { getFillColor: [selected?.id], getLineWidth: [selected?.id] },
}));
const stripNonAscii = (s: string) =>
s.replace(/[\u{0080}-\u{FFFF}]/gu, c => {
// Translitérations basiques pour la lisibilité
const map: Record<string, string> = { é:'e',è:'e',ê:'e',ë:'e',à:'a',â:'a',ô:'o',ù:'u',û:'u',î:'i',ï:'i',ç:'c' };
return map[c] ?? '';
}).replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27FF}]/gu, '').trim();
layerList.push(new TextLayer({
id: 'labels',
data: nodes,
getPosition: (d: ClusterNode) => [toWorld(d.pca_x), toWorld(d.pca_y), 0],
getText: (d: ClusterNode) => stripNonAscii(d.label),
getSize: 12,
sizeUnits: 'pixels',
getColor: [255, 255, 255, 200],
getAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: (d: ClusterNode) => [0, d.radius + 4],
fontFamily: 'monospace',
background: true,
getBorderColor: [0, 0, 0, 0],
backgroundPadding: [3, 1, 3, 1],
getBackgroundColor: [15, 20, 30, 180],
}));
return layerList;
}, [data, selected, clusterPoints, showEdges, handleSelectCluster]);
// ── Rendering ────────────────────────────────────────────────────────────
return (
<div className="flex h-full overflow-hidden bg-background text-text-primary">
{/* ── Panneau gauche (scroll indépendant) ── */}
<div className="flex flex-col w-72 flex-shrink-0 border-r border-gray-700 overflow-y-auto p-4 gap-4 z-10" style={{ height: '100%' }}>
<div>
<h2 className="text-lg font-bold mb-1">🔬 Clustering IPs</h2>
<p className="text-xs text-text-secondary">Rendu WebGL · K-means++ sur toutes les IPs</p>
</div>
{/* Paramètres */}
<div className="bg-background-card rounded-lg p-3 space-y-3">
{/* Sensibilité */}
<div className="space-y-1">
<div className="flex justify-between text-xs text-text-secondary">
<span className="flex items-center">
Sensibilité
<InfoTip content={TIPS.sensitivity} />
</span>
<span className="font-mono text-white">
{sensitivity <= 0.5 ? 'Grossière' : sensitivity <= 1.0 ? 'Normale' : sensitivity <= 2.0 ? 'Fine' : sensitivity <= 3.5 ? 'Très fine' : sensitivity <= 4.5 ? 'Maximale' : 'Extrême'}
{' '}(<span title={TIPS.k_actual}>{Math.round(k * sensitivity)} clusters effectifs</span>)
</span>
</div>
<input type="range" min={0.5} max={5.0} step={0.5} value={sensitivity}
onChange={e => setSensitivity(+e.target.value)}
className="w-full accent-accent-primary" />
<div className="flex justify-between text-xs text-text-disabled">
<span>Grossière</span><span>Fine</span><span>Extrême</span>
</div>
</div>
{/* k avancé */}
<details className="text-xs text-text-secondary">
<summary className="cursor-pointer hover:text-white">Paramètres avancés</summary>
<div className="mt-2 space-y-2">
<label className="block">
<span className="flex items-center gap-1">
Clusters de base (k)
<InfoTip content={TIPS.k_base} />
</span>
<input type="range" min={4} max={100} value={k}
onChange={e => setK(+e.target.value)}
className="w-full mt-1 accent-accent-primary" />
<span className="font-mono text-white">{k} {Math.round(k * sensitivity)} clusters effectifs</span>
</label>
<label className="block">
Fenêtre
<select value={hours} onChange={e => setHours(+e.target.value)}
className="w-full mt-1 bg-background border border-gray-600 rounded px-2 py-1">
<option value={6}>6h</option>
<option value={12}>12h</option>
<option value={24}>24h</option>
<option value={48}>48h</option>
<option value={168}>7j</option>
</select>
</label>
</div>
</details>
<label className="flex items-center gap-2 text-xs text-text-secondary cursor-pointer">
<input type="checkbox" checked={showEdges} onChange={e => setShowEdges(e.target.checked)}
className="accent-accent-primary" />
<span className="flex items-center">
Afficher les arêtes
<InfoTip content={TIPS.show_edges} />
</span>
</label>
<button onClick={() => fetchClusters(true)}
disabled={loading}
className="w-full py-2 bg-accent-primary text-white rounded text-sm font-medium hover:opacity-90 disabled:opacity-50">
{computing ? '⏳ Calcul en cours…' : loading ? '⏳ Chargement…' : '🔄 Recalculer'}
</button>
</div>
{/* Stats globales */}
{data?.stats && (
<div className="bg-background-card rounded-lg p-3 space-y-1 text-xs">
<div className="font-semibold text-sm mb-2">Résultats</div>
<Stat label="Clusters" value={data.stats.total_clusters} tooltip={TIPS.k_actual} />
<Stat label="IPs totales" value={data.stats.total_ips.toLocaleString()} tooltip={TIPS.pca_2d} />
<Stat label="Hits totaux" value={data.stats.total_hits.toLocaleString()} tooltip={TIPS.total_hits} />
<Stat label="Calcul" value={`${data.stats.elapsed_s}s`} tooltip={TIPS.calc_time} />
</div>
)}
{/* Message computing */}
{computing && (
<div className="bg-yellow-900/30 border border-yellow-600/40 rounded-lg p-3 text-xs text-yellow-300">
Calcul en cours sur {data?.stats?.n_samples?.toLocaleString() ?? '…'} IPs
<br />Mise à jour automatique toutes les 3s
</div>
)}
{error && (
<div className="bg-red-900/30 border border-red-600/40 rounded p-3 text-xs text-red-300">
{error}
</div>
)}
{/* Liste clusters */}
{data?.nodes && (
<div className="space-y-1">
<div className="text-xs text-text-secondary font-semibold uppercase tracking-wide">Clusters</div>
{[...data.nodes]
.sort((a, b) => b.risk_score - a.risk_score)
.map(n => (
<button key={n.id} onClick={() => handleSelectCluster(n)}
className={`w-full text-left px-3 py-2 rounded text-xs flex items-center gap-2 transition-colors
${selected?.id === n.id ? 'bg-accent-primary/20 ring-1 ring-accent-primary' : 'hover:bg-background-secondary'}`}>
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: n.color }} />
<span className="flex-1 truncate">{n.label}</span>
<span className="text-text-disabled">{n.ip_count.toLocaleString()}</span>
</button>
))}
</div>
)}
</div>
{/* ── Canvas WebGL (deck.gl) ── */}
<div className="flex-1 relative overflow-hidden">
{/* Animation de calcul — REMPLACE DeckGL (le canvas WebGL ignore z-index) */}
{(computing || loading) ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-background">
{/* Noeuds pulsants animés */}
<div className="relative w-56 h-56 mb-2">
{/* Anneaux tournants */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-32 h-32 rounded-full border-2 border-accent-primary/20 border-t-accent-primary animate-spin" style={{ animationDuration: '1.4s' }} />
</div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-20 h-20 rounded-full border-2 border-blue-500/20 border-b-blue-500/80 animate-spin" style={{ animationDuration: '2.1s', animationDirection: 'reverse' }} />
</div>
{/* Noeuds orbitaux représentant les clusters */}
{([0,1,2,3,4,5,6,7] as const).map((i) => {
const angle = (i / 8) * 2 * Math.PI;
const x = 50 + 39 * Math.cos(angle);
const y = 50 + 39 * Math.sin(angle);
const colors = ['#dc2626','#f97316','#eab308','#22c55e','#3b82f6','#8b5cf6','#ec4899','#14b8a6'];
return (
<div key={i} className="absolute w-3 h-3 rounded-full animate-ping"
style={{
left: `${x}%`, top: `${y}%`, transform: 'translate(-50%,-50%)',
background: colors[i], opacity: 0.75,
animationDelay: `${i * 0.18}s`, animationDuration: '1.6s',
}} />
);
})}
{/* Centre */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-2xl select-none animate-pulse">🔬</div>
</div>
</div>
<p className="text-white font-semibold text-lg tracking-wide">Clustering en cours</p>
<p className="text-text-secondary text-sm mt-1">
K-means++ · 30 features · {Math.round(k * sensitivity)} clusters · toutes les IPs
</p>
<p className="text-text-disabled text-xs mt-2 animate-pulse">Mise à jour automatique toutes les 3 secondes</p>
</div>
) : !data ? (
/* État vide initial */
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-text-secondary">
<span className="text-4xl">🔬</span>
<span>Cliquez sur <strong className="text-white">Recalculer</strong> pour démarrer</span>
</div>
) : (
/* Canvas WebGL — monté seulement quand il y a des données */
<DeckGL
views={new OrthographicView({ id: 'ortho', controller: true })}
viewState={viewState}
onViewStateChange={({ viewState: vs }) => setViewState(vs as typeof viewState)}
layers={layers as any}
style={{ width: '100%', height: '100%' }}
controller={true}
>
{/* Légende overlay — gradient non-humanité */}
<div style={{ position: 'absolute', bottom: 16, left: 16, pointerEvents: 'none' }}>
<div className="bg-black/70 rounded-lg p-2 text-xs flex flex-col gap-1.5">
<div className="text-white/50 text-[10px] uppercase tracking-wide">Non-humanité</div>
{/* Barre de dégradé bleu → rouge */}
<div className="relative w-28 h-3 rounded-full overflow-hidden"
style={{ background: 'linear-gradient(to right, hsl(220,70%,58%), hsl(165,78%,53%), hsl(110,82%,52%), hsl(55,86%,52%), hsl(0,90%,48%)' }}>
</div>
<div className="flex justify-between w-28 text-[9px] text-white/50">
<span>Humain</span>
<span>Bot</span>
</div>
<div className="mt-0.5 pt-1 border-t border-white/10 text-white/40 text-[10px] cursor-help" title={TIPS.features_31}>
30 features · PCA 2D
</div>
</div>
</div>
{/* Tooltip zoom hint */}
<div style={{ position: 'absolute', bottom: 16, right: selected ? 320 : 16, pointerEvents: 'none' }}>
<div className="text-xs text-white/40">Scroll pour zoomer · Drag pour déplacer · Click sur un cluster</div>
</div>
</DeckGL>
)}
</div>
{/* ── Sidebar droite (sélection) ── */}
{selected && (
<ClusterSidebar
node={selected}
ipDetails={ipDetails}
ipTotal={ipTotal}
ipPage={ipPage}
clusterPoints={clusterPoints}
onClose={() => { setSelected(null); setClusterPoints([]); setIpDetails([]); }}
onPageChange={(p) => loadClusterIPs(selected, p)}
/>
)}
</div>
);
}
// ─── Stat helper ─────────────────────────────────────────────────────────────
function Stat({ label, value, color, tooltip }: { label: string; value: string | number; color?: string; tooltip?: string }) {
return (
<div className="flex justify-between items-center">
<span className="text-text-secondary flex items-center">
{label}
{tooltip && <InfoTip content={tooltip} />}
</span>
<span className={`font-mono font-semibold ${color ?? 'text-white'}`}>{value}</span>
</div>
);
}
// ─── Sidebar détaillée ───────────────────────────────────────────────────────
function ClusterSidebar({ node, ipDetails, ipTotal, ipPage, clusterPoints, onClose, onPageChange }: {
node: ClusterNode;
ipDetails: IPDetail[];
ipTotal: number;
ipPage: number;
clusterPoints: IPPoint[];
onClose: () => void;
onPageChange: (p: number) => void;
}) {
const riskLabel = (r: number) =>
r > 0.70 ? 'CRITICAL' : r > 0.45 ? 'HIGH' : r > 0.25 ? 'MEDIUM' : 'LOW';
const riskClass = (r: number) =>
r > 0.70 ? 'text-red-500' : r > 0.45 ? 'text-orange-500' : r > 0.25 ? 'text-yellow-400' : 'text-green-500';
const totalPages = Math.ceil(ipTotal / 50);
const exportCSV = () => {
const header = 'IP,JA4,TTL,MSS,Hits,Score,Menace,Pays,ASN\n';
const rows = ipDetails.map(ip =>
[ip.ip, ip.ja4, ip.tcp_ttl, ip.tcp_mss, ip.hits, ip.avg_score, ip.threat_level, ip.country_code, ip.asn_org].join(',')
).join('\n');
const blob = new Blob([header + rows], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `cluster_${node.id}.csv`; a.click();
};
return (
<div className="w-96 flex-shrink-0 border-l border-gray-700 bg-background-secondary flex flex-col overflow-hidden" style={{ height: '100%' }}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
<div>
<div className="font-bold text-sm">{node.label}</div>
<div className="text-xs text-text-secondary">{node.ip_count.toLocaleString()} IPs · {node.hit_count.toLocaleString()} hits</div>
</div>
<div className="flex items-center gap-2">
<span className={`text-xs font-bold ${riskClass(node.risk_score)}`}>{riskLabel(node.risk_score)}</span>
<button onClick={onClose} className="text-text-secondary hover:text-white text-lg leading-none">×</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
{/* Score risque */}
<div className="bg-background-card rounded-lg p-3">
<div className="text-xs text-text-secondary mb-2 flex items-center">
Score de risque
<InfoTip content={TIPS.risk_score} />
</div>
<div className="flex items-center gap-3">
<div className="flex-1 h-3 bg-gray-700 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${node.risk_score * 100}%`, background: node.color }} />
</div>
<span className={`text-sm font-bold ${riskClass(node.risk_score)}`}>
{(node.risk_score * 100).toFixed(0)}%
</span>
</div>
</div>
{/* Radar chart */}
{node.radar?.length > 0 && (
<div className="bg-background-card rounded-lg p-3">
<div className="text-xs text-text-secondary mb-2 flex items-center">
Profil {node.radar?.length ?? 21} features
<InfoTip content={TIPS.radar_profile} />
</div>
<ResponsiveContainer width="100%" height={200}>
<RadarChart data={node.radar}>
<PolarGrid stroke="#374151" />
<PolarAngleAxis dataKey="feature" tick={{ fill: '#9ca3af', fontSize: 8 }} />
<Radar dataKey="value" stroke={node.color} fill={node.color} fillOpacity={0.25} />
<Tooltip
contentStyle={{ background: '#1f2937', border: '1px solid #374151', borderRadius: 8, fontSize: 11 }}
formatter={(v: number) => [`${(v * 100).toFixed(1)}%`, '']}
/>
</RadarChart>
</ResponsiveContainer>
</div>
)}
{/* TCP stack */}
<div className="bg-background-card rounded-lg p-3 text-xs space-y-1">
<div className="font-semibold mb-2">Stack TCP</div>
<Stat label="TTL moyen" value={node.mean_ttl} tooltip={TIPS.mean_ttl} />
<Stat label="MSS moyen" value={node.mean_mss} tooltip={TIPS.mean_mss} />
<Stat label="Vélocité" value={node.mean_velocity?.toFixed ? `${node.mean_velocity.toFixed(2)} rps` : '-'} tooltip={TIPS.mean_velocity} />
<Stat label="Headless" value={node.mean_headless ? `${(node.mean_headless * 100).toFixed(0)}%` : '-'} tooltip={TIPS.mean_headless} />
<Stat label="UA-CH Mismatch" value={node.mean_ua_ch ? `${(node.mean_ua_ch * 100).toFixed(0)}%` : '-'} tooltip={TIPS.mean_ua_ch} />
</div>
{/* Contexte */}
{(node.top_countries?.length > 0 || node.top_orgs?.length > 0) && (
<div className="bg-background-card rounded-lg p-3 text-xs space-y-2">
<div className="font-semibold">Géographie & AS</div>
{node.top_countries?.length > 0 && (
<div className="flex flex-wrap gap-1">
{node.top_countries.map(c => (
<span key={c} className="bg-blue-900/40 border border-blue-700/40 rounded px-2 py-0.5">{c}</span>
))}
</div>
)}
{node.top_orgs?.length > 0 && (
<div className="space-y-1">
{node.top_orgs.map(o => (
<div key={o} className="truncate text-text-secondary">{o}</div>
))}
</div>
)}
</div>
)}
{/* IPs paginées */}
<div className="bg-background-card rounded-lg p-3 text-xs">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold">IPs ({ipTotal.toLocaleString()})</span>
<button onClick={exportCSV} className="text-accent-primary hover:underline text-xs">CSV </button>
</div>
{ipDetails.length === 0 ? (
<div className="text-text-disabled">Chargement</div>
) : (
<div className="space-y-1 max-h-60 overflow-y-auto font-mono">
{ipDetails.map(ip => (
<div key={ip.ip + ip.ja4} className="flex items-center gap-2 py-0.5 border-b border-gray-800/50">
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${
ip.avg_score > 0.45 ? 'bg-red-500' : ip.avg_score > 0.25 ? 'bg-orange-400' : 'bg-green-500'
}`}
/>
<a href={`/investigation/ip/${ip.ip}`}
className="text-blue-400 hover:underline flex-1 truncate">{ip.ip}</a>
<span className="text-text-disabled">{ip.country_code}</span>
<span className="text-text-disabled">{ip.hits}</span>
</div>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-2">
<button onClick={() => onPageChange(ipPage - 1)} disabled={ipPage === 0}
className="px-2 py-0.5 bg-gray-700 rounded disabled:opacity-30"></button>
<span className="text-text-disabled">{ipPage + 1} / {totalPages}</span>
<button onClick={() => onPageChange(ipPage + 1)} disabled={ipPage >= totalPages - 1}
className="px-2 py-0.5 bg-gray-700 rounded disabled:opacity-30"></button>
</div>
)}
</div>
{/* Points info */}
{clusterPoints.length > 0 && (
<div className="text-xs text-text-secondary text-center pb-2">
{clusterPoints.length.toLocaleString()} IPs affichées en WebGL
</div>
)}
</div>
</div>
);
}

View File

@ -1,585 +0,0 @@
import ReactFlow, {
Node,
Edge,
Controls,
Background,
useNodesState,
useEdgesState,
MarkerType,
Panel,
useReactFlow,
ReactFlowProvider,
NodeTypes,
Handle,
Position,
} from 'reactflow';
import 'reactflow/dist/style.css';
import { useEffect, useState, useCallback, memo } from 'react';
import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips';
import { getCountryFlag } from '../utils/countryUtils';
// ─── Types ───────────────────────────────────────────────────────────────────
interface CorrelationGraphProps {
ip: string;
height?: string;
}
interface FilterState {
showSubnet: boolean;
showASN: boolean;
showJA4: boolean;
showUA: boolean;
showHost: boolean;
showCountry: boolean;
}
interface RawData {
variability: any;
subnet: any;
entities: any;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function cleanIP(address: string): string {
if (!address) return '';
return address.replace(/^::ffff:/i, '');
}
function classifyUA(ua: string): 'bot' | 'script' | 'normal' {
const u = ua.toLowerCase();
if (u.includes('bot') || u.includes('crawler') || u.includes('spider')) return 'bot';
if (
u.includes('python') ||
u.includes('curl') ||
u.includes('wget') ||
u.includes('go-http') ||
u.includes('java/') ||
u.includes('axios')
)
return 'script';
return 'normal';
}
// ─── Custom node components (must be OUTSIDE the main component) ─────────────
const IPNode = memo(({ data }: { data: any }) => (
<div className="px-4 py-3 bg-blue-700 border-2 border-blue-400 rounded-xl shadow-xl w-52 select-none">
<Handle type="source" position={Position.Right} style={{ background: '#93c5fd' }} />
<div className="text-xs text-blue-200 font-bold mb-1">🌐 IP SOURCE</div>
<div className="text-sm text-white font-mono font-bold break-all">{data.label}</div>
<div className="text-xs text-blue-200 mt-2">
{(data.detections ?? 0).toLocaleString()} détections
</div>
</div>
));
IPNode.displayName = 'IPNode';
const SubnetNode = memo(({ data }: { data: any }) => (
<div className="px-4 py-3 bg-purple-700 border-2 border-purple-400 rounded-xl shadow-xl w-52 select-none">
<Handle type="target" position={Position.Left} style={{ background: '#d8b4fe' }} />
<div className="text-xs text-purple-200 font-bold mb-1">🔷 SUBNET /24</div>
<div className="text-sm text-white font-mono break-all">{data.label}</div>
<div className="text-xs text-purple-200 mt-1">{data.ipsInSubnet ?? 0} IPs actives</div>
</div>
));
SubnetNode.displayName = 'SubnetNode';
const ASNNode = memo(({ data }: { data: any }) => (
<div className="px-4 py-3 bg-orange-700 border-2 border-orange-400 rounded-xl shadow-xl w-52 select-none">
<Handle type="target" position={Position.Left} style={{ background: '#fdba74' }} />
<div className="text-xs text-orange-200 font-bold mb-1">🏢 ASN</div>
<div className="text-sm text-white font-bold">{data.label}</div>
<div className="text-xs text-orange-200 truncate max-w-[180px] mt-0.5">{data.org}</div>
<div className="text-xs text-orange-200 mt-0.5">
{(data.totalInAsn ?? 0).toLocaleString()} IPs
</div>
</div>
));
ASNNode.displayName = 'ASNNode';
const CountryNode = memo(({ data }: { data: any }) => (
<div className="px-4 py-3 bg-slate-600 border-2 border-slate-400 rounded-xl shadow-xl w-40 text-center select-none">
<Handle type="target" position={Position.Left} style={{ background: '#cbd5e1' }} />
<div className="text-xs text-slate-200 font-bold mb-1">🌍 PAYS</div>
<div className="text-3xl leading-tight">{data.flag}</div>
<div className="text-sm text-white font-bold mt-1">{data.label}</div>
<div className="text-xs text-slate-200">
{(data.percentage ?? 0).toFixed(0)}% · {(data.count ?? 0)} det.
</div>
</div>
));
CountryNode.displayName = 'CountryNode';
const JA4Node = memo(({ data }: { data: any }) => (
<div className="px-4 py-3 bg-emerald-700 border-2 border-emerald-400 rounded-xl shadow-xl w-60 select-none">
<Handle type="target" position={Position.Left} style={{ background: '#6ee7b7' }} />
<div className="text-xs text-emerald-200 font-bold mb-1">🔐 JA4 Fingerprint</div>
<div
className="text-xs text-white font-mono break-all overflow-hidden"
style={{ maxHeight: '3.5rem' }}
>
{data.label}
</div>
<div className="text-xs text-emerald-200 mt-1.5 flex gap-2">
<span>{data.count} det.</span>
<span>{(data.percentage ?? 0).toFixed(1)}%</span>
</div>
</div>
));
JA4Node.displayName = 'JA4Node';
const UANode = memo(({ data }: { data: any }) => {
const borderClass =
data.classification === 'bot'
? 'border-red-400'
: data.classification === 'script'
? 'border-yellow-400'
: 'border-indigo-400';
const badge =
data.classification === 'bot'
? '🔴 BOT'
: data.classification === 'script'
? '🟡 SCRIPT'
: '🟢 Normal';
return (
<div
className={`px-4 py-3 bg-rose-900 border-2 ${borderClass} rounded-xl shadow-xl w-64 select-none`}
>
<Handle type="target" position={Position.Left} style={{ background: '#fca5a5' }} />
<div className="text-xs text-rose-200 font-bold mb-1">
🤖 User-Agent <span className="ml-1">{badge}</span>
</div>
<div
className="text-xs text-white font-mono break-all overflow-hidden leading-tight"
style={{ maxHeight: '3.5rem' }}
title={data.label}
>
{data.label}
</div>
<div className="text-xs text-rose-200 mt-1.5 flex gap-2">
<span>{data.count} det.</span>
<span>{(data.percentage ?? 0).toFixed(1)}%</span>
</div>
</div>
);
});
UANode.displayName = 'UANode';
const HostNode = memo(({ data }: { data: any }) => (
<div className="px-4 py-3 bg-amber-700 border-2 border-amber-400 rounded-xl shadow-xl w-52 select-none">
<Handle type="target" position={Position.Left} style={{ background: '#fcd34d' }} />
<div className="text-xs text-amber-200 font-bold mb-1">🖥 Host cible</div>
<div
className="text-sm text-white font-mono break-all overflow-hidden"
style={{ maxHeight: '3rem' }}
title={data.label}
>
{data.label}
</div>
</div>
));
HostNode.displayName = 'HostNode';
// nodeTypes must be defined outside the component (stable reference)
const nodeTypes: NodeTypes = {
ipNode: IPNode,
subnetNode: SubnetNode,
asnNode: ASNNode,
countryNode: CountryNode,
ja4Node: JA4Node,
uaNode: UANode,
hostNode: HostNode,
};
// ─── Layout builder ───────────────────────────────────────────────────────────
function buildGraph(rawData: RawData, filters: FilterState): { nodes: Node[]; edges: Edge[] } {
const { variability, subnet, entities } = rawData;
const newNodes: Node[] = [];
const newEdges: Edge[] = [];
const makeEdge = (
id: string,
source: string,
target: string,
color: string,
label: string
): Edge => ({
id,
source,
target,
type: 'smoothstep',
style: { stroke: color, strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color },
label,
labelStyle: { fill: color, fontWeight: 600, fontSize: 11 },
labelBgStyle: { fill: '#1e293b', fillOpacity: 0.85 },
labelBgPadding: [4, 2],
});
// Center: IP
newNodes.push({
id: 'ip',
type: 'ipNode',
data: { label: cleanIP(variability?.value || ''), detections: variability?.total_detections },
position: { x: 0, y: 0 },
});
// Inner ring (r=320): Subnet, ASN, Country — evenly spaced
const innerItems: Array<{
id: string;
type: string;
data: any;
color: string;
label: string;
}> = [];
if (filters.showSubnet && subnet?.subnet) {
innerItems.push({
id: 'subnet',
type: 'subnetNode',
data: {
label: cleanIP(subnet.subnet),
ipsInSubnet: subnet.total_in_subnet,
},
color: '#a855f7',
label: 'appartient à',
});
}
if (filters.showASN && subnet?.asn_number) {
innerItems.push({
id: 'asn',
type: 'asnNode',
data: {
label: `AS${subnet.asn_number}`,
org: subnet.asn_org || 'Unknown',
totalInAsn: subnet.total_in_asn,
},
color: '#f97316',
label: 'hébergé par',
});
}
if (
filters.showCountry &&
variability?.attributes?.countries?.length > 0
) {
const c = variability.attributes.countries[0];
innerItems.push({
id: 'country',
type: 'countryNode',
data: {
label: c.value,
flag: getCountryFlag(c.value),
percentage: c.percentage,
count: c.count,
},
color: '#eab308',
label: 'localisé',
});
}
const r1 = 320;
innerItems.forEach((item, idx) => {
const angle = (2 * Math.PI * idx) / Math.max(innerItems.length, 1) - Math.PI / 2;
const x = r1 * Math.cos(angle);
const y = r1 * Math.sin(angle);
newNodes.push({ id: item.id, type: item.type, data: item.data, position: { x, y } });
newEdges.push(makeEdge(`ip-${item.id}`, 'ip', item.id, item.color, item.label));
});
// Outer ring (r=640): JA4, UA, Host — evenly spaced, interleaved
const outerItems: Array<{
id: string;
type: string;
data: any;
color: string;
label: string;
}> = [];
if (filters.showJA4 && variability?.attributes?.ja4) {
variability.attributes.ja4.slice(0, 6).forEach((ja4: any, idx: number) => {
outerItems.push({
id: `ja4-${idx}`,
type: 'ja4Node',
data: { label: ja4.value, count: ja4.count, percentage: ja4.percentage },
color: '#22c55e',
label: 'JA4',
});
});
}
if (filters.showUA && variability?.attributes?.user_agents) {
variability.attributes.user_agents.slice(0, 5).forEach((ua: any, idx: number) => {
const classification = classifyUA(ua.value);
outerItems.push({
id: `ua-${idx}`,
type: 'uaNode',
data: { label: ua.value, count: ua.count, percentage: ua.percentage, classification },
color:
classification === 'bot'
? '#ef4444'
: classification === 'script'
? '#f59e0b'
: '#818cf8',
label: 'User-Agent',
});
});
}
if (filters.showHost && entities?.related?.hosts) {
(entities.related.hosts as string[]).slice(0, 5).forEach((host, idx) => {
outerItems.push({
id: `host-${idx}`,
type: 'hostNode',
data: { label: host },
color: '#f59e0b',
label: 'cible',
});
});
}
const r2 = 640;
outerItems.forEach((item, idx) => {
const angle = (2 * Math.PI * idx) / Math.max(outerItems.length, 1) - Math.PI / 2;
const x = r2 * Math.cos(angle);
const y = r2 * Math.sin(angle);
newNodes.push({ id: item.id, type: item.type, data: item.data, position: { x, y } });
newEdges.push(makeEdge(`ip-${item.id}`, 'ip', item.id, item.color, item.label));
});
return { nodes: newNodes, edges: newEdges };
}
// ─── Inner graph component (needs useReactFlow, must be child of ReactFlowProvider) ──
interface GraphInnerProps {
rawData: RawData | null;
loading: boolean;
error: string | null;
filters: FilterState;
toggleFilter: (k: keyof FilterState) => void;
height: string;
ip: string;
}
function GraphInner({ rawData, loading, error, filters, toggleFilter, height, ip }: GraphInnerProps) {
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const { fitView } = useReactFlow();
useEffect(() => {
if (!rawData) return;
const { nodes: n, edges: e } = buildGraph(rawData, filters);
setNodes(n);
setEdges(e);
// fitView after React renders the new nodes
setTimeout(() => fitView({ padding: 0.15, duration: 400 }), 60);
}, [rawData, filters, setNodes, setEdges, fitView]);
const filterConfig: [keyof FilterState, string, string][] = [
['showSubnet', 'Subnet', '#a855f7'],
['showASN', 'ASN', '#f97316'],
['showCountry', 'Pays', '#eab308'],
['showJA4', 'JA4', '#22c55e'],
['showUA', 'User-Agent', '#ef4444'],
['showHost', 'Hosts', '#f59e0b'],
];
if (loading) {
return (
<div
className="flex flex-col items-center justify-center bg-background-secondary rounded-lg gap-3"
style={{ height }}
>
<div className="text-3xl animate-spin"></div>
<div className="text-sm text-text-secondary">Chargement du graphe de corrélations</div>
</div>
);
}
if (error) {
return (
<div
className="flex flex-col items-center justify-center bg-background-secondary rounded-lg gap-3"
style={{ height }}
>
<div className="text-3xl"></div>
<div className="text-sm text-red-400">{error}</div>
</div>
);
}
if (nodes.length === 0) {
return (
<div
className="flex flex-col items-center justify-center bg-background-secondary rounded-lg gap-3"
style={{ height }}
>
<div className="text-3xl">🕸</div>
<div className="text-sm text-text-secondary">Aucune corrélation trouvée pour {cleanIP(ip)}</div>
</div>
);
}
return (
<div
className="w-full border border-background-card rounded-lg overflow-hidden"
style={{ height }}
>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.15 }}
attributionPosition="bottom-right"
className="bg-background-secondary"
nodesDraggable
nodesConnectable={false}
elementsSelectable
minZoom={0.05}
maxZoom={2}
proOptions={{ hideAttribution: true }}
>
<Background color="#334155" gap={24} size={1} />
<Controls className="bg-background-card border border-background-card rounded-lg" />
{/* Filtres */}
<Panel
position="top-left"
className="bg-background-secondary/95 border border-background-card rounded-lg p-3 shadow-lg"
>
<div className="text-xs font-bold text-text-primary mb-2">Filtres</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
{filterConfig.map(([key, label, color]) => (
<label key={key} className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={filters[key]}
onChange={() => toggleFilter(key)}
className="rounded"
style={{ accentColor: color }}
/>
<span className="text-text-primary">{label}</span>
</label>
))}
</div>
</Panel>
{/* Légende */}
<Panel
position="top-right"
className="bg-background-secondary/95 border border-background-card rounded-lg p-3 shadow-lg"
>
<div className="text-xs font-bold text-text-primary mb-2">Légende</div>
<div className="space-y-1 text-xs">
{[
['bg-blue-700', 'IP Source'],
['bg-purple-700', 'Subnet /24'],
['bg-orange-700', 'ASN'],
['bg-slate-600', 'Pays'],
['bg-emerald-700', 'JA4'],
['bg-rose-900', 'User-Agent'],
['bg-amber-700', 'Host cible'],
].map(([bg, lbl]) => (
<div key={lbl} className="flex items-center gap-2">
<div className={`w-3 h-3 rounded ${bg} border border-white/20`} />
<span className="text-text-secondary">{lbl}</span>
</div>
))}
<div className="mt-2 pt-2 border-t border-background-card text-text-disabled space-y-0.5">
<div>🔴 UA = Bot</div>
<div>🟡 UA = Script</div>
<div>🟢 UA = Normal</div>
</div>
</div>
</Panel>
{/* Stats */}
<Panel
position="bottom-left"
className="bg-background-secondary/95 border border-background-card rounded-lg px-3 py-2 shadow-lg text-xs text-text-secondary"
>
<span className="flex items-center gap-1">{nodes.length} nœuds · {edges.length} arêtes<InfoTip content={TIPS.correlation_node} /></span>
</Panel>
</ReactFlow>
</div>
);
}
// ─── Exported component ───────────────────────────────────────────────────────
export function CorrelationGraph({ ip, height = '700px' }: CorrelationGraphProps) {
const [rawData, setRawData] = useState<RawData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filters, setFilters] = useState<FilterState>({
showSubnet: true,
showASN: true,
showJA4: true,
showUA: true,
showHost: true,
showCountry: true,
});
// Fetch data only when IP changes — filters are applied client-side
useEffect(() => {
if (!ip) return;
const cleaned = cleanIP(ip);
let cancelled = false;
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const [varRes, subnetRes, entitiesRes] = await Promise.all([
fetch(`/api/variability/ip/${encodeURIComponent(cleaned)}`),
fetch(`/api/analysis/${encodeURIComponent(cleaned)}/subnet`),
fetch(`/api/entities/ip/${encodeURIComponent(cleaned)}`),
]);
if (cancelled) return;
const [variability, subnet, entities] = await Promise.all([
varRes.ok ? varRes.json().catch(() => null) : null,
subnetRes.ok ? subnetRes.json().catch(() => null) : null,
entitiesRes.ok ? entitiesRes.json().catch(() => null) : null,
]);
if (cancelled) return;
setRawData({ variability, subnet, entities });
} catch {
if (!cancelled) setError('Erreur de chargement des données de corrélation');
} finally {
if (!cancelled) setLoading(false);
}
};
fetchData();
return () => {
cancelled = true;
};
}, [ip]);
const toggleFilter = useCallback((key: keyof FilterState) => {
setFilters((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
return (
<ReactFlowProvider>
<GraphInner
ip={ip}
height={height}
rawData={rawData}
loading={loading}
error={error}
filters={filters}
toggleFilter={toggleFilter}
/>
</ReactFlowProvider>
);
}

View File

@ -1,153 +0,0 @@
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useVariability } from '../hooks/useVariability';
import { VariabilityPanel } from './VariabilityPanel';
import { formatDateShort } from '../utils/dateUtils';
export function DetailsView() {
const { type, value } = useParams<{ type: string; value: string }>();
const navigate = useNavigate();
const { data, loading, error } = useVariability(type || '', value || '');
if (loading) {
return (
<div className="flex items-center justify-center h-64 text-text-secondary">
Chargement
</div>
);
}
if (error) {
return (
<div className="bg-threat-critical_bg border border-threat-critical rounded-xl p-6">
<p className="text-threat-critical font-semibold mb-4">Erreur : {error.message}</p>
<button
onClick={() => navigate('/detections')}
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm"
>
Retour
</button>
</div>
);
}
if (!data) return null;
const typeLabels: Record<string, string> = {
ip: 'IP',
ja4: 'JA4',
country: 'Pays',
asn: 'ASN',
host: 'Host',
user_agent: 'User-Agent',
};
const typeLabel = typeLabels[type || ''] || type;
const isIP = type === 'ip';
const isJA4 = type === 'ja4';
const first = data.date_range.first_seen ? new Date(data.date_range.first_seen) : null;
const last = data.date_range.last_seen ? new Date(data.date_range.last_seen) : null;
const sameDate = first && last && first.getTime() === last.getTime();
const fmtDate = (d: Date) => formatDateShort(d.toISOString());
return (
<div className="space-y-5 animate-fade-in">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-xs text-text-secondary">
<Link to="/" className="hover:text-text-primary">Dashboard</Link>
<span>/</span>
<Link to="/detections" className="hover:text-text-primary">Détections</Link>
<span>/</span>
<span className="text-text-primary">{typeLabel}: {value}</span>
</nav>
{/* Header card */}
<div className="bg-background-secondary rounded-xl p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
{/* Identité */}
<div>
<p className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-1">{typeLabel}</p>
<p className="text-lg font-mono font-bold text-text-primary break-all">{value}</p>
</div>
{/* Actions */}
<div className="flex flex-wrap gap-2">
{isIP && (
<button
onClick={() => navigate(`/investigation/${encodeURIComponent(value!)}`)}
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
🔍 Investigation complète
</button>
)}
{isJA4 && (
<button
onClick={() => navigate(`/investigation/ja4/${encodeURIComponent(value!)}`)}
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
🔍 Investigation JA4
</button>
)}
<button
onClick={() => navigate('/detections')}
className="bg-background-card hover:bg-background-card/70 text-text-primary px-4 py-2 rounded-lg text-sm"
>
Retour
</button>
</div>
</div>
{/* Métriques clés */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-5">
<Metric label="Détections (24h)" value={data.total_detections.toLocaleString()} accent />
{!isIP && (
<Metric label="IPs uniques" value={data.unique_ips.toLocaleString()} />
)}
<Metric label="User-Agents" value={(data.attributes.user_agents?.length ?? 0).toString()} />
{first && last && (
sameDate ? (
<Metric label="Détecté le" value={fmtDate(last)} />
) : (
<div className="bg-background-card rounded-xl p-3">
<p className="text-[10px] font-semibold text-text-secondary uppercase tracking-wider mb-1">Période</p>
<p className="text-xs text-text-primary font-medium">{fmtDate(first)}</p>
<p className="text-[10px] text-text-secondary"> {fmtDate(last)}</p>
</div>
)
)}
</div>
</div>
{/* Insights */}
{data.insights.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{data.insights.map((ins, i) => {
const s: Record<string, string> = {
warning: 'bg-yellow-500/10 border-yellow-500/40 text-yellow-400',
info: 'bg-blue-500/10 border-blue-500/40 text-blue-400',
success: 'bg-green-500/10 border-green-500/40 text-green-400',
};
return (
<div key={i} className={`${s[ins.type] ?? s.info} border rounded-xl p-3 text-sm`}>
{ins.message}
</div>
);
})}
</div>
)}
{/* Attributs */}
<VariabilityPanel attributes={data.attributes} hideAssociatedIPs={isIP} />
</div>
);
}
function Metric({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
return (
<div className="bg-background-card rounded-xl p-3">
<p className="text-[10px] font-semibold text-text-secondary uppercase tracking-wider mb-1">{label}</p>
<p className={`text-xl font-bold ${accent ? 'text-accent-primary' : 'text-text-primary'}`}>{value}</p>
</div>
);
}

View File

@ -1,598 +0,0 @@
import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useDetections } from '../hooks/useDetections';
import DataTable, { Column } from './ui/DataTable';
import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips';
import { formatDate, formatDateOnly, formatTimeOnly } from '../utils/dateUtils';
type SortField = 'detected_at' | 'threat_level' | 'anomaly_score' | 'src_ip' | 'country_code' | 'asn_number' | 'host' | 'hits' | 'hit_velocity';
type SortOrder = 'asc' | 'desc';
interface ColumnConfig {
key: string;
label: string;
visible: boolean;
sortable: boolean;
}
interface DetectionRow {
src_ip: string;
ja4?: string;
host?: string;
client_headers?: string;
model_name: string;
anomaly_score: number;
threat_level?: string;
bot_name?: string;
hits?: number;
hit_velocity?: number;
asn_org?: string;
asn_number?: string | number;
asn_score?: number | null;
asn_rep_label?: string;
country_code?: string;
detected_at: string;
first_seen?: string;
last_seen?: string;
unique_ja4s?: string[];
unique_hosts?: string[];
unique_client_headers?: string[];
anubis_bot_name?: string;
anubis_bot_action?: string;
anubis_bot_category?: string;
}
export function DetectionsList() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const page = parseInt(searchParams.get('page') || '1');
const modelName = searchParams.get('model_name') || undefined;
const search = searchParams.get('search') || undefined;
const sortField = (searchParams.get('sort_by') || searchParams.get('sort') || 'detected_at') as SortField;
const sortOrder = (searchParams.get('sort_order') || searchParams.get('order') || 'desc') as SortOrder;
const scoreType = searchParams.get('score_type') || undefined;
const [groupByIP, setGroupByIP] = useState(true);
const [threatDist, setThreatDist] = useState<{threat_level: string; count: number; percentage: number}[]>([]);
useEffect(() => {
fetch('/api/metrics/threats')
.then(r => r.ok ? r.json() : null)
.then(d => { if (d?.items) setThreatDist(d.items); })
.catch(() => null);
}, []);
const { data, loading, error } = useDetections({
page,
page_size: 25,
model_name: modelName,
search,
sort_by: sortField,
sort_order: sortOrder,
group_by_ip: groupByIP,
score_type: scoreType,
});
const [searchInput, setSearchInput] = useState(search || '');
const [showColumnSelector, setShowColumnSelector] = useState(false);
const [columns, setColumns] = useState<ColumnConfig[]>([
{ key: 'ip_ja4', label: 'IP / JA4', visible: true, sortable: true },
{ key: 'host', label: 'Host', visible: true, sortable: true },
{ key: 'client_headers', label: 'Client Headers', visible: false, sortable: false },
{ key: 'model_name', label: 'Modèle', visible: true, sortable: true },
{ key: 'anomaly_score', label: 'Score', visible: true, sortable: true },
{ key: 'anubis', label: '🤖 Anubis', visible: true, sortable: false },
{ key: 'hits', label: 'Hits', visible: true, sortable: true },
{ key: 'hit_velocity', label: 'Velocity', visible: true, sortable: true },
{ key: 'asn', label: 'ASN', visible: true, sortable: true },
{ key: 'country', label: 'Pays', visible: true, sortable: true },
{ key: 'detected_at', label: 'Date', visible: true, sortable: true },
]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const newParams = new URLSearchParams(searchParams);
if (searchInput.trim()) {
newParams.set('search', searchInput.trim());
} else {
newParams.delete('search');
}
newParams.set('page', '1');
setSearchParams(newParams);
};
const handleFilterChange = (key: string, value: string) => {
const newParams = new URLSearchParams(searchParams);
if (value) {
newParams.set(key, value);
} else {
newParams.delete(key);
}
newParams.set('page', '1');
setSearchParams(newParams);
};
const toggleColumn = (key: string) => {
setColumns(cols => cols.map(col =>
col.key === key ? { ...col, visible: !col.visible } : col
));
};
const handlePageChange = (newPage: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('page', newPage.toString());
setSearchParams(newParams);
};
const handleSort = (key: string, dir: 'asc' | 'desc') => {
const newParams = new URLSearchParams(searchParams);
newParams.set('sort_by', key);
newParams.set('sort_order', dir);
newParams.set('page', '1');
setSearchParams(newParams);
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-text-secondary">Chargement...</div>
</div>
);
}
if (error) {
return (
<div className="bg-threat-critical_bg border border-threat-critical rounded-lg p-4">
<p className="text-threat-critical">Erreur: {error.message}</p>
</div>
);
}
if (!data) return null;
// Backend handles grouping — data is already grouped when groupByIP=true
const processedData = data;
// Build DataTable columns from visible column configs
const tableColumns: Column<DetectionRow>[] = columns
.filter((col) => col.visible)
.map((col): Column<DetectionRow> => {
switch (col.key) {
case 'ip_ja4':
return {
key: 'src_ip',
label: col.label,
sortable: true,
width: 'w-[220px] min-w-[180px]',
render: (_, row) => {
const ja4s = groupByIP && row.unique_ja4s?.length ? row.unique_ja4s : row.ja4 ? [row.ja4] : [];
const ja4Label = ja4s.length > 1 ? `${ja4s.length} JA4` : ja4s[0] ?? '—';
return (
<div>
<div className="font-mono text-sm text-text-primary whitespace-nowrap">{row.src_ip}</div>
<div className="font-mono text-xs text-text-disabled truncate max-w-[200px]" title={ja4s.join(' | ')}>
{ja4Label}
</div>
</div>
);
},
};
case 'host':
return {
key: 'host',
label: col.label,
sortable: true,
width: 'w-[180px] min-w-[140px]',
render: (_, row) => {
const hosts = groupByIP && row.unique_hosts?.length ? row.unique_hosts : row.host ? [row.host] : [];
const primary = hosts[0] ?? '—';
const extra = hosts.length > 1 ? ` +${hosts.length - 1}` : '';
return (
<div className="truncate max-w-[175px] text-sm text-text-primary" title={hosts.join(', ')}>
{primary}<span className="text-text-disabled text-xs">{extra}</span>
</div>
);
},
};
case 'client_headers':
return {
key: 'client_headers',
label: col.label,
sortable: false,
render: (_, row) =>
groupByIP && row.unique_client_headers && row.unique_client_headers.length > 0 ? (
<div className="space-y-1">
<div className="text-xs text-text-secondary font-medium">
{row.unique_client_headers.length} Header{row.unique_client_headers.length > 1 ? 's' : ''} unique{row.unique_client_headers.length > 1 ? 's' : ''}
</div>
{row.unique_client_headers.slice(0, 3).map((header, idx) => (
<div key={idx} className="text-xs text-text-primary break-all whitespace-normal font-mono">
{header}
</div>
))}
{row.unique_client_headers.length > 3 && (
<div className="text-xs text-text-disabled">
+{row.unique_client_headers.length - 3} autre{row.unique_client_headers.length - 3 > 1 ? 's' : ''}
</div>
)}
</div>
) : (
<div className="text-xs text-text-primary break-all whitespace-normal font-mono">
{row.client_headers || '-'}
</div>
),
};
case 'model_name':
return {
key: 'model_name',
label: col.label,
sortable: true,
render: (_, row) => <ModelBadge model={row.model_name} />,
};
case 'anubis':
return {
key: 'anubis_bot_name',
label: (
<span className="inline-flex items-center gap-1">
🤖 Anubis
<InfoTip content={TIPS.anubis_identification} />
</span>
),
sortable: false,
width: 'w-[140px]',
render: (_, row) => {
const name = row.anubis_bot_name;
const action = row.anubis_bot_action;
if (!name) return <span className="text-text-disabled text-xs"></span>;
const actionColor =
action === 'ALLOW' ? 'text-green-400' :
action === 'DENY' ? 'text-red-400' : 'text-yellow-400';
return (
<div className="truncate max-w-[135px]" title={`${name} · ${action}`}>
<span className={`text-xs font-medium ${actionColor}`}>{name}</span>
{action && <span className="text-[10px] text-text-disabled ml-1">· {action}</span>}
</div>
);
},
};
case 'anomaly_score':
return {
key: 'anomaly_score',
label: col.label,
sortable: true,
align: 'right' as const,
render: (_, row) => (
<ScoreBadge
score={row.anomaly_score}
threatLevel={row.threat_level}
botName={row.bot_name}
anubisAction={row.anubis_bot_action}
/>
),
};
case 'hits':
return {
key: 'hits',
label: col.label,
sortable: true,
align: 'right' as const,
render: (_, row) => (
<div className="text-sm text-text-primary font-medium">{row.hits ?? 0}</div>
),
};
case 'hit_velocity':
return {
key: 'hit_velocity',
label: col.label,
sortable: true,
align: 'right' as const,
render: (_, row) => (
<div
className={`text-sm font-medium ${
row.hit_velocity && row.hit_velocity > 10
? 'text-threat-high'
: row.hit_velocity && row.hit_velocity > 1
? 'text-threat-medium'
: 'text-text-primary'
}`}
>
{row.hit_velocity ? row.hit_velocity.toFixed(2) : '0.00'}
<span className="text-xs text-text-secondary ml-1">req/s</span>
</div>
),
};
case 'asn':
return {
key: 'asn_org',
label: col.label,
sortable: true,
width: 'w-[150px]',
render: (_, row) => (
<div className="truncate max-w-[145px]" title={`${row.asn_org ?? ''} AS${row.asn_number ?? ''}`}>
<span className="text-sm text-text-primary">{row.asn_org || `AS${row.asn_number}` || '—'}</span>
{row.asn_number && <span className="text-xs text-text-disabled ml-1">AS{row.asn_number}</span>}
</div>
),
};
case 'country':
return {
key: 'country_code',
label: col.label,
sortable: true,
align: 'center' as const,
render: (_, row) =>
row.country_code ? (
<span className="text-lg">{getFlag(row.country_code)}</span>
) : (
<span>-</span>
),
};
case 'detected_at':
return {
key: 'detected_at',
label: col.label,
sortable: true,
width: 'w-[110px]',
render: (_, row) => {
if (groupByIP && row.first_seen) {
const last = new Date(row.last_seen!);
return <div className="text-xs text-text-secondary whitespace-nowrap">{formatDate(last.toISOString())}</div>;
}
return (
<div className="text-xs text-text-secondary whitespace-nowrap">
{formatDateOnly(row.detected_at)} {formatTimeOnly(row.detected_at)}
</div>
);
},
};
default:
return { key: col.key, label: col.label, sortable: col.sortable };
}
});
return (
<div className="space-y-2 animate-fade-in">
{/* ── Barre unique : titre + pills + filtres + recherche ── */}
<div className="flex flex-wrap items-center gap-2 bg-background-secondary rounded-lg px-3 py-2">
{/* Titre + compteur */}
<div className="flex items-center gap-2 shrink-0">
<span className="font-semibold text-text-primary">Détections</span>
<span className="text-xs text-text-disabled bg-background-card rounded px-1.5 py-0.5">
{data.total.toLocaleString()}
</span>
</div>
<div className="w-px h-5 bg-background-card shrink-0" />
{/* Pills distribution */}
{threatDist.map(({ threat_level, count, percentage }) => {
const label = threat_level === 'KNOWN_BOT' ? '🤖 BOT' :
threat_level === 'ANUBIS_DENY' ? '🔴 RÈGLE' :
threat_level === 'HIGH' ? '⚠️ HIGH' :
threat_level === 'MEDIUM' ? '📊 MED' :
threat_level === 'CRITICAL' ? '🔥 CRIT' : threat_level;
const style = threat_level === 'KNOWN_BOT' ? 'bg-green-500/15 text-green-400 border-green-500/30 hover:bg-green-500/25' :
threat_level === 'ANUBIS_DENY' ? 'bg-red-500/15 text-red-400 border-red-500/30 hover:bg-red-500/25' :
threat_level === 'HIGH' ? 'bg-orange-500/15 text-orange-400 border-orange-500/30 hover:bg-orange-500/25' :
threat_level === 'MEDIUM' ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30 hover:bg-yellow-500/25' :
threat_level === 'CRITICAL' ? 'bg-red-700/15 text-red-300 border-red-700/30 hover:bg-red-700/25' :
'bg-background-card text-text-secondary border-background-card';
const filterVal = threat_level === 'KNOWN_BOT' ? 'BOT' : threat_level === 'ANUBIS_DENY' ? 'REGLE' : null;
const active = filterVal && scoreType === filterVal;
return (
<button
key={threat_level}
onClick={() => {
if (filterVal) handleFilterChange('score_type', scoreType === filterVal ? '' : filterVal);
else handleFilterChange('threat_level', threat_level);
}}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-xs font-medium transition-colors ${style} ${active ? 'ring-1 ring-offset-1 ring-current' : ''}`}
>
{label} <span className="font-bold">{count.toLocaleString()}</span>
<span className="opacity-50">{percentage.toFixed(0)}%</span>
</button>
);
})}
<div className="w-px h-5 bg-background-card shrink-0" />
{/* Filtres select */}
<select
value={modelName || ''}
onChange={(e) => handleFilterChange('model_name', e.target.value)}
className="bg-background-card border border-background-card rounded px-2 py-1 text-text-primary text-xs focus:outline-none focus:border-accent-primary"
>
<option value="">Tous modèles</option>
<option value="Complet">Complet</option>
<option value="Applicatif">Applicatif</option>
</select>
<select
value={scoreType || ''}
onChange={(e) => handleFilterChange('score_type', e.target.value)}
className="bg-background-card border border-background-card rounded px-2 py-1 text-text-primary text-xs focus:outline-none focus:border-accent-primary"
>
<option value="">Tous scores</option>
<option value="BOT">🟢 BOT</option>
<option value="REGLE">🔴 RÈGLE</option>
<option value="BOT_REGLE">BOT+RÈGLE</option>
<option value="SCORE">Score num.</option>
</select>
{(modelName || scoreType || search || sortField !== 'detected_at') && (
<button
onClick={() => setSearchParams({})}
className="text-xs text-text-secondary hover:text-text-primary bg-background-card rounded px-2 py-1 border border-background-card transition-colors"
>
Effacer
</button>
)}
{/* Spacer */}
<div className="flex-1" />
{/* Toggle grouper */}
<button
onClick={() => setGroupByIP(!groupByIP)}
className={`text-xs border rounded px-2 py-1 transition-colors shrink-0 ${
groupByIP ? 'bg-accent-primary text-white border-accent-primary' : 'bg-background-card text-text-secondary border-background-card hover:text-text-primary'
}`}
title={groupByIP ? 'Vue individuelle' : 'Vue groupée par IP'}
>
{groupByIP ? '⊞ Groupé' : '⊟ Individuel'}
</button>
{/* Sélecteur colonnes */}
<div className="relative shrink-0">
<button
onClick={() => setShowColumnSelector(!showColumnSelector)}
className="text-xs bg-background-card hover:bg-background-card/80 border border-background-card rounded px-2 py-1 text-text-primary transition-colors"
>
Colonnes
</button>
{showColumnSelector && (
<div className="absolute right-0 mt-1 w-44 bg-background-secondary border border-background-card rounded-lg shadow-lg z-20 p-2">
{columns.map(col => (
<label key={col.key} className="flex items-center gap-2 px-2 py-1 hover:bg-background-card rounded cursor-pointer">
<input
type="checkbox"
checked={col.visible}
onChange={() => toggleColumn(col.key)}
className="rounded bg-background-card border-background-card text-accent-primary"
/>
<span className="text-xs text-text-primary">{col.label}</span>
</label>
))}
</div>
)}
</div>
{/* Recherche */}
<form onSubmit={handleSearch} className="flex gap-1 shrink-0">
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="IP, JA4, Host..."
className="bg-background-card border border-background-card rounded px-2 py-1 text-xs text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary w-40"
/>
<button
type="submit"
className="bg-accent-primary hover:bg-accent-primary/80 text-white text-xs px-2 py-1 rounded transition-colors"
>
🔍
</button>
</form>
</div>
{/* ── Tableau ── */}
<div className="bg-background-secondary rounded-lg overflow-x-auto">
<DataTable<DetectionRow>
data={processedData.items as DetectionRow[]}
columns={tableColumns}
rowKey={(row) => `${row.src_ip}-${row.detected_at}-${groupByIP ? 'g' : 'i'}`}
defaultSortKey={sortField}
defaultSortDir={sortOrder}
onSort={handleSort}
onRowClick={(row) => navigate(`/detections/ip/${encodeURIComponent(row.src_ip)}`)}
emptyMessage="Aucune détection trouvée"
compact
/>
</div>
{/* ── Pagination ── */}
{data.total_pages > 1 && (
<div className="flex items-center justify-between text-sm">
<p className="text-text-secondary text-xs">
Page {data.page}/{data.total_pages} · {data.total.toLocaleString()} détections
</p>
<div className="flex gap-1">
<button
onClick={() => handlePageChange(data.page - 1)}
disabled={data.page === 1}
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs px-3 py-1.5 rounded transition-colors"
>
Précédent
</button>
<button
onClick={() => handlePageChange(data.page + 1)}
disabled={data.page === data.total_pages}
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs px-3 py-1.5 rounded transition-colors"
>
Suivant
</button>
</div>
</div>
)}
</div>
);
}
// Composant ModelBadge
function ModelBadge({ model }: { model: string }) {
const styles: Record<string, string> = {
Complet: 'bg-accent-primary/20 text-accent-primary',
Applicatif: 'bg-purple-500/20 text-purple-400',
};
return (
<span className={`${styles[model] || 'bg-background-card'} px-2 py-1 rounded text-xs`}>
{model}
</span>
);
}
// Composant ScoreBadge
// Les scores non-IF (ANUBIS_DENY, KNOWN_BOT) sont stockés comme sentinels
// (-1.0 et 0.0) et doivent être affichés comme des badges textuels,
// pas comme des scores numériques calculés par l'IsolationForest.
function ScoreBadge({
score,
threatLevel,
botName,
anubisAction,
}: {
score: number;
threatLevel?: string;
botName?: string;
anubisAction?: string;
}) {
// ANUBIS_DENY : menace identifiée par règle, pas par IF
if (threatLevel === 'ANUBIS_DENY' || anubisAction === 'DENY') {
return (
<span className="inline-flex items-center text-xs px-1.5 py-0.5 rounded border bg-red-500/15 text-red-400 border-red-500/30 font-medium">
RÈGLE
</span>
);
}
// KNOWN_BOT : bot légitime identifié par dictionnaire ou Anubis ALLOW
if (threatLevel === 'KNOWN_BOT' || (botName && botName !== '')) {
return (
<span className="inline-flex items-center text-xs px-1.5 py-0.5 rounded border bg-green-500/15 text-green-400 border-green-500/30 font-medium">
BOT
</span>
);
}
// Score IF réel
let color = 'text-threat-low';
if (score < -0.3) color = 'text-threat-critical';
else if (score < -0.15) color = 'text-threat-high';
else if (score < -0.05) color = 'text-threat-medium';
return (
<span className={`font-mono text-sm ${color}`}>
{score.toFixed(3)}
</span>
);
}
// Helper pour les drapeaux
function getFlag(countryCode: string): string {
const code = countryCode.toUpperCase();
return code.replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
}

View File

@ -1,397 +0,0 @@
import { useParams, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips';
import { formatDateOnly } from '../utils/dateUtils';
import { getCountryFlag } from '../utils/countryUtils';
interface EntityStats {
entity_type: string;
entity_value: string;
total_requests: number;
unique_ips: number;
first_seen: string;
last_seen: string;
}
interface EntityRelatedAttributes {
ips: string[];
ja4s: string[];
hosts: string[];
asns: string[];
countries: string[];
}
interface AttributeValue {
value: string;
count: number;
percentage: number;
}
interface EntityInvestigationData {
stats: EntityStats;
related: EntityRelatedAttributes;
user_agents: AttributeValue[];
client_headers: AttributeValue[];
paths: AttributeValue[];
query_params: AttributeValue[];
}
export function EntityInvestigationView() {
const { type, value } = useParams<{ type: string; value: string }>();
const navigate = useNavigate();
const [data, setData] = useState<EntityInvestigationData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showAllUA, setShowAllUA] = useState(false);
useEffect(() => {
if (!type || !value) {
setError("Type ou valeur d'entité manquant");
setLoading(false);
return;
}
const fetchInvestigation = async () => {
setLoading(true);
try {
const response = await fetch(`/api/entities/${type}/${encodeURIComponent(value)}`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Erreur chargement données');
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
fetchInvestigation();
}, [type, value]);
const getEntityLabel = (entityType: string) => {
const labels: Record<string, string> = {
ip: 'Adresse IP',
ja4: 'Fingerprint JA4',
user_agent: 'User-Agent',
client_header: 'Client Header',
host: 'Host',
path: 'Path',
query_param: 'Query Params'
};
return labels[entityType] || entityType;
};
;
if (loading) {
return (
<div className="min-h-screen bg-background-primary">
<div className="container mx-auto px-4 py-8">
<div className="text-center text-text-secondary">Chargement...</div>
</div>
</div>
);
}
if (error || !data) {
return (
<div className="min-h-screen bg-background-primary">
<div className="container mx-auto px-4 py-8">
<div className="bg-threat-high/10 border border-threat-high rounded-lg p-6 text-center">
<div className="text-threat-high font-medium mb-2">Erreur</div>
<div className="text-text-secondary">{error || 'Données non disponibles'}</div>
<button
onClick={() => navigate(-1)}
className="mt-4 bg-accent-primary text-white px-6 py-2 rounded-lg hover:bg-accent-primary/80"
>
Retour
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background-primary">
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="mb-8">
<button
onClick={() => navigate(-1)}
className="text-text-secondary hover:text-text-primary transition-colors mb-4"
>
Retour
</button>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-text-primary mb-2">
Investigation: {getEntityLabel(data.stats.entity_type)}
</h1>
<div className="text-text-secondary font-mono text-sm break-all max-w-4xl">
{data.stats.entity_value}
</div>
</div>
<div className="text-right text-sm text-text-secondary">
<div>Requêtes: <span className="text-text-primary font-bold">{data.stats.total_requests.toLocaleString()}</span></div>
<div>IPs Uniques: <span className="text-text-primary font-bold">{data.stats.unique_ips.toLocaleString()}</span></div>
</div>
</div>
</div>
{/* Stats Summary */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<StatCard
label="Total Requêtes"
value={data.stats.total_requests.toLocaleString()}
/>
<StatCard
label="IPs Uniques"
value={data.stats.unique_ips.toLocaleString()}
/>
<StatCard
label="Première Détection"
value={formatDateOnly(data.stats.first_seen)}
/>
<StatCard
label="Dernière Détection"
value={formatDateOnly(data.stats.last_seen)}
/>
</div>
{/* Panel 1: IPs Associées */}
<div className="bg-background-secondary rounded-lg p-6 mb-6">
<h3 className="text-lg font-medium text-text-primary mb-4">1. IPs Associées</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
{data.related.ips.slice(0, 20).map((ip, idx) => (
<button
key={idx}
onClick={() => navigate(`/investigation/${ip}`)}
className="text-left px-3 py-2 bg-background-card rounded-lg text-sm text-text-primary hover:bg-background-card/80 transition-colors font-mono"
>
{ip}
</button>
))}
</div>
{data.related.ips.length === 0 && (
<div className="text-center text-text-secondary py-8">Aucune IP associée</div>
)}
{data.related.ips.length > 20 && (
<div className="text-center text-text-secondary mt-4 text-sm">
+{data.related.ips.length - 20} autres IPs
</div>
)}
</div>
{/* Panel 2: JA4 Fingerprints */}
<div className="bg-background-secondary rounded-lg p-6 mb-6">
<h3 className="text-lg font-medium text-text-primary mb-4"><span className="flex items-center gap-1">2. JA4 Fingerprints<InfoTip content={TIPS.ja4} /></span></h3>
<div className="space-y-2">
{data.related.ja4s.slice(0, 10).map((ja4, idx) => (
<div key={idx} className="flex items-center justify-between bg-background-card rounded-lg p-3">
<div className="font-mono text-sm text-text-primary break-all flex-1">
{ja4}
</div>
<button
onClick={() => navigate(`/investigation/ja4/${encodeURIComponent(ja4)}`)}
className="ml-4 text-xs bg-accent-primary text-white px-3 py-1 rounded hover:bg-accent-primary/80 whitespace-nowrap"
>
Investigation
</button>
</div>
))}
</div>
{data.related.ja4s.length === 0 && (
<div className="text-center text-text-secondary py-8">Aucun JA4 associé</div>
)}
{data.related.ja4s.length > 10 && (
<div className="text-center text-text-secondary mt-4 text-sm">
+{data.related.ja4s.length - 10} autres JA4
</div>
)}
</div>
{/* Panel 3: User-Agents */}
<div className="bg-background-secondary rounded-lg p-6 mb-6">
<h3 className="text-lg font-medium text-text-primary mb-4">3. User-Agents</h3>
<div className="space-y-3">
{(showAllUA ? data.user_agents : data.user_agents.slice(0, 10)).map((ua, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3 space-y-2">
<div className="text-xs text-text-primary font-mono break-all leading-relaxed">
{ua.value}
</div>
<div className="flex items-center gap-2">
<div className="text-text-secondary text-xs">{ua.count} requêtes</div>
<div className="text-text-secondary text-xs">{ua.percentage.toFixed(1)}%</div>
</div>
</div>
))}
</div>
{data.user_agents.length === 0 && (
<div className="text-center text-text-secondary py-8">Aucun User-Agent</div>
)}
{data.user_agents.length > 10 && (
<button
onClick={() => setShowAllUA(v => !v)}
className="mt-4 w-full text-xs text-accent-primary hover:text-accent-primary/80 transition-colors"
>
{showAllUA ? '↑ Réduire' : `↓ Voir les ${data.user_agents.length - 10} autres`}
</button>
)}
</div>
{/* Panel 4: Client Headers */}
<div className="bg-background-secondary rounded-lg p-6 mb-6">
<h3 className="text-lg font-medium text-text-primary mb-4"><span className="flex items-center gap-1">4. Client Headers<InfoTip content={TIPS.accept_encoding} /></span></h3>
<div className="space-y-3">
{data.client_headers.slice(0, 10).map((header, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3 space-y-2">
<div className="text-xs text-text-primary font-mono break-all">
{header.value}
</div>
<div className="flex items-center gap-2">
<div className="text-text-secondary text-xs">{header.count} requêtes</div>
<div className="text-text-secondary text-xs">{header.percentage.toFixed(1)}%</div>
</div>
</div>
))}
</div>
{data.client_headers.length === 0 && (
<div className="text-center text-text-secondary py-8">Aucun Client Header</div>
)}
{data.client_headers.length > 10 && (
<div className="text-center text-text-secondary mt-4 text-sm">
+{data.client_headers.length - 10} autres Client Headers
</div>
)}
</div>
{/* Panel 5: Hosts */}
<div className="bg-background-secondary rounded-lg p-6 mb-6">
<h3 className="text-lg font-medium text-text-primary mb-4">5. Hosts Ciblés</h3>
<div className="space-y-2">
{data.related.hosts.slice(0, 15).map((host, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3">
<div className="text-sm text-text-primary break-all">{host}</div>
</div>
))}
</div>
{data.related.hosts.length === 0 && (
<div className="text-center text-text-secondary py-8">Aucun Host associé</div>
)}
{data.related.hosts.length > 15 && (
<div className="text-center text-text-secondary mt-4 text-sm">
+{data.related.hosts.length - 15} autres Hosts
</div>
)}
</div>
{/* Panel 6: Paths */}
<div className="bg-background-secondary rounded-lg p-6 mb-6">
<h3 className="text-lg font-medium text-text-primary mb-4">6. Paths</h3>
<div className="space-y-2">
{data.paths.slice(0, 15).map((path, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3">
<div className="text-sm text-text-primary font-mono break-all">{path.value}</div>
<div className="flex items-center gap-2 mt-1">
<div className="text-text-secondary text-xs">{path.count} requêtes</div>
<div className="text-text-secondary text-xs">{path.percentage.toFixed(1)}%</div>
</div>
</div>
))}
</div>
{data.paths.length === 0 && (
<div className="text-center text-text-secondary py-8">Aucun Path</div>
)}
{data.paths.length > 15 && (
<div className="text-center text-text-secondary mt-4 text-sm">
+{data.paths.length - 15} autres Paths
</div>
)}
</div>
{/* Panel 7: Query Params */}
<div className="bg-background-secondary rounded-lg p-6 mb-6">
<h3 className="text-lg font-medium text-text-primary mb-4">7. Query Params</h3>
<div className="space-y-2">
{data.query_params.slice(0, 15).map((qp, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3">
<div className="text-sm text-text-primary font-mono break-all">{qp.value}</div>
<div className="flex items-center gap-2 mt-1">
<div className="text-text-secondary text-xs">{qp.count} requêtes</div>
<div className="text-text-secondary text-xs">{qp.percentage.toFixed(1)}%</div>
</div>
</div>
))}
</div>
{data.query_params.length === 0 && (
<div className="text-center text-text-secondary py-8">Aucun Query Param</div>
)}
{data.query_params.length > 15 && (
<div className="text-center text-text-secondary mt-4 text-sm">
+{data.query_params.length - 15} autres Query Params
</div>
)}
</div>
{/* Panel 8: ASNs & Pays */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
{/* ASNs */}
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4"><span className="flex items-center gap-1">ASNs<InfoTip content={TIPS.asn} /></span></h3>
<div className="space-y-2">
{data.related.asns.slice(0, 10).map((asn, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3">
<div className="text-sm text-text-primary">{asn}</div>
</div>
))}
</div>
{data.related.asns.length === 0 && (
<div className="text-center text-text-secondary py-8">Aucun ASN</div>
)}
{data.related.asns.length > 10 && (
<div className="text-center text-text-secondary mt-4 text-sm">
+{data.related.asns.length - 10} autres ASNs
</div>
)}
</div>
{/* Pays */}
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">Pays</h3>
<div className="space-y-2">
{data.related.countries.slice(0, 10).map((country, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3 flex items-center gap-2">
<span className="text-xl">{getCountryFlag(country)}</span>
<span className="text-sm text-text-primary">{country}</span>
</div>
))}
</div>
{data.related.countries.length === 0 && (
<div className="text-center text-text-secondary py-8">Aucun pays</div>
)}
{data.related.countries.length > 10 && (
<div className="text-center text-text-secondary mt-4 text-sm">
+{data.related.countries.length - 10} autres pays
</div>
)}
</div>
</div>
</div>
</div>
);
}
function StatCard({ label, value }: { label: string; value: string }) {
return (
<div className="bg-background-secondary rounded-lg p-4">
<div className="text-xs text-text-secondary mb-1">{label}</div>
<div className="text-2xl font-bold text-text-primary">{value}</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,333 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import DataTable, { Column } from './ui/DataTable';
import { TIPS } from './ui/tooltips';
import { formatNumber } from '../utils/dateUtils';
import { ErrorMessage } from './ui/Feedback';
// ─── Types ────────────────────────────────────────────────────────────────────
interface HeaderCluster {
hash: string;
unique_ips: number;
avg_browser_score: number;
ua_ch_mismatch_count: number;
ua_ch_mismatch_pct: number;
top_sec_fetch_modes: string[];
has_cookie_pct: number;
has_referer_pct: number;
classification: string;
}
interface ClusterIP {
ip: string;
browser_score: number;
ua_ch_mismatch: boolean;
sec_fetch_mode: string;
sec_fetch_dest: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function mismatchColor(pct: number): string {
if (pct > 50) return 'text-threat-critical';
if (pct > 10) return 'text-threat-medium';
return 'text-threat-low';
}
function browserScoreColor(score: number): string {
if (score >= 70) return 'bg-threat-low';
if (score >= 40) return 'bg-threat-medium';
return 'bg-threat-critical';
}
function classificationBadge(cls: string): { bg: string; text: string; label: string } {
switch (cls) {
case 'bot':
return { bg: 'bg-threat-critical/20', text: 'text-threat-critical', label: '🤖 Bot' };
case 'suspicious':
return { bg: 'bg-threat-high/20', text: 'text-threat-high', label: '⚠️ Suspect' };
case 'legitimate':
return { bg: 'bg-threat-low/20', text: 'text-threat-low', label: '✅ Légitime' };
default:
return { bg: 'bg-background-card', text: 'text-text-secondary', label: cls };
}
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
return (
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
<span className="text-text-secondary text-sm">{label}</span>
<span className={`text-2xl font-bold ${accent ?? 'text-text-primary'}`}>{value}</span>
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function HeaderFingerprintView() {
const navigate = useNavigate();
const [clusters, setClusters] = useState<HeaderCluster[]>([]);
const [totalClusters, setTotalClusters] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedHash, setExpandedHash] = useState<string | null>(null);
const [clusterIPsMap, setClusterIPsMap] = useState<Record<string, ClusterIP[]>>({});
const [loadingHashes, setLoadingHashes] = useState<Set<string>>(new Set());
const [ipErrors, setIpErrors] = useState<Record<string, string>>({});
const expandedPanelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const fetchClusters = async () => {
setLoading(true);
try {
const res = await fetch('/api/headers/clusters?limit=50');
if (!res.ok) throw new Error('Erreur chargement des clusters');
const data: { clusters: HeaderCluster[]; total_clusters: number } = await res.json();
setClusters(data.clusters ?? []);
setTotalClusters(data.total_clusters ?? 0);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
fetchClusters();
}, []);
const handleToggleCluster = async (hash: string) => {
if (expandedHash === hash) {
setExpandedHash(null);
return;
}
setExpandedHash(hash);
setTimeout(() => expandedPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
if (clusterIPsMap[hash] !== undefined) return;
setLoadingHashes((prev) => new Set(prev).add(hash));
try {
const res = await fetch(`/api/headers/cluster/${hash}/ips?limit=50`);
if (!res.ok) throw new Error('Erreur chargement IPs');
const data: { items: ClusterIP[] } = await res.json();
setClusterIPsMap((prev) => ({ ...prev, [hash]: data.items ?? [] }));
} catch (err) {
setIpErrors((prev) => ({ ...prev, [hash]: err instanceof Error ? err.message : 'Erreur inconnue' }));
} finally {
setLoadingHashes((prev) => {
const next = new Set(prev);
next.delete(hash);
return next;
});
}
};
const suspiciousClusters = clusters.filter((c) => c.ua_ch_mismatch_pct > 50).length;
const legitimateClusters = clusters.filter((c) => c.classification === 'legitimate').length;
const clusterColumns: Column<HeaderCluster>[] = [
{
key: 'hash',
label: 'Hash cluster',
tooltip: TIPS.hash_cluster,
sortable: true,
render: (_, row) => (
<span>
<span className="text-accent-primary text-xs mr-2">{expandedHash === row.hash ? '▾' : '▸'}</span>
<span className="font-mono text-xs text-text-primary">{row.hash.slice(0, 16)}</span>
</span>
),
},
{
key: 'unique_ips',
label: 'IPs',
sortable: true,
align: 'right',
render: (v) => <span className="text-text-primary">{formatNumber(v)}</span>,
},
{
key: 'avg_browser_score',
label: 'Browser Score',
tooltip: TIPS.browser_score,
sortable: true,
render: (v) => (
<div className="flex items-center gap-2">
<div className="w-20 bg-background-card rounded-full h-2">
<div className={`h-2 rounded-full ${browserScoreColor(v)}`} style={{ width: `${v}%` }} />
</div>
<span className="text-xs text-text-secondary">{Math.round(v)}</span>
</div>
),
},
{
key: 'ua_ch_mismatch_pct',
label: 'UA/CH Mismatch %',
tooltip: TIPS.ua_mismatch,
sortable: true,
align: 'right',
render: (v) => (
<span className={`font-semibold text-sm ${mismatchColor(v)}`}>{Math.round(v)}%</span>
),
},
{
key: 'classification',
label: 'Classification',
sortable: true,
render: (v) => {
const badge = classificationBadge(v);
return (
<span className={`text-xs px-2 py-1 rounded-full ${badge.bg} ${badge.text}`}>{badge.label}</span>
);
},
},
{
key: 'top_sec_fetch_modes',
label: 'Sec-Fetch modes',
tooltip: TIPS.sec_fetch,
sortable: false,
render: (v) => (
<div className="flex flex-wrap gap-1">
{(v ?? []).slice(0, 3).map((mode: string) => (
<span key={mode} className="text-xs bg-background-card border border-border px-1.5 py-0.5 rounded text-text-secondary">
{mode}
</span>
))}
</div>
),
},
];
const ipColumns: Column<ClusterIP>[] = [
{
key: 'ip',
label: 'IP',
sortable: true,
render: (v) => <span className="font-mono text-text-primary">{v}</span>,
},
{
key: 'browser_score',
label: 'Browser Score',
sortable: true,
align: 'right',
render: (v) => (
<span className={v >= 70 ? 'text-threat-low' : v >= 40 ? 'text-threat-medium' : 'text-threat-critical'}>
{Math.round(v)}
</span>
),
},
{
key: 'ua_ch_mismatch',
label: 'UA/CH Mismatch',
sortable: true,
render: (v) =>
v ? (
<span className="text-threat-critical"> Oui</span>
) : (
<span className="text-threat-low"> Non</span>
),
},
{
key: 'sec_fetch_mode',
label: 'Sec-Fetch Mode',
tooltip: TIPS.sec_fetch_dest,
sortable: true,
render: (v) => <span className="text-text-secondary">{v || '—'}</span>,
},
{
key: 'sec_fetch_dest',
label: 'Sec-Fetch Dest',
tooltip: TIPS.sec_fetch_dest,
sortable: true,
render: (v) => <span className="text-text-secondary">{v || '—'}</span>,
},
{
key: 'actions',
label: '',
sortable: false,
render: (_, row) => (
<button
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.ip}`); }}
className="bg-accent-primary/10 text-accent-primary px-2 py-0.5 rounded hover:bg-accent-primary/20 transition-colors text-xs"
>
Investiguer
</button>
),
},
];
return (
<div className="p-6 space-y-6 animate-fade-in">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-text-primary">📡 Fingerprint HTTP Headers</h1>
<p className="text-text-secondary mt-1">
Clustering par ordre et composition des headers HTTP pour identifier les bots et fingerprints suspects.
</p>
</div>
{/* Stat cards */}
<div className="grid grid-cols-3 gap-4">
<StatCard label="Total clusters" value={formatNumber(totalClusters)} accent="text-text-primary" />
<StatCard label="Clusters suspects (UA/CH >50%)" value={formatNumber(suspiciousClusters)} accent="text-threat-critical" />
<StatCard label="Clusters légitimes" value={formatNumber(legitimateClusters)} accent="text-threat-low" />
</div>
{/* Clusters DataTable */}
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
{error ? (
<div className="p-4"><ErrorMessage message={error} /></div>
) : (
<DataTable<HeaderCluster>
data={clusters}
columns={clusterColumns}
rowKey="hash"
defaultSortKey="unique_ips"
onRowClick={(row) => handleToggleCluster(row.hash)}
loading={loading}
emptyMessage="Aucun cluster détecté"
compact
maxHeight="max-h-[480px]"
/>
)}
</div>
{/* Expanded IPs panel */}
{expandedHash && (
<div ref={expandedPanelRef} className="bg-background-secondary rounded-lg border border-border overflow-hidden">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<span className="text-sm font-semibold text-text-primary">
IPs du cluster{' '}
<span className="font-mono text-xs text-accent-primary">{expandedHash.slice(0, 16)}</span>
</span>
<button
onClick={() => setExpandedHash(null)}
className="text-text-secondary hover:text-text-primary text-xs"
>
Fermer
</button>
</div>
{ipErrors[expandedHash] ? (
<div className="p-4"><ErrorMessage message={ipErrors[expandedHash]} /></div>
) : (
<DataTable<ClusterIP>
data={clusterIPsMap[expandedHash] ?? []}
columns={ipColumns}
rowKey="ip"
defaultSortKey="browser_score"
loading={loadingHashes.has(expandedHash)}
emptyMessage="Aucune IP trouvée"
compact
/>
)}
</div>
)}
{!loading && !error && (
<p className="text-text-secondary text-xs">{formatNumber(totalClusters)} cluster(s) détecté(s)</p>
)}
</div>
);
}

View File

@ -1,567 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips';
import { getCountryFlag } from '../utils/countryUtils';
interface IncidentCluster {
id: string;
score: number;
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
total_detections: number;
unique_ips: number;
subnet?: string;
sample_ip?: string;
ja4?: string;
primary_ua?: string;
primary_target?: string;
countries: { code: string; percentage: number }[];
asn?: string;
first_seen: string;
last_seen: string;
trend: 'up' | 'down' | 'stable';
trend_percentage: number;
hits_per_second?: number;
}
interface MetricsSummary {
total_detections: number;
critical_count: number;
high_count: number;
medium_count: number;
low_count: number;
unique_ips: number;
known_bots_count: number;
anomalies_count: number;
}
interface BaselineMetric {
today: number;
yesterday: number;
pct_change: number;
}
interface BaselineData {
total_detections: BaselineMetric;
unique_ips: BaselineMetric;
critical_alerts: BaselineMetric;
}
export function IncidentsView() {
const navigate = useNavigate();
const [clusters, setClusters] = useState<IncidentCluster[]>([]);
const [metrics, setMetrics] = useState<MetricsSummary | null>(null);
const [baseline, setBaseline] = useState<BaselineData | null>(null);
const [loading, setLoading] = useState(true);
const [selectedClusters, setSelectedClusters] = useState<Set<string>>(new Set());
useEffect(() => {
const fetchIncidents = async () => {
setLoading(true);
try {
const metricsResponse = await fetch('/api/metrics');
if (metricsResponse.ok) {
const metricsData = await metricsResponse.json();
setMetrics(metricsData.summary);
}
const baselineResponse = await fetch('/api/metrics/baseline');
if (baselineResponse.ok) {
setBaseline(await baselineResponse.json());
}
const clustersResponse = await fetch('/api/incidents/clusters');
if (clustersResponse.ok) {
const clustersData = await clustersResponse.json();
setClusters(clustersData.items || []);
}
} catch (error) {
console.error('Error fetching incidents:', error);
} finally {
setLoading(false);
}
};
fetchIncidents();
const interval = setInterval(fetchIncidents, 60000);
return () => clearInterval(interval);
}, []);
const toggleCluster = (id: string) => {
const newSelected = new Set(selectedClusters);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
setSelectedClusters(newSelected);
};
const selectAll = () => {
if (selectedClusters.size === clusters.length) {
setSelectedClusters(new Set());
} else {
setSelectedClusters(new Set(clusters.map(c => c.id)));
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'CRITICAL': return 'border-red-500 bg-red-500/10';
case 'HIGH': return 'border-orange-500 bg-orange-500/10';
case 'MEDIUM': return 'border-yellow-500 bg-yellow-500/10';
case 'LOW': return 'border-green-500 bg-green-500/10';
default: return 'border-gray-500 bg-gray-500/10';
}
};
const getSeverityBadgeColor = (severity: string) => {
switch (severity) {
case 'CRITICAL': return 'bg-red-500 text-white';
case 'HIGH': return 'bg-orange-500 text-white';
case 'MEDIUM': return 'bg-yellow-500 text-white';
case 'LOW': return 'bg-green-500 text-white';
default: return 'bg-gray-500 text-white';
}
};
;
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-text-secondary">Chargement...</div>
</div>
);
}
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-text-primary">SOC Dashboard</h1>
<p className="text-text-secondary text-sm mt-1">Surveillance en temps réel · 24 dernières heures</p>
</div>
</div>
{/* Stats unifiées — 6 cartes compact */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{/* Total détections avec comparaison hier */}
<div
className="bg-background-card border border-border rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-accent-primary/50 transition-colors"
onClick={() => navigate('/detections')}
>
<div className="text-[10px] text-text-disabled uppercase tracking-wide flex items-center gap-1">
📊 Total 24h<InfoTip content={TIPS.total_detections_stat} />
</div>
<div className="text-xl font-bold text-text-primary">
{(metrics?.total_detections ?? 0).toLocaleString()}
</div>
{baseline && (() => {
const m = baseline.total_detections;
const up = m.pct_change > 0;
const neutral = m.pct_change === 0;
return (
<div className={`text-[10px] font-medium ${neutral ? 'text-text-disabled' : up ? 'text-threat-critical' : 'text-threat-low'}`}>
{neutral ? '= même' : up ? `▲ +${m.pct_change}%` : `${m.pct_change}%`} vs hier
</div>
);
})()}
</div>
{/* IPs uniques */}
<div
className="bg-background-card border border-border rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-accent-primary/50 transition-colors"
onClick={() => navigate('/detections')}
>
<div className="text-[10px] text-text-disabled uppercase tracking-wide flex items-center gap-1">
🖥 IPs uniques<InfoTip content={TIPS.unique_ips_stat} />
</div>
<div className="text-xl font-bold text-text-primary">
{(metrics?.unique_ips ?? 0).toLocaleString()}
</div>
{baseline && (() => {
const m = baseline.unique_ips;
const up = m.pct_change > 0;
const neutral = m.pct_change === 0;
return (
<div className={`text-[10px] font-medium ${neutral ? 'text-text-disabled' : up ? 'text-threat-critical' : 'text-threat-low'}`}>
{neutral ? '= même' : up ? `▲ +${m.pct_change}%` : `${m.pct_change}%`} vs hier
</div>
);
})()}
</div>
{/* BOT connus */}
<div
className="bg-green-500/10 border border-green-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-green-500/60 transition-colors"
onClick={() => navigate('/detections?score_type=BOT')}
>
<div className="text-[10px] text-green-400/80 uppercase tracking-wide">🤖 BOT nommés</div>
<div className="text-xl font-bold text-green-400">
{(metrics?.known_bots_count ?? 0).toLocaleString()}
</div>
<div className="text-[10px] text-green-400/60">
{metrics ? Math.round((metrics.known_bots_count / metrics.total_detections) * 100) : 0}% du total
</div>
</div>
{/* Anomalies ML */}
<div
className="bg-purple-500/10 border border-purple-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-purple-500/60 transition-colors"
onClick={() => navigate('/detections?score_type=SCORE')}
>
<div className="text-[10px] text-purple-400/80 uppercase tracking-wide">🔬 Anomalies ML</div>
<div className="text-xl font-bold text-purple-400">
{(metrics?.anomalies_count ?? 0).toLocaleString()}
</div>
<div className="text-[10px] text-purple-400/60">
{metrics ? Math.round((metrics.anomalies_count / metrics.total_detections) * 100) : 0}% du total
</div>
</div>
{/* HIGH */}
<div
className="bg-orange-500/10 border border-orange-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-orange-500/60 transition-colors"
onClick={() => navigate('/detections?threat_level=HIGH')}
>
<div className="text-[10px] text-orange-400/80 uppercase tracking-wide"> HIGH</div>
<div className="text-xl font-bold text-orange-400">
{(metrics?.high_count ?? 0).toLocaleString()}
</div>
<div className="text-[10px] text-orange-400/60">Menaces élevées</div>
</div>
{/* MEDIUM */}
<div
className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-yellow-500/60 transition-colors"
onClick={() => navigate('/detections?threat_level=MEDIUM')}
>
<div className="text-[10px] text-yellow-400/80 uppercase tracking-wide">📊 MEDIUM</div>
<div className="text-xl font-bold text-yellow-400">
{(metrics?.medium_count ?? 0).toLocaleString()}
</div>
<div className="text-[10px] text-yellow-400/60">Menaces moyennes</div>
</div>
</div>
{/* Bulk Actions */}
{selectedClusters.size > 0 && (
<div className="bg-blue-500/20 border border-blue-500 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="text-text-primary">
<span className="font-bold">{selectedClusters.size}</span> incidents sélectionnés
</div>
<div className="flex gap-2">
<button
onClick={() => {
// Bulk classification
const ips = clusters
.filter(c => selectedClusters.has(c.id))
.flatMap(c => c.subnet ? [c.subnet.split('/')[0]] : []);
navigate(`/bulk-classify?ips=${encodeURIComponent(ips.join(','))}`);
}}
className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:bg-blue-600 transition-colors"
>
Classifier en masse
</button>
<button
onClick={() => {
// Export selected
const data = clusters.filter(c => selectedClusters.has(c.id));
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `export_incidents_${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}}
className="px-4 py-2 bg-background-card text-text-primary rounded-lg text-sm hover:bg-background-card/80 transition-colors"
>
Export JSON
</button>
<button
onClick={() => setSelectedClusters(new Set())}
className="px-4 py-2 bg-background-card text-text-primary rounded-lg text-sm hover:bg-background-card/80 transition-colors"
>
Désélectionner
</button>
</div>
</div>
</div>
)}
{/* Main content: incidents list (2/3) + top threats table (1/3) */}
<div className="grid grid-cols-3 gap-6 items-start">
{/* Incidents list — 2/3 */}
<div className="col-span-2">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-text-primary">
Incidents Prioritaires
</h2>
<button
onClick={selectAll}
className="text-sm text-accent-primary hover:text-accent-primary/80"
>
{selectedClusters.size === clusters.length ? 'Tout désélectionner' : 'Tout sélectionner'}
</button>
</div>
<div className="space-y-3">
{clusters.map((cluster) => (
<div
key={cluster.id}
className={`border-2 rounded-lg p-4 transition-all hover:shadow-lg ${getSeverityColor(cluster.severity)}`}
>
<div className="flex items-start gap-4">
{/* Checkbox */}
<input
type="checkbox"
checked={selectedClusters.has(cluster.id)}
onChange={() => toggleCluster(cluster.id)}
className="mt-1 w-4 h-4 rounded bg-background-card border-background-card text-accent-primary focus:ring-accent-primary"
/>
{/* Content */}
<div className="flex-1">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<span title={cluster.severity === 'CRITICAL' ? TIPS.risk_critical : cluster.severity === 'HIGH' ? TIPS.risk_high : cluster.severity === 'MEDIUM' ? TIPS.risk_medium : TIPS.risk_low} className={`px-2 py-1 rounded text-xs font-bold ${getSeverityBadgeColor(cluster.severity)}`}>
{cluster.severity}
</span>
<span className="text-lg font-bold text-text-primary">{cluster.id}</span>
<span className="text-text-secondary">|</span>
<span className="font-mono text-sm text-text-primary">{cluster.subnet || ''}</span>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="text-2xl font-bold text-text-primary">{cluster.score}/100</div>
<div className="text-xs text-text-secondary flex items-center gap-1">Score de risque<InfoTip content={TIPS.risk_score_inv} /></div>
</div>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-3">
<div>
<div className="text-xs text-text-secondary mb-1">IPs</div>
<div className="text-text-primary font-bold">{cluster.unique_ips}</div>
</div>
<div>
<div className="text-xs text-text-secondary mb-1">Détections</div>
<div className="text-text-primary font-bold">{cluster.total_detections}</div>
</div>
<div>
<div className="text-xs text-text-secondary mb-1">Pays</div>
<div className="text-text-primary">
{cluster.countries[0] && (
<>
{getCountryFlag(cluster.countries[0].code)} {cluster.countries[0].code}
</>
)}
</div>
</div>
<div>
<div className="text-xs text-text-secondary mb-1 flex items-center gap-1">ASN<InfoTip content={TIPS.asn} /></div>
<div className="text-text-primary">AS{cluster.asn || '?'}</div>
</div>
<div>
<div className="text-xs text-text-secondary mb-1 flex items-center gap-1">Tendance<InfoTip content={TIPS.tendance} /></div>
<div className={`font-bold ${
cluster.trend === 'up' ? 'text-red-500' :
cluster.trend === 'down' ? 'text-green-500' :
'text-gray-400'
}`}>
{cluster.trend === 'up' ? '↑' : cluster.trend === 'down' ? '↓' : '→'} {cluster.trend_percentage}%
</div>
</div>
</div>
{cluster.ja4 && (
<div className="mb-3 p-2 bg-background-card rounded">
<div className="text-xs text-text-secondary mb-1 flex items-center gap-1">JA4 Principal<InfoTip content={TIPS.ja4} /></div>
<div className="font-mono text-xs text-text-primary break-all">{cluster.ja4}</div>
</div>
)}
<div className="flex gap-2">
<button
onClick={() => navigate(`/investigation/${cluster.sample_ip || cluster.subnet?.split('/')[0] || ''}`)}
className="px-3 py-1.5 bg-accent-primary text-white rounded text-sm hover:bg-accent-primary/80 transition-colors"
>
Investiguer
</button>
<button
onClick={() => navigate(`/entities/subnet/${encodeURIComponent((cluster.subnet || '').replace('/', '_'))}`)}
className="px-3 py-1.5 bg-background-card text-text-primary rounded text-sm hover:bg-background-card/80 transition-colors"
>
Voir détails
</button>
<button
onClick={() => {
// Quick classify
navigate(`/bulk-classify?ips=${encodeURIComponent(cluster.sample_ip || cluster.subnet?.split('/')[0] || '')}`);
}}
className="px-3 py-1.5 bg-background-card text-text-primary rounded text-sm hover:bg-background-card/80 transition-colors"
>
Classifier
</button>
<button
onClick={() => {
// Export STIX
const stixData = {
type: 'bundle',
id: `bundle--${cluster.id}`,
objects: [{
type: 'indicator',
id: `indicator--${cluster.id}`,
pattern: `[ipv4-addr:value = '${cluster.subnet?.split('/')[0] || ''}'`,
pattern_type: 'stix'
}]
};
const blob = new Blob([JSON.stringify(stixData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `stix_${cluster.id}_${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}}
className="px-3 py-1.5 bg-background-card text-text-primary rounded text-sm hover:bg-background-card/80 transition-colors"
>
Export STIX
</button>
</div>
</div>
</div>
</div>
))}
{clusters.length === 0 && (
<div className="bg-background-secondary rounded-lg p-12 text-center">
<h3 className="text-xl font-semibold text-text-primary mb-2">
Aucun incident actif
</h3>
<p className="text-text-secondary">
Le système ne détecte aucun incident prioritaire en ce moment.
</p>
</div>
)}
</div>
</div>{/* end col-span-2 */}
{/* Top threats sidebar — 1/3 */}
<div className="sticky top-4">
<div className="bg-background-secondary rounded-lg overflow-hidden">
<div className="p-4 border-b border-background-card">
<h3 className="text-base font-semibold text-text-primary">🔥 Top Menaces</h3>
</div>
<div className="divide-y divide-background-card">
{clusters.slice(0, 12).map((cluster, index) => (
<div
key={cluster.id}
className="px-4 py-3 flex items-center gap-3 hover:bg-background-card/50 transition-colors cursor-pointer"
onClick={() => navigate(`/investigation/${cluster.sample_ip || cluster.subnet?.split('/')[0] || ''}`)}
>
<span className="text-text-disabled text-xs w-4">{index + 1}</span>
<div className="flex-1 min-w-0">
<div className="font-mono text-xs text-text-primary truncate">
{cluster.sample_ip || cluster.subnet?.split('/')[0] || 'Unknown'}
</div>
<div className="text-xs text-text-secondary flex gap-2 mt-0.5">
{cluster.countries[0] && (
<span>{getCountryFlag(cluster.countries[0].code)} {cluster.countries[0].code}</span>
)}
<span>AS{cluster.asn || '?'}</span>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<span className={`px-1.5 py-0.5 rounded text-xs font-bold ${
cluster.score > 80 ? 'bg-red-500 text-white' :
cluster.score > 60 ? 'bg-orange-500 text-white' :
cluster.score > 40 ? 'bg-yellow-500 text-white' :
'bg-green-500 text-white'
}`}>
{cluster.score}
</span>
<span className={`text-xs font-bold ${
cluster.trend === 'up' ? 'text-red-500' :
cluster.trend === 'down' ? 'text-green-500' :
'text-gray-400'
}`}>
{cluster.trend === 'up' ? '↑' : cluster.trend === 'down' ? '↓' : '→'}
</span>
</div>
</div>
))}
{clusters.length === 0 && (
<div className="px-4 py-8 text-center text-text-secondary text-sm">
Aucune menace active
</div>
)}
</div>
</div>
</div>
</div>{/* end grid */}
<div className="mt-6">
<MiniHeatmap />
</div>
</div>
);
}
// ─── Mini Heatmap ─────────────────────────────────────────────────────────────
interface HeatmapHour {
hour: number;
hits: number;
unique_ips: number;
}
function MiniHeatmap() {
const [data, setData] = useState<HeatmapHour[]>([]);
useEffect(() => {
fetch('/api/heatmap/hourly')
.then(r => r.ok ? r.json() : null)
.then(d => { if (d) setData(d.hours ?? d.items ?? []); })
.catch(() => {});
}, []);
if (data.length === 0) return null;
const maxHits = Math.max(...data.map(d => d.hits), 1);
const barColor = (hits: number) => {
const pct = (hits / maxHits) * 100;
if (pct >= 75) return 'bg-red-500/70';
if (pct >= 50) return 'bg-purple-500/60';
if (pct >= 25) return 'bg-blue-500/50';
if (pct >= 5) return 'bg-blue-400/30';
return 'bg-slate-700/30';
};
return (
<div className="bg-background-secondary border border-border rounded-lg p-4">
<div className="text-sm font-semibold text-text-primary mb-3"> Activité par heure (72h)</div>
<div className="flex items-end gap-px h-16">
{data.map((d, i) => (
<div key={i} className="relative flex-1 flex flex-col items-center justify-end group">
<div
className={`w-full rounded-sm ${barColor(d.hits)}`}
style={{ height: `${Math.max((d.hits / maxHits) * 100, 2)}%` }}
/>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 hidden group-hover:flex bg-background-card border border-border text-xs text-text-primary rounded px-2 py-1 whitespace-nowrap z-10 pointer-events-none">
{d.hits.toLocaleString()} hits {d.unique_ips} IPs
</div>
<div className="text-[9px] text-text-disabled mt-0.5 leading-none">
{[0, 6, 12, 18].includes(d.hour) ? `${d.hour}h` : '\u00a0'}
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -1,376 +0,0 @@
import { useEffect, useState, useRef } from 'react';
import { format, parseISO } from 'date-fns';
import { fr } from 'date-fns/locale';
interface TimelineEvent {
timestamp: string;
type: 'detection' | 'escalation' | 'peak' | 'stabilization' | 'classification';
severity?: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
count?: number;
description?: string;
ip?: string;
ja4?: string;
}
interface InteractiveTimelineProps {
ip?: string;
events?: TimelineEvent[];
hours?: number;
height?: string;
}
export function InteractiveTimeline({
ip,
events: propEvents,
hours = 24,
height = '300px'
}: InteractiveTimelineProps) {
const [events, setEvents] = useState<TimelineEvent[]>([]);
const [loading, setLoading] = useState(true);
const [zoom, setZoom] = useState(1);
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const fetchTimelineData = async () => {
setLoading(true);
try {
if (ip) {
// Fetch detections for this IP to build timeline
const response = await fetch(`/api/detections?search=${encodeURIComponent(ip)}&page_size=100&sort_by=detected_at&sort_order=asc`);
if (response.ok) {
const data = await response.json();
const timelineEvents = buildTimelineFromDetections(data.items);
setEvents(timelineEvents);
}
} else if (propEvents) {
setEvents(propEvents);
}
} catch (error) {
console.error('Error fetching timeline data:', error);
} finally {
setLoading(false);
}
};
fetchTimelineData();
}, [ip, propEvents]);
const buildTimelineFromDetections = (detections: any[]): TimelineEvent[] => {
if (!detections || detections.length === 0) return [];
const events: TimelineEvent[] = [];
// First detection
events.push({
timestamp: detections[0]?.detected_at,
type: 'detection',
severity: detections[0]?.threat_level,
count: 1,
description: 'Première détection',
ip: detections[0]?.src_ip,
});
// Group by time windows (5 minutes)
const timeWindows = new Map<string, any[]>();
detections.forEach((d: any) => {
const window = format(parseISO(d.detected_at), 'yyyy-MM-dd HH:mm', { locale: fr });
if (!timeWindows.has(window)) {
timeWindows.set(window, []);
}
timeWindows.get(window)!.push(d);
});
// Find peaks
let maxCount = 0;
timeWindows.forEach((items) => {
if (items.length > maxCount) {
maxCount = items.length;
}
if (items.length >= 10) {
events.push({
timestamp: items[0]?.detected_at,
type: 'peak',
severity: items[0]?.threat_level,
count: items.length,
description: `Pic d'activité: ${items.length} détections`,
});
}
});
// Escalation detection
const sortedWindows = Array.from(timeWindows.entries()).sort((a, b) =>
new Date(a[0]).getTime() - new Date(b[0]).getTime()
);
for (let i = 1; i < sortedWindows.length; i++) {
const prevCount = sortedWindows[i - 1][1].length;
const currCount = sortedWindows[i][1].length;
if (currCount > prevCount * 2 && currCount >= 5) {
events.push({
timestamp: sortedWindows[i][1][0]?.detected_at,
type: 'escalation',
severity: 'HIGH',
count: currCount,
description: `Escalade: ${prevCount}${currCount} détections`,
});
}
}
// Last detection
if (detections.length > 1) {
events.push({
timestamp: detections[detections.length - 1]?.detected_at,
type: 'detection',
severity: detections[detections.length - 1]?.threat_level,
count: detections.length,
description: 'Dernière détection',
});
}
// Sort by timestamp
return events.sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
};
const getEventTypeColor = (type: string) => {
switch (type) {
case 'detection': return 'bg-blue-500';
case 'escalation': return 'bg-orange-500';
case 'peak': return 'bg-red-500';
case 'stabilization': return 'bg-green-500';
case 'classification': return 'bg-purple-500';
default: return 'bg-gray-500';
}
};
const getEventTypeIcon = (type: string) => {
switch (type) {
case 'detection': return '🔍';
case 'escalation': return '📈';
case 'peak': return '🔥';
case 'stabilization': return '📉';
case 'classification': return '🏷️';
default: return '📍';
}
};
const getSeverityColor = (severity?: string) => {
switch (severity) {
case 'CRITICAL': return 'text-red-500';
case 'HIGH': return 'text-orange-500';
case 'MEDIUM': return 'text-yellow-500';
case 'LOW': return 'text-green-500';
default: return 'text-gray-400';
}
};
const visibleEvents = events.slice(
Math.max(0, Math.floor((events.length * (1 - zoom)) / 2)),
Math.min(events.length, Math.ceil(events.length * (1 + zoom) / 2))
);
if (loading) {
return (
<div className="flex items-center justify-center" style={{ height }}>
<div className="text-text-secondary">Chargement de la timeline...</div>
</div>
);
}
if (events.length === 0) {
return (
<div className="flex items-center justify-center" style={{ height }}>
<div className="text-text-secondary text-center">
<div className="text-4xl mb-2">📭</div>
<div className="text-sm">Aucun événement dans cette période</div>
</div>
</div>
);
}
return (
<div ref={containerRef} className="w-full" style={{ height }}>
{/* Controls */}
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-text-secondary">
{events.length} événements sur {hours}h
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setZoom(Math.max(0.5, zoom - 0.25))}
className="px-3 py-1 bg-background-card text-text-primary rounded text-xs hover:bg-background-card/80"
>
Zoom
</button>
<button
onClick={() => setZoom(1)}
className="px-3 py-1 bg-background-card text-text-primary rounded text-xs hover:bg-background-card/80"
>
100%
</button>
<button
onClick={() => setZoom(Math.min(2, zoom + 0.25))}
className="px-3 py-1 bg-background-card text-text-primary rounded text-xs hover:bg-background-card/80"
>
+ Zoom
</button>
</div>
</div>
{/* Timeline */}
<div className="relative overflow-x-auto">
<div className="min-w-full">
{/* Time axis */}
<div className="flex justify-between mb-4 text-xs text-text-secondary">
{events.length > 0 && (
<>
<span>{format(parseISO(events[0].timestamp), 'dd/MM HH:mm', { locale: fr })}</span>
<span>{format(parseISO(events[events.length - 1].timestamp), 'dd/MM HH:mm', { locale: fr })}</span>
</>
)}
</div>
{/* Events line */}
<div className="relative h-24 border-t-2 border-background-card">
{visibleEvents.map((event, idx) => {
const position = (idx / (visibleEvents.length - 1 || 1)) * 100;
return (
<button
key={idx}
onClick={() => setSelectedEvent(event)}
className="absolute transform -translate-x-1/2 -translate-y-1/2 group"
style={{ left: `${position}%` }}
>
<div className={`w-4 h-4 rounded-full ${getEventTypeColor(event.type)} border-2 border-background-secondary shadow-lg group-hover:scale-150 transition-transform`}>
<div className="text-xs text-center leading-3">
{getEventTypeIcon(event.type)}
</div>
</div>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden group-hover:block">
<div className="bg-background-secondary border border-background-card rounded-lg p-2 shadow-xl whitespace-nowrap z-10">
<div className="text-xs text-text-primary font-bold">
{event.description}
</div>
<div className="text-xs text-text-secondary mt-1">
{format(parseISO(event.timestamp), 'dd/MM HH:mm:ss', { locale: fr })}
</div>
{event.count && (
<div className="text-xs text-text-secondary mt-1">
{event.count} détections
</div>
)}
</div>
</div>
</button>
);
})}
</div>
{/* Event cards */}
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-h-64 overflow-y-auto">
{visibleEvents.slice(0, 12).map((event, idx) => (
<div
key={idx}
onClick={() => setSelectedEvent(event)}
className={`bg-background-card rounded-lg p-3 cursor-pointer hover:bg-background-card/80 transition-colors border-l-4 ${
event.severity === 'CRITICAL' ? 'border-threat-critical' :
event.severity === 'HIGH' ? 'border-threat-high' :
event.severity === 'MEDIUM' ? 'border-threat-medium' :
'border-threat-low'
}`}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<span className="text-lg">{getEventTypeIcon(event.type)}</span>
<div>
<div className="text-sm text-text-primary font-medium">
{event.description}
</div>
<div className="text-xs text-text-secondary mt-1">
{format(parseISO(event.timestamp), 'dd/MM HH:mm', { locale: fr })}
</div>
</div>
</div>
{event.count && (
<div className="text-xs text-text-primary font-bold bg-background-secondary px-2 py-1 rounded">
{event.count}
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
{/* Selected Event Modal */}
{selectedEvent && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setSelectedEvent(null)}>
<div className="bg-background-secondary rounded-lg p-6 max-w-md mx-4" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="text-2xl">{getEventTypeIcon(selectedEvent.type)}</span>
<h3 className="text-lg font-bold text-text-primary">Détails de l'événement</h3>
</div>
<button onClick={() => setSelectedEvent(null)} className="text-text-secondary hover:text-text-primary">
</button>
</div>
<div className="space-y-3">
<div>
<div className="text-xs text-text-secondary">Type</div>
<div className="text-text-primary capitalize">{selectedEvent.type}</div>
</div>
<div>
<div className="text-xs text-text-secondary">Timestamp</div>
<div className="text-text-primary font-mono">
{format(parseISO(selectedEvent.timestamp), 'dd/MM/yyyy HH:mm:ss', { locale: fr })}
</div>
</div>
{selectedEvent.description && (
<div>
<div className="text-xs text-text-secondary">Description</div>
<div className="text-text-primary">{selectedEvent.description}</div>
</div>
)}
{selectedEvent.count && (
<div>
<div className="text-xs text-text-secondary">Nombre de détections</div>
<div className="text-text-primary font-bold">{selectedEvent.count}</div>
</div>
)}
{selectedEvent.severity && (
<div>
<div className="text-xs text-text-secondary">Sévérité</div>
<div className={`font-bold ${getSeverityColor(selectedEvent.severity)}`}>
{selectedEvent.severity}
</div>
</div>
)}
{selectedEvent.ip && (
<div>
<div className="text-xs text-text-secondary">IP</div>
<div className="text-text-primary font-mono text-sm">{selectedEvent.ip}</div>
</div>
)}
</div>
<div className="mt-6 flex justify-end">
<button
onClick={() => setSelectedEvent(null)}
className="px-4 py-2 bg-accent-primary text-white rounded-lg hover:bg-accent-primary/80"
>
Fermer
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -1,524 +0,0 @@
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { useVariability } from '../hooks/useVariability';
import { VariabilityPanel } from './VariabilityPanel';
import { formatDateShort } from '../utils/dateUtils';
import { SubnetAnalysis } from './analysis/SubnetAnalysis';
import { CountryAnalysis } from './analysis/CountryAnalysis';
import { JA4Analysis } from './analysis/JA4Analysis';
import { UserAgentAnalysis } from './analysis/UserAgentAnalysis';
import { CorrelationSummary } from './analysis/CorrelationSummary';
import { CorrelationGraph } from './CorrelationGraph';
import { ReputationPanel } from './ReputationPanel';
import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips';
// ─── Multi-source Activity Summary Widget ─────────────────────────────────────
interface IPSummary {
ip: string;
risk_score: number;
ml: { max_score: number; threat_level: string; attack_type: string; total_detections: number; distinct_hosts: number; distinct_ja4: number };
bruteforce: { active: boolean; hosts_attacked: number; total_hits: number; total_params: number; top_hosts: string[] };
tcp_spoofing: { detected: boolean; tcp_ttl: number | null; suspected_os: string | null; declared_os: string | null };
ja4_rotation: { rotating: boolean; distinct_ja4_count: number; total_hits?: number };
persistence: { persistent: boolean; recurrence: number; worst_score?: number; worst_threat_level?: string; first_seen?: string; last_seen?: string };
timeline_24h: { hour: number; hits: number; ja4s: string[] }[];
}
function RiskGauge({ score }: { score: number }) {
const color = score >= 75 ? '#ef4444' : score >= 50 ? '#f97316' : score >= 25 ? '#eab308' : '#22c55e';
return (
<div className="flex flex-col items-center gap-1">
<svg width="80" height="80" viewBox="0 0 80 80">
<circle cx="40" cy="40" r="34" fill="none" stroke="rgba(100,116,139,0.2)" strokeWidth="8" />
<circle cx="40" cy="40" r="34" fill="none" stroke={color} strokeWidth="8"
strokeDasharray={`${(score / 100) * 213.6} 213.6`}
strokeLinecap="round"
transform="rotate(-90 40 40)" />
<text x="40" y="44" textAnchor="middle" fontSize="18" fontWeight="bold" fill={color}>{score}</text>
</svg>
<span className="text-xs text-text-secondary flex items-center">Risk Score<InfoTip content={TIPS.risk_score_inv} /></span>
</div>
);
}
function ActivityBadge({ active, label, color }: { active: boolean; label: string; color: string }) {
return (
<div className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium ${
active ? `border-${color}/40 bg-${color}/10 text-${color}` : 'border-border bg-background-card text-text-disabled'
}`}>
<span>{active ? '●' : '○'}</span>
{label}
</div>
);
}
function MiniTimeline({ data }: { data: { hour: number; hits: number }[] }) {
if (!data.length) return <span className="text-text-disabled text-xs">Pas d'activité 24h</span>;
const max = Math.max(...data.map(d => d.hits), 1);
return (
<div className="flex items-end gap-0.5 h-8">
{Array.from({ length: 24 }, (_, h) => {
const d = data.find(x => x.hour === h);
const pct = d ? (d.hits / max) * 100 : 0;
return (
<div key={h} className="flex-1 flex flex-col justify-end" title={d ? `${h}h: ${d.hits} hits` : `${h}h: 0`}>
<div className={`w-full rounded-sm ${pct > 0 ? 'bg-accent-primary' : 'bg-background-card'}`}
style={{ height: `${Math.max(pct, 2)}%` }} />
</div>
);
})}
</div>
);
}
function IPActivitySummary({ ip }: { ip: string }) {
const [open, setOpen] = useState(false); // fermée par défaut
const [loading, setLoading] = useState(true);
const [data, setData] = useState<IPSummary | null>(null);
useEffect(() => {
setLoading(true);
fetch(`/api/investigation/${encodeURIComponent(ip)}/summary`)
.then(r => r.ok ? r.json() : null)
.then(d => setData(d))
.catch(() => null)
.finally(() => setLoading(false));
}, [ip]);
return (
<div className="bg-background-secondary rounded-lg border border-border">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-background-card/50 transition-colors"
>
<span className="font-semibold text-text-primary flex items-center gap-2">
🔎 Synthèse multi-sources
{data && <span className={`text-xs px-2 py-0.5 rounded-full font-bold ${
data.risk_score >= 75 ? 'bg-threat-critical/20 text-threat-critical' :
data.risk_score >= 50 ? 'bg-threat-high/20 text-threat-high' :
data.risk_score >= 25 ? 'bg-threat-medium/20 text-threat-medium' :
'bg-threat-low/20 text-threat-low'
}`}>Score: {data.risk_score}</span>}
</span>
<span className="text-text-secondary">{open ? '' : ''}</span>
</button>
{open && (
<div className="px-5 pb-5">
{loading && <div className="text-text-disabled text-sm py-4">Chargement des données multi-sources…</div>}
{!loading && !data && <div className="text-text-disabled text-sm py-4">Données insuffisantes pour cette IP.</div>}
{data && (
<div className="space-y-4">
{/* Risk + badges row */}
<div className="flex items-start gap-6">
<RiskGauge score={data.risk_score} />
<div className="flex-1 space-y-3">
<div className="flex flex-wrap gap-2">
<ActivityBadge active={data.ml.total_detections > 0} label={`ML: ${data.ml.total_detections} détections`} color="threat-critical" />
<ActivityBadge active={data.bruteforce.active} label={`Brute Force: ${data.bruteforce.hosts_attacked} hosts`} color="threat-high" />
<span title={TIPS.spoof_verdict}><ActivityBadge active={data.tcp_spoofing.detected} label={`TCP Spoof: TTL ${data.tcp_spoofing.tcp_ttl ?? ''}`} color="threat-medium" /></span>
<span title={TIPS.ja4_rotation}><ActivityBadge active={data.ja4_rotation.rotating} label={`JA4 Rotation: ${data.ja4_rotation.distinct_ja4_count} signatures`} color="threat-medium" /></span>
<span title={TIPS.persistence}><ActivityBadge active={data.persistence.persistent} label={`Persistance: ${data.persistence.recurrence}x récurrences`} color="threat-high" /></span>
</div>
{/* Detail grid */}
<div className="grid grid-cols-3 gap-3 text-xs">
{data.ml.total_detections > 0 && (
<div className="bg-background-card rounded p-2">
<div className="text-text-disabled mb-1">ML Detection</div>
<div className="text-text-primary font-medium">{data.ml.threat_level || ''} · {data.ml.attack_type || ''}</div>
<div className="text-text-secondary">Score: {data.ml.max_score} · {data.ml.distinct_ja4} JA4(s)</div>
</div>
)}
{data.bruteforce.active && (
<div className="bg-background-card rounded p-2">
<div className="text-text-disabled mb-1">Brute Force</div>
<div className="text-threat-high font-medium">{data.bruteforce.total_hits.toLocaleString(navigator.language || undefined)} hits</div>
<div className="text-text-secondary truncate" title={data.bruteforce.top_hosts.join(', ')}>
{data.bruteforce.top_hosts[0] ?? ''}
</div>
</div>
)}
{data.tcp_spoofing.detected && (
<div className="bg-background-card rounded p-2">
<div className="text-text-disabled mb-1" title={TIPS.spoof_verdict}>TCP Spoofing</div>
<div className="text-threat-medium font-medium">TTL {data.tcp_spoofing.tcp_ttl} → {data.tcp_spoofing.suspected_os}</div>
<div className="text-text-secondary">UA déclare: {data.tcp_spoofing.declared_os}</div>
</div>
)}
{data.persistence.persistent && (
<div className="bg-background-card rounded p-2">
<div className="text-text-disabled mb-1">Persistance</div>
<div className="text-threat-high font-medium">{data.persistence.recurrence}× sessions</div>
<div className="text-text-secondary">{data.persistence.first_seen?.substring(0, 10)} → {data.persistence.last_seen?.substring(0, 10)}</div>
</div>
)}
</div>
</div>
</div>
{/* Mini timeline */}
<div>
<div className="text-xs text-text-disabled mb-1 font-medium uppercase tracking-wide">Activité dernières 24h</div>
<MiniTimeline data={data.timeline_24h} />
<div className="flex justify-between text-xs text-text-disabled mt-0.5"><span>0h</span><span>12h</span><span>23h</span></div>
</div>
</div>
)}
</div>
)}
</div>
);
}
interface CoherenceData {
verdict: string;
spoofing_score: number;
explanation: string[];
indicators: {
ua_ch_mismatch_rate: number;
sni_mismatch_rate: number;
avg_browser_score: number;
distinct_ja4_count: number;
is_ua_rotating: boolean;
rare_ja4_rate: number;
};
fingerprints: { ja4_list: string[]; latest_ja4: string };
user_agents: { ua: string; count: number; type: string }[];
}
const VERDICT_STYLE: Record<string, { cls: string; icon: string; label: string }> = {
high_confidence_spoofing: { cls: 'bg-threat-critical/10 border-threat-critical/40 text-threat-critical', icon: '🎭', label: 'Spoofing haute confiance' },
suspicious_spoofing: { cls: 'bg-threat-high/10 border-threat-high/40 text-threat-high', icon: '', label: 'Spoofing suspect' },
known_bot_no_spoofing: { cls: 'bg-threat-medium/10 border-threat-medium/40 text-threat-medium', icon: '🤖', label: 'Bot connu (pas de spoofing)' },
legitimate_browser: { cls: 'bg-threat-low/10 border-threat-low/40 text-threat-low', icon: '', label: 'Navigateur légitime' },
inconclusive: { cls: 'bg-background-card border-background-card text-text-secondary', icon: '', label: 'Non concluant' },
};
function FingerprintCoherenceWidget({ ip }: { ip: string }) {
const navigate = useNavigate();
const [data, setData] = useState<CoherenceData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
setLoading(true);
setError(false);
fetch(`/api/fingerprints/ip/${encodeURIComponent(ip)}/coherence`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (d) setData(d); else setError(true); })
.catch(() => setError(true))
.finally(() => setLoading(false));
}, [ip]);
const vs = data ? (VERDICT_STYLE[data.verdict] ?? VERDICT_STYLE.inconclusive) : null;
return (
<div className="bg-background-secondary rounded-lg p-5">
<h3 className="text-base font-semibold text-text-primary mb-4">🎭 Cohérence JA4 / User-Agent</h3>
{loading && <div className="text-text-disabled text-sm">Analyse en cours…</div>}
{error && <div className="text-text-disabled text-sm">Données insuffisantes pour cette IP</div>}
{data && vs && (
<div className="space-y-4">
{/* Verdict badge + score */}
<div className={`flex items-center gap-3 p-3 rounded-lg border ${vs.cls}`}>
<span className="text-2xl">{vs.icon}</span>
<div className="flex-1">
<div className="font-semibold text-sm">{vs.label}</div>
<div className="text-xs opacity-75 mt-0.5 flex items-center gap-1">
Score de spoofing: <strong>{data.spoofing_score}/100</strong><InfoTip content={TIPS.spoofing_score} />
</div>
</div>
<div className="w-24">
<div className="h-2 bg-white/20 rounded-full overflow-hidden">
<div
className={`h-2 rounded-full ${
data.spoofing_score >= 70 ? 'bg-threat-critical' :
data.spoofing_score >= 40 ? 'bg-threat-high' :
data.spoofing_score >= 20 ? 'bg-threat-medium' : 'bg-threat-low'
}`}
style={{ width: `${data.spoofing_score}%` }}
/>
</div>
</div>
</div>
{/* Explanation */}
<ul className="space-y-1">
{data.explanation.map((e, i) => (
<li key={i} className="text-xs text-text-secondary flex items-start gap-1.5">
<span className="text-text-disabled mt-0.5">•</span> {e}
</li>
))}
</ul>
{/* Key indicators */}
<div className="grid grid-cols-2 gap-2">
{[
{ label: 'UA/CH mismatch', tip: TIPS.ua_mismatch, value: `${data.indicators.ua_ch_mismatch_rate}%`, warn: data.indicators.ua_ch_mismatch_rate > 20 },
{ label: 'Browser score', tip: TIPS.browser_score, value: `${data.indicators.avg_browser_score}/100`, warn: data.indicators.avg_browser_score > 60 },
{ label: 'JA4 distincts', tip: TIPS.ja4_distinct, value: data.indicators.distinct_ja4_count, warn: data.indicators.distinct_ja4_count > 2 },
{ label: 'JA4 rares %', tip: TIPS.ja4_rare_pct, value: `${data.indicators.rare_ja4_rate}%`, warn: data.indicators.rare_ja4_rate > 50 },
].map((ind) => (
<div key={ind.label} className={`rounded p-2 text-center ${ind.warn ? 'bg-threat-high/10' : 'bg-background-card'}`}>
<div className={`text-sm font-semibold ${ind.warn ? 'text-threat-high' : 'text-text-primary'}`}>{ind.value}</div>
<div className="text-xs text-text-disabled flex items-center justify-center gap-0.5">
{ind.label}
<InfoTip content={ind.tip} />
</div>
</div>
))}
</div>
{/* Top UAs */}
{data.user_agents.length > 0 && (
<div>
<div className="text-xs text-text-disabled mb-1.5 font-medium uppercase tracking-wide">User-Agents observés</div>
<div className="space-y-1">
{data.user_agents.slice(0, 4).map((u, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs ${
u.type === 'bot' ? 'bg-threat-critical/20 text-threat-critical' :
u.type === 'browser' ? 'bg-accent-primary/20 text-accent-primary' :
'bg-background-card text-text-secondary'
}`}>{u.type}</span>
<span className="truncate text-text-secondary font-mono" title={u.ua}>
{u.ua.length > 45 ? u.ua.slice(0, 45) + '' : u.ua}
</span>
</div>
))}
</div>
</div>
)}
{/* JA4 links */}
{data.fingerprints.ja4_list.length > 0 && (
<div>
<div className="text-xs text-text-disabled mb-1 font-medium uppercase tracking-wide">JA4 utilisés</div>
<div className="flex flex-wrap gap-1">
{data.fingerprints.ja4_list.map((j4) => (
<button
key={j4}
onClick={() => navigate(`/investigation/ja4/${encodeURIComponent(j4)}`)}
className="text-xs font-mono text-accent-primary hover:underline truncate max-w-[140px]"
title={j4}
>
{j4.length > 18 ? `${j4.slice(0, 9)}…${j4.slice(-8)}` : j4}
</button>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
// ─── Section "Attributs détectés" (données de variabilité, ex-DetailsView) ───
function Metric({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
return (
<div className="bg-background-card rounded-xl p-3">
<p className="text-[10px] font-semibold text-text-secondary uppercase tracking-wider mb-1">{label}</p>
<p className={`text-xl font-bold ${accent ? 'text-accent-primary' : 'text-text-primary'}`}>{value}</p>
</div>
);
}
function DetectionAttributesSection({ ip }: { ip: string }) {
const [open, setOpen] = useState(true); // ouvert par défaut
const { data, loading } = useVariability('ip', ip);
const first = data?.date_range.first_seen ? new Date(data.date_range.first_seen) : null;
const last = data?.date_range.last_seen ? new Date(data.date_range.last_seen) : null;
const sameDate = first && last && first.getTime() === last.getTime();
const fmt = (d: Date) => formatDateShort(d.toISOString());
return (
<div className="bg-background-secondary rounded-lg border border-border">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-background-card/50 transition-colors"
>
<span className="font-semibold text-text-primary flex items-center gap-2">
📋 Attributs détectés
{data && (
<span className="text-xs px-2 py-0.5 rounded-full bg-accent-primary/20 text-accent-primary font-normal">
{data.total_detections} détections · {data.attributes.user_agents?.length ?? 0} UA · {data.attributes.ja4?.length ?? 0} JA4
</span>
)}
</span>
<span className="text-text-secondary">{open ? '' : ''}</span>
</button>
{open && (
<div className="px-5 pb-5 space-y-4">
{loading && <div className="text-text-disabled text-sm py-4">Chargement…</div>}
{data && (
<>
{/* Métriques */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Metric label="Détections (24h)" value={data.total_detections.toLocaleString()} accent />
<Metric label="User-Agents" value={(data.attributes.user_agents?.length ?? 0).toString()} />
{first && last && (
sameDate ? (
<Metric label="Détecté le" value={fmt(last!)} />
) : (
<div className="bg-background-card rounded-xl p-3 col-span-2">
<p className="text-[10px] font-semibold text-text-secondary uppercase tracking-wider mb-1">Période</p>
<p className="text-xs text-text-primary font-medium">{fmt(first)}</p>
<p className="text-[10px] text-text-secondary">→ {fmt(last!)}</p>
</div>
)
)}
</div>
{/* Insights */}
{data.insights.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{data.insights.map((ins, i) => {
const s: Record<string, string> = {
warning: 'bg-yellow-500/10 border-yellow-500/40 text-yellow-400',
info: 'bg-blue-500/10 border-blue-500/40 text-blue-400',
success: 'bg-green-500/10 border-green-500/40 text-green-400',
};
return (
<div key={i} className={`${s[ins.type] ?? s.info} border rounded-xl p-3 text-sm`}>
{ins.message}
</div>
);
})}
</div>
)}
{/* Attributs (JA4, hosts, ASN, pays, UA…) */}
<VariabilityPanel attributes={data.attributes} hideAssociatedIPs />
</>
)}
</div>
)}
</div>
);
}
export function InvestigationView() {
const { ip } = useParams<{ ip: string }>();
const navigate = useNavigate();
if (!ip) {
return (
<div className="text-center text-text-secondary py-12">
IP non spécifiée
</div>
);
}
const handleClassify = (label: string, tags: string[], comment: string, confidence: number) => {
// Callback optionnel après classification
console.log('IP classifiée:', { ip, label, tags, comment, confidence });
};
return (
<div className="space-y-6 animate-fade-in">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-xs text-text-secondary">
<Link to="/" className="hover:text-text-primary">Dashboard</Link>
<span>/</span>
<Link to="/detections" className="hover:text-text-primary">Détections</Link>
<span>/</span>
<span className="text-text-primary font-mono">{ip}</span>
</nav>
{/* En-tête */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-4 mb-2">
<button
onClick={() => navigate(-1)}
className="text-text-secondary hover:text-text-primary transition-colors"
>
← Retour
</button>
<h1 className="text-2xl font-bold text-text-primary">Investigation: {ip}</h1>
</div>
<div className="text-text-secondary text-sm">
Analyse de corrélations pour classification SOC
</div>
</div>
</div>
{/* Navigation ancres inter-sections */}
<div className="flex items-center gap-2 overflow-x-auto pb-1 text-xs font-medium sticky top-0 z-10 bg-background py-2">
<span className="text-text-disabled shrink-0">Aller à :</span>
{[
{ id: 'section-attributs', label: '📡 Attributs' },
{ id: 'section-synthese', label: '🔎 Synthèse' },
{ id: 'section-reputation', label: '🌍 Réputation' },
{ id: 'section-correlations', label: '🕸 Corrélations' },
{ id: 'section-geo', label: '🌐 Géo / JA4' },
{ id: 'section-classification', label: '🏷 Classification' },
].map(({ id, label }) => (
<a key={id} href={`#${id}`} className="shrink-0 px-3 py-1 rounded-full bg-background-card text-text-secondary hover:text-text-primary hover:bg-background-secondary transition-colors">
{label}
</a>
))}
</div>
{/* Attributs détectés (ex-DetailsView) */}
<div id="section-attributs">
<DetectionAttributesSection ip={ip} />
</div>
{/* Synthèse multi-sources */}
<div id="section-synthese">
<IPActivitySummary ip={ip} />
</div>
{/* Réputation (1/3) + Graph de corrélations (2/3) */}
<div id="section-reputation" className="grid grid-cols-3 gap-6 items-start">
<div className="bg-background-secondary rounded-lg p-6 h-full">
<h3 className="text-lg font-medium text-text-primary mb-4">🌍 Réputation IP</h3>
<ReputationPanel ip={ip} />
</div>
<div id="section-correlations" className="col-span-2 bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">🕸️ Graph de Corrélations</h3>
<CorrelationGraph ip={ip} height="600px" />
</div>
</div>
{/* Subnet / Country / JA4 */}
<div id="section-geo" className="grid grid-cols-3 gap-6 items-start">
<SubnetAnalysis ip={ip} />
<CountryAnalysis ip={ip} />
<JA4Analysis ip={ip} />
</div>
{/* User-Agents (1/2) + Classification (1/2) */}
<div id="section-classification" className="grid grid-cols-2 gap-6 items-start">
<UserAgentAnalysis ip={ip} />
<CorrelationSummary ip={ip} onClassify={handleClassify} />
</div>
{/* Cohérence JA4/UA (spoofing) */}
<div className="grid grid-cols-3 gap-6 items-start">
<FingerprintCoherenceWidget ip={ip} />
<div className="col-span-2 bg-background-secondary rounded-lg p-5">
<h3 className="text-base font-semibold text-text-primary mb-3 flex items-center gap-1">
🔏 JA4 Légitimes (baseline)
<InfoTip content={TIPS.baseline_ja4} />
</h3>
<p className="text-xs text-text-secondary mb-3">
Comparez les fingerprints de cette IP avec la baseline des JA4 légitimes pour évaluer le risque de spoofing.
</p>
<button
onClick={() => navigate('/fingerprints?tab=spoofing')}
className="text-sm px-4 py-2 rounded-lg bg-accent-primary/20 text-accent-primary hover:bg-accent-primary/30 transition-colors"
>
🎭 Voir l'analyse de spoofing globale
</button>
</div>
</div>
</div>
);
}

View File

@ -1,336 +0,0 @@
import { useParams, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { JA4CorrelationSummary } from './analysis/JA4CorrelationSummary';
import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips';
import { formatDateShort } from '../utils/dateUtils';
interface JA4InvestigationData {
ja4: string;
total_detections: number;
unique_ips: number;
first_seen: string;
last_seen: string;
top_ips: { ip: string; count: number; percentage: number }[];
top_countries: { code: string; name: string; count: number; percentage: number }[];
top_asns: { asn: string; org: string; count: number; percentage: number }[];
top_hosts: { host: string; count: number; percentage: number }[];
user_agents: { ua: string; count: number; percentage: number; classification: string }[];
}
export function JA4InvestigationView() {
const { ja4 } = useParams<{ ja4: string }>();
const navigate = useNavigate();
const [data, setData] = useState<JA4InvestigationData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchJA4Investigation = async () => {
setLoading(true);
try {
// Récupérer les données de base
const baseResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4 || '')}`);
if (!baseResponse.ok) throw new Error('Erreur chargement données JA4');
const baseData = await baseResponse.json();
// Récupérer les IPs associées
const ipsResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4 || '')}/ips?limit=20`);
const ipsData = await ipsResponse.json();
// Récupérer les attributs associés
const countriesResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4 || '')}/attributes?target_attr=countries&limit=10`);
const countriesData = await countriesResponse.json();
const asnsResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4 || '')}/attributes?target_attr=asns&limit=10`);
const asnsData = await asnsResponse.json();
const hostsResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4 || '')}/attributes?target_attr=hosts&limit=10`);
const hostsData = await hostsResponse.json();
// Récupérer les user-agents
const uaResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4 || '')}/user_agents?limit=10`);
const uaData = await uaResponse.json();
// Formater les données
setData({
ja4: ja4 || '',
total_detections: baseData.total_detections || 0,
unique_ips: ipsData.total || 0,
first_seen: baseData.date_range?.first_seen || '',
last_seen: baseData.date_range?.last_seen || '',
top_ips: ipsData.ips?.slice(0, 10).map((item: any) => ({
ip: typeof item === 'string' ? item : item.ip,
count: typeof item === 'string' ? 0 : (item.count || 0),
percentage: typeof item === 'string' ? 0 : (item.percentage || 0)
})) || [],
top_countries: countriesData.items?.map((item: any) => ({
code: item.value,
name: item.value,
count: item.count,
percentage: item.percentage
})) || [],
top_asns: asnsData.items?.map((item: any) => {
const match = item.value.match(/AS(\d+)/);
return {
asn: match ? `AS${match[1]}` : item.value,
org: item.value.replace(/AS\d+\s*-\s*/, ''),
count: item.count,
percentage: item.percentage
};
}) || [],
top_hosts: hostsData.items?.map((item: any) => ({
host: item.value,
count: item.count,
percentage: item.percentage
})) || [],
user_agents: uaData.user_agents?.map((ua: any) => ({
ua: ua.value,
count: ua.count,
percentage: ua.percentage,
classification: ua.classification || 'normal'
})) || []
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
if (ja4) {
fetchJA4Investigation();
}
}, [ja4]);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-text-secondary">Chargement...</div>
</div>
);
}
if (error || !data) {
return (
<div className="bg-threat-critical_bg border border-threat-critical rounded-lg p-6">
<div className="text-threat-critical mb-4">Erreur: {error || 'Données non disponibles'}</div>
<button
onClick={() => navigate('/detections')}
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg transition-colors"
>
Retour aux détections
</button>
</div>
);
}
const getFlag = (code: string) => {
return code.toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
};
const getClassificationBadge = (classification: string) => {
switch (classification) {
case 'normal':
return <span className="bg-threat-low/20 text-threat-low px-2 py-0.5 rounded text-xs"> Normal</span>;
case 'bot':
return <span className="bg-threat-medium/20 text-threat-medium px-2 py-0.5 rounded text-xs"> Bot</span>;
case 'script':
return <span className="bg-threat-high/20 text-threat-high px-2 py-0.5 rounded text-xs"> Script</span>;
default:
return null;
}
};
return (
<div className="space-y-6 animate-fade-in">
{/* En-tête */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-4 mb-2">
<button
onClick={() => navigate('/detections')}
className="text-text-secondary hover:text-text-primary transition-colors"
>
Retour
</button>
<h1 className="text-2xl font-bold text-text-primary">Investigation JA4</h1>
</div>
<div className="text-text-secondary text-sm">
Analyse de fingerprint JA4 pour classification SOC
</div>
</div>
</div>
{/* Stats principales */}
<div className="bg-background-secondary rounded-lg p-6">
<div className="flex items-start justify-between mb-6">
<div className="flex-1">
<div className="text-sm text-text-secondary mb-2 flex items-center gap-1">JA4 Fingerprint<InfoTip content={TIPS.ja4} /></div>
<div className="bg-background-card rounded-lg p-3 font-mono text-sm text-text-primary break-all">
{data.ja4}
</div>
</div>
<div className="text-right ml-6">
<div className="text-3xl font-bold text-text-primary">{data.total_detections.toLocaleString()}</div>
<div className="text-text-secondary text-sm">détections (24h)</div>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatBox label="IPs Uniques" value={data.unique_ips.toLocaleString()} tip={TIPS.unique_ips_stat} />
<StatBox label="Première détection" value={formatDate(data.first_seen)} />
<StatBox label="Dernière détection" value={formatDate(data.last_seen)} />
<StatBox label="User-Agents" value={data.user_agents.length.toString()} />
</div>
</div>
{/* Ligne 2: Top IPs (gauche) | Top Pays + Top ASNs (droite empilés) */}
<div className="grid grid-cols-2 gap-6 items-start">
{/* Top IPs */}
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">📍 TOP IPs</h3>
<div className="space-y-2">
{data.top_ips.length > 0 ? (
data.top_ips.map((ipData, idx) => (
<div
key={idx}
className="flex items-center justify-between bg-background-card rounded-lg p-3"
>
<div className="flex items-center gap-3">
<span className="text-text-secondary text-sm w-6">{idx + 1}.</span>
<button
onClick={() => navigate(`/investigation/${ipData.ip}`)}
className="font-mono text-sm text-accent-primary hover:text-accent-primary/80 transition-colors text-left"
>
{ipData.ip}
</button>
</div>
<div className="text-right">
<div className="text-text-primary font-medium">{ipData.count.toLocaleString()}</div>
<div className="text-text-secondary text-xs">{ipData.percentage.toFixed(1)}%</div>
</div>
</div>
))
) : (
<div className="text-center text-text-secondary py-8">Aucune IP trouvée</div>
)}
{data.unique_ips > 10 && (
<p className="text-text-secondary text-sm mt-4 text-center">
... et {data.unique_ips - 10} autres IPs
</p>
)}
</div>
</div>
{/* Top Pays + Top ASNs empilés */}
<div className="space-y-6">
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">🌍 TOP Pays</h3>
<div className="space-y-3">
{data.top_countries.map((country, idx) => (
<div key={idx} className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-2xl">{getFlag(country.code)}</span>
<span className="text-text-primary font-medium text-sm">{country.name} ({country.code})</span>
</div>
<div className="text-right">
<div className="text-text-primary font-bold">{country.count.toLocaleString()}</div>
<div className="text-text-secondary text-xs">{country.percentage.toFixed(1)}%</div>
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div className="h-2 rounded-full bg-accent-primary transition-all" style={{ width: `${Math.min(country.percentage, 100)}%` }} />
</div>
</div>
))}
</div>
</div>
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4"><span className="flex items-center gap-1">🏢 TOP ASNs<InfoTip content={TIPS.asn} /></span></h3>
<div className="space-y-3">
{data.top_asns.map((asn, idx) => (
<div key={idx} className="space-y-1">
<div className="flex items-center justify-between">
<div className="text-text-primary font-medium text-sm">{asn.asn} - {asn.org}</div>
<div className="text-right">
<div className="text-text-primary font-bold">{asn.count.toLocaleString()}</div>
<div className="text-text-secondary text-xs">{asn.percentage.toFixed(1)}%</div>
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div className="h-2 rounded-full bg-accent-primary transition-all" style={{ width: `${Math.min(asn.percentage, 100)}%` }} />
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* Ligne 3: Top Hosts (gauche) | User-Agents (droite) */}
<div className="grid grid-cols-2 gap-6 items-start">
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">🖥 TOP Hosts Ciblés</h3>
<div className="space-y-3">
{data.top_hosts.map((host, idx) => (
<div key={idx} className="space-y-1">
<div className="flex items-center justify-between">
<div className="text-text-primary font-medium text-sm truncate max-w-xs">{host.host}</div>
<div className="text-right">
<div className="text-text-primary font-bold">{host.count.toLocaleString()}</div>
<div className="text-text-secondary text-xs">{host.percentage.toFixed(1)}%</div>
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div className="h-2 rounded-full bg-accent-primary transition-all" style={{ width: `${Math.min(host.percentage, 100)}%` }} />
</div>
</div>
))}
</div>
</div>
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">🤖 User-Agents</h3>
<div className="space-y-3">
{data.user_agents.map((ua, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="text-text-primary text-xs font-mono break-all flex-1 leading-relaxed">{ua.ua}</div>
{getClassificationBadge(ua.classification)}
</div>
<div className="flex items-center gap-2">
<div className="text-text-secondary text-xs">{ua.count} IPs</div>
<div className="text-text-secondary text-xs">{ua.percentage.toFixed(1)}%</div>
</div>
</div>
))}
{data.user_agents.length === 0 && (
<div className="text-center text-text-secondary py-8">Aucun User-Agent trouvé</div>
)}
</div>
</div>
</div>
{/* Ligne 4: Classification JA4 (full width) */}
<JA4CorrelationSummary ja4={ja4 || ''} />
</div>
);
}
function StatBox({ label, value, tip }: { label: string; value: string; tip?: string }) {
return (
<div className="bg-background-card rounded-lg p-4">
<div className="text-sm text-text-secondary mb-1 flex items-center gap-1">{label}{tip && <InfoTip content={tip} />}</div>
<div className="text-2xl font-bold text-text-primary">{value}</div>
</div>
);
}
function formatDate(dateStr: string): string {
if (!dateStr) return '-';
return formatDateShort(dateStr);
}

View File

@ -1,559 +0,0 @@
import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import DataTable, { Column } from './ui/DataTable';
import { TIPS } from './ui/tooltips';
import { formatNumber } from '../utils/dateUtils';
import { LoadingSpinner, ErrorMessage } from './ui/Feedback';
// ─── Types ────────────────────────────────────────────────────────────────────
interface MLAnomaly {
ip: string;
ja4: string;
host: string;
hits: number;
fuzzing_index: number;
hit_velocity: number;
temporal_entropy: number;
is_fake_navigation: boolean;
ua_ch_mismatch: boolean;
sni_host_mismatch: boolean;
is_ua_rotating: boolean;
path_diversity_ratio: number;
anomalous_payload_ratio: number;
asn_label: string;
bot_name: string;
attack_type: string;
}
interface RadarData {
ip: string;
fuzzing_score: number;
velocity_score: number;
fake_nav_score: number;
ua_mismatch_score: number;
sni_mismatch_score: number;
orphan_score: number;
path_repetition_score: number;
payload_anomaly_score: number;
}
interface ScatterPoint {
ip: string;
ja4: string;
fuzzing_index: number;
hit_velocity: number;
hits: number;
attack_type: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function attackTypeEmoji(type: string): string {
switch (type) {
case 'brute_force': return '🔑';
case 'flood': return '🌊';
case 'scraper': return '🕷️';
case 'spoofing': return '🎭';
case 'scanner': return '🔍';
default: return '❓';
}
}
function attackTypeColor(type: string): string {
switch (type) {
case 'brute_force': return '#ef4444';
case 'flood': return '#3b82f6';
case 'scraper': return '#a855f7';
case 'spoofing': return '#f97316';
case 'scanner': return '#eab308';
default: return '#6b7280';
}
}
function fuzzingBadgeClass(value: number): string {
if (value >= 200) return 'bg-threat-critical/20 text-threat-critical';
if (value >= 100) return 'bg-threat-high/20 text-threat-high';
if (value >= 50) return 'bg-threat-medium/20 text-threat-medium';
return 'bg-background-card text-text-secondary';
}
// ─── Sub-components ───────────────────────────────────────────────────────────
// ─── Radar Chart (SVG octagonal) ─────────────────────────────────────────────
const RADAR_AXES = [
{ key: 'fuzzing_score', label: 'Fuzzing', tip: TIPS.fuzzing },
{ key: 'velocity_score', label: 'Vélocité', tip: TIPS.velocity },
{ key: 'fake_nav_score', label: 'Fausse nav', tip: TIPS.fake_nav },
{ key: 'ua_mismatch_score', label: 'UA/CH mismatch', tip: TIPS.ua_mismatch },
{ key: 'sni_mismatch_score', label: 'SNI mismatch', tip: TIPS.sni_mismatch },
{ key: 'orphan_score', label: 'Orphan ratio', tip: TIPS.orphan_ratio },
{ key: 'path_repetition_score', label: 'Répétition URL', tip: TIPS.path_repetition },
{ key: 'payload_anomaly_score', label: 'Payload anormal', tip: TIPS.payload_anomaly },
] as const;
type RadarKey = typeof RADAR_AXES[number]['key'];
function RadarChart({ data }: { data: RadarData }) {
const size = 280;
const cx = size / 2;
const cy = size / 2;
const maxR = 100;
const n = RADAR_AXES.length;
const angleOf = (i: number) => (i * 2 * Math.PI) / n - Math.PI / 2;
const pointFor = (i: number, r: number): [number, number] => {
const a = angleOf(i);
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
};
// Background rings (at 25%, 50%, 75%, 100%)
const rings = [25, 50, 75, 100];
const dataPoints = RADAR_AXES.map((axis, i) => {
const val = Math.min((data[axis.key as RadarKey] ?? 0), 100);
return pointFor(i, (val / 100) * maxR);
});
const polygonPoints = dataPoints.map(([x, y]) => `${x},${y}`).join(' ');
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="overflow-visible">
{/* Background rings */}
{rings.map((r) => {
const pts = RADAR_AXES.map((_, i) => {
const [x, y] = pointFor(i, (r / 100) * maxR);
return `${x},${y}`;
}).join(' ');
return (
<polygon
key={r}
points={pts}
fill="none"
stroke="rgba(100,116,139,0.2)"
strokeWidth="1"
/>
);
})}
{/* Axis lines */}
{RADAR_AXES.map((_, i) => {
const [x, y] = pointFor(i, maxR);
return (
<line
key={i}
x1={cx} y1={cy}
x2={x} y2={y}
stroke="rgba(100,116,139,0.35)"
strokeWidth="1"
/>
);
})}
{/* Data polygon */}
<polygon
points={polygonPoints}
fill="rgba(239,68,68,0.2)"
stroke="rgba(239,68,68,0.85)"
strokeWidth="2"
/>
{/* Data dots */}
{dataPoints.map(([x, y], i) => (
<circle key={i} cx={x} cy={y} r="3" fill="rgba(239,68,68,0.9)" />
))}
{/* Axis labels — survolez pour la définition */}
{RADAR_AXES.map((axis, i) => {
const [x, y] = pointFor(i, maxR + 18);
const anchor = x < cx - 5 ? 'end' : x > cx + 5 ? 'start' : 'middle';
return (
<text
key={axis.key}
x={x}
y={y}
textAnchor={anchor}
fontSize="10"
fill="rgba(148,163,184,0.9)"
dominantBaseline="middle"
style={{ cursor: 'help' }}
>
<title>{axis.tip}</title>
{axis.label}
</text>
);
})}
{/* Percentage labels on vertical axis */}
{rings.map((r) => {
const [, y] = pointFor(0, (r / 100) * maxR);
return (
<text key={r} x={cx + 3} y={y} fontSize="8" fill="rgba(100,116,139,0.6)" dominantBaseline="middle">
{r}
</text>
);
})}
</svg>
);
}
// ─── Scatter plot ─────────────────────────────────────────────────────────────
function ScatterPlot({ points }: { points: ScatterPoint[] }) {
const [tooltip, setTooltip] = useState<{ ip: string; type: string; x: number; y: number } | null>(null);
const W = 600;
const H = 200;
const padL = 40;
const padB = 30;
const padT = 10;
const padR = 20;
const maxX = 350;
const maxY = 1;
const toSvgX = (v: number) => padL + ((v / maxX) * (W - padL - padR));
const toSvgY = (v: number) => padT + ((1 - v / maxY) * (H - padT - padB));
// X axis ticks
const xTicks = [0, 50, 100, 150, 200, 250, 300, 350];
const yTicks = [0, 0.25, 0.5, 0.75, 1.0];
return (
<div className="relative">
<svg
width="100%"
viewBox={`0 0 ${W} ${H}`}
className="overflow-visible"
onMouseLeave={() => setTooltip(null)}
>
{/* Grid lines */}
{xTicks.map((v) => (
<line key={v} x1={toSvgX(v)} y1={padT} x2={toSvgX(v)} y2={H - padB} stroke="rgba(100,116,139,0.15)" strokeWidth="1" />
))}
{yTicks.map((v) => (
<line key={v} x1={padL} y1={toSvgY(v)} x2={W - padR} y2={toSvgY(v)} stroke="rgba(100,116,139,0.15)" strokeWidth="1" />
))}
{/* X axis */}
<line x1={padL} y1={H - padB} x2={W - padR} y2={H - padB} stroke="rgba(100,116,139,0.4)" strokeWidth="1" />
{xTicks.map((v) => (
<text key={v} x={toSvgX(v)} y={H - padB + 12} textAnchor="middle" fontSize="9" fill="rgba(148,163,184,0.7)">{v}</text>
))}
<text x={(W - padL - padR) / 2 + padL} y={H - 2} textAnchor="middle" fontSize="10" fill="rgba(148,163,184,0.8)" style={{ cursor: 'help' }}>
<title>{TIPS.fuzzing_index}</title>
Fuzzing Index
</text>
{/* Y axis */}
<line x1={padL} y1={padT} x2={padL} y2={H - padB} stroke="rgba(100,116,139,0.4)" strokeWidth="1" />
{yTicks.map((v) => (
<text key={v} x={padL - 4} y={toSvgY(v)} textAnchor="end" fontSize="9" fill="rgba(148,163,184,0.7)" dominantBaseline="middle">{v.toFixed(2)}</text>
))}
{/* Data points */}
{points.map((pt, i) => {
const x = toSvgX(Math.min(pt.fuzzing_index, maxX));
const y = toSvgY(Math.min(pt.hit_velocity, maxY));
const color = attackTypeColor(pt.attack_type);
return (
<circle
key={i}
cx={x}
cy={y}
r={3}
fill={color}
fillOpacity={0.75}
stroke={color}
strokeWidth="0.5"
style={{ cursor: 'pointer' }}
onMouseEnter={() => setTooltip({ ip: pt.ip, type: pt.attack_type, x, y })}
/>
);
})}
{/* Tooltip */}
{tooltip && (
<g>
<rect
x={Math.min(tooltip.x + 6, W - 120)}
y={Math.max(tooltip.y - 28, padT)}
width={110}
height={28}
rx={3}
fill="rgba(15,23,42,0.95)"
stroke="rgba(100,116,139,0.4)"
strokeWidth="1"
/>
<text
x={Math.min(tooltip.x + 11, W - 115)}
y={Math.max(tooltip.y - 18, padT + 8)}
fontSize="9"
fill="white"
>
{tooltip.ip}
</text>
<text
x={Math.min(tooltip.x + 11, W - 115)}
y={Math.max(tooltip.y - 7, padT + 19)}
fontSize="9"
fill="rgba(148,163,184,0.9)"
>
{attackTypeEmoji(tooltip.type)} {tooltip.type}
</text>
</g>
)}
</svg>
</div>
);
}
// ─── Anomalies DataTable ─────────────────────────────────────────────────────
function AnomaliesTable({
anomalies,
selectedIP,
onRowClick,
}: {
anomalies: MLAnomaly[];
selectedIP: string | null;
onRowClick: (row: MLAnomaly) => void;
}) {
const columns = useMemo((): Column<MLAnomaly>[] => [
{
key: 'ip',
label: 'IP',
render: (v: string, row: MLAnomaly) => (
<span className={`font-mono text-xs ${selectedIP === row.ip ? 'text-accent-primary' : 'text-text-primary'}`}>
{v}
</span>
),
},
{
key: 'host',
label: 'Host',
render: (v: string) => (
<span className="text-text-secondary max-w-[120px] truncate block" title={v}>
{v || '—'}
</span>
),
},
{
key: 'hits',
label: 'Hits',
align: 'right',
render: (v: number) => formatNumber(v),
},
{
key: 'fuzzing_index',
label: 'Fuzzing',
tooltip: TIPS.fuzzing_index,
align: 'right',
render: (v: number) => (
<span className={`px-1.5 py-0.5 rounded text-xs font-semibold ${fuzzingBadgeClass(v)}`}>
{Math.round(v)}
</span>
),
},
{
key: 'attack_type',
label: 'Type',
tooltip: 'Type d\'attaque détecté : Brute Force 🔑, Flood 🌊, Scraper 🕷️, Spoofing 🎭, Scanner 🔍',
render: (v: string) => (
<span title={v} className="text-sm">{attackTypeEmoji(v)}</span>
),
},
{
key: '_signals',
label: 'Signaux',
tooltip: '⚠️ UA/CH mismatch · 🎭 Fausse navigation · 🔄 UA rotatif · 🌐 SNI mismatch',
sortable: false,
render: (_: unknown, row: MLAnomaly) => (
<span className="flex gap-0.5">
{row.ua_ch_mismatch && <span title="UA/CH mismatch"></span>}
{row.is_fake_navigation && <span title="Fausse navigation">🎭</span>}
{row.is_ua_rotating && <span title="UA rotatif">🔄</span>}
{row.sni_host_mismatch && <span title="SNI mismatch">🌐</span>}
</span>
),
},
], [selectedIP]);
return (
<div className="overflow-auto max-h-[500px]">
<DataTable
data={anomalies}
columns={columns}
rowKey="ip"
defaultSortKey="fuzzing_index"
onRowClick={onRowClick}
emptyMessage="Aucune anomalie détectée"
compact
/>
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function MLFeaturesView() {
const navigate = useNavigate();
const [anomalies, setAnomalies] = useState<MLAnomaly[]>([]);
const [anomaliesLoading, setAnomaliesLoading] = useState(true);
const [anomaliesError, setAnomaliesError] = useState<string | null>(null);
const [scatter, setScatter] = useState<ScatterPoint[]>([]);
const [scatterLoading, setScatterLoading] = useState(true);
const [scatterError, setScatterError] = useState<string | null>(null);
const [selectedIP, setSelectedIP] = useState<string | null>(null);
const [radarData, setRadarData] = useState<RadarData | null>(null);
const [radarLoading, setRadarLoading] = useState(false);
const [radarError, setRadarError] = useState<string | null>(null);
useEffect(() => {
const fetchAnomalies = async () => {
try {
const res = await fetch('/api/ml/top-anomalies?limit=50');
if (!res.ok) throw new Error('Erreur chargement des anomalies');
const data: { items: MLAnomaly[] } = await res.json();
setAnomalies(data.items ?? []);
} catch (err) {
setAnomaliesError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setAnomaliesLoading(false);
}
};
const fetchScatter = async () => {
try {
const res = await fetch('/api/ml/scatter?limit=200');
if (!res.ok) throw new Error('Erreur chargement du scatter');
const data: { points: ScatterPoint[] } = await res.json();
setScatter(data.points ?? []);
} catch (err) {
setScatterError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setScatterLoading(false);
}
};
fetchAnomalies();
fetchScatter();
}, []);
const loadRadar = async (ip: string) => {
if (selectedIP === ip) {
setSelectedIP(null);
setRadarData(null);
return;
}
setSelectedIP(ip);
setRadarLoading(true);
setRadarError(null);
try {
const res = await fetch(`/api/ml/ip/${encodeURIComponent(ip)}/radar`);
if (!res.ok) throw new Error('Erreur chargement du radar');
const data: RadarData = await res.json();
setRadarData(data);
} catch (err) {
setRadarError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setRadarLoading(false);
}
};
return (
<div className="p-6 space-y-6 animate-fade-in">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-text-primary">🤖 Analyse Features ML</h1>
<p className="text-text-secondary mt-1">
Visualisation des features ML pour la détection d'anomalies comportementales.
</p>
</div>
{/* Main two-column layout */}
<div className="flex gap-6 flex-col lg:flex-row">
{/* Left: anomalies table */}
<div className="flex-1 min-w-0">
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
<div className="px-4 py-3 border-b border-border">
<h2 className="text-text-primary font-semibold text-sm">Top anomalies</h2>
</div>
{anomaliesLoading ? (
<LoadingSpinner />
) : anomaliesError ? (
<div className="p-4"><ErrorMessage message={anomaliesError} /></div>
) : (
<AnomaliesTable
anomalies={anomalies}
selectedIP={selectedIP}
onRowClick={(row) => loadRadar(row.ip)}
/>
)}
</div>
</div>
{/* Right: Radar chart */}
<div className="lg:w-80 xl:w-96">
<div className="bg-background-secondary rounded-lg border border-border p-4 h-full flex flex-col">
<h2 className="text-text-primary font-semibold text-sm mb-3">
Radar ML {selectedIP ? <span className="text-accent-primary font-mono text-xs">— {selectedIP}</span> : ''}
</h2>
{!selectedIP ? (
<div className="flex-1 flex items-center justify-center text-text-disabled text-sm text-center">
Cliquez sur une IP<br />pour afficher le radar
</div>
) : radarLoading ? (
<div className="flex-1 flex items-center justify-center">
<LoadingSpinner />
</div>
) : radarError ? (
<ErrorMessage message={radarError} />
) : radarData ? (
<>
<div className="flex justify-center">
<RadarChart data={radarData} />
</div>
<button
onClick={() => navigate(`/investigation/${selectedIP}`)}
className="mt-3 w-full text-xs bg-accent-primary/10 text-accent-primary px-3 py-2 rounded hover:bg-accent-primary/20 transition-colors"
>
🔍 Investiguer {selectedIP}
</button>
</>
) : null}
</div>
</div>
</div>
{/* Scatter plot */}
<div className="bg-background-secondary rounded-lg border border-border p-6">
<h2 className="text-text-primary font-semibold mb-4">Nuage de points — Fuzzing Index × Vélocité</h2>
{scatterLoading ? (
<LoadingSpinner />
) : scatterError ? (
<ErrorMessage message={scatterError} />
) : (
<>
<ScatterPlot points={scatter} />
{/* Legend */}
<div className="flex flex-wrap gap-4 mt-3 text-xs text-text-secondary">
{['brute_force', 'flood', 'scraper', 'spoofing', 'scanner'].map((type) => (
<span key={type} className="flex items-center gap-1.5">
<span
style={{ backgroundColor: attackTypeColor(type), display: 'inline-block', width: '10px', height: '10px', borderRadius: '50%' }}
/>
{attackTypeEmoji(type)} {type}
</span>
))}
</div>
</>
)}
</div>
</div>
);
}

View File

@ -1,365 +0,0 @@
/**
* PivotView — Cross-entity correlation matrix
*
* SOC analysts add multiple IPs or JA4 fingerprints.
* The page fetches variability data for each entity and renders a
* comparison matrix highlighting shared values (correlations).
*
* Columns = entities added by the analyst
* Rows = attribute categories (JA4, User-Agent, Country, ASN, Subnet)
* ★ = value shared by 2+ entities → possible campaign link
*/
import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips';
import { getCountryFlag } from '../utils/countryUtils';
// ─── Types ────────────────────────────────────────────────────────────────────
type EntityType = 'ip' | 'ja4';
interface EntityCol {
id: string;
type: EntityType;
value: string;
loading: boolean;
error: string | null;
data: VariabilityData | null;
}
interface AttrItem {
value: string;
count: number;
percentage: number;
}
interface VariabilityData {
total_detections: number;
unique_ips?: number;
attributes: {
ja4?: AttrItem[];
user_agents?: AttrItem[];
countries?: AttrItem[];
asns?: AttrItem[];
hosts?: AttrItem[];
subnets?: AttrItem[];
};
}
type AttrKey = keyof VariabilityData['attributes'];
const ATTR_ROWS: { key: AttrKey; label: string; icon: string; tip?: string }[] = [
{ key: 'ja4', label: 'JA4 Fingerprint', icon: '🔐', tip: TIPS.ja4 },
{ key: 'user_agents', label: 'User-Agents', icon: '🤖' },
{ key: 'countries', label: 'Pays', icon: '🌍' },
{ key: 'asns', label: 'ASN', icon: '🏢', tip: TIPS.asn },
{ key: 'hosts', label: 'Hosts cibles', icon: '🖥️' },
{ key: 'subnets', label: 'Subnets', icon: '🔷' },
];
const MAX_VALUES_PER_CELL = 4;
function detectType(input: string): EntityType {
// JA4 fingerprints are ~36 chars containing letters, digits, underscores
// IP addresses match IPv4 pattern
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(input.trim())) return 'ip';
return 'ja4';
}
// ─── Component ────────────────────────────────────────────────────────────────
export function PivotView() {
const navigate = useNavigate();
const [cols, setCols] = useState<EntityCol[]>([]);
const [input, setInput] = useState('');
const fetchEntity = useCallback(async (col: EntityCol): Promise<EntityCol> => {
try {
const encoded = encodeURIComponent(col.value);
const url = col.type === 'ip'
? `/api/variability/ip/${encoded}`
: `/api/variability/ja4/${encoded}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: VariabilityData = await res.json();
return { ...col, loading: false, data };
} catch (e) {
return { ...col, loading: false, error: (e as Error).message };
}
}, []);
const addEntity = useCallback(async () => {
const val = input.trim();
if (!val) return;
// Support batch: comma-separated
const values = val.split(/[,\s]+/).map(v => v.trim()).filter(Boolean);
setInput('');
for (const v of values) {
const id = `${Date.now()}-${v}`;
const type = detectType(v);
const pending: EntityCol = { id, type, value: v, loading: true, error: null, data: null };
setCols(prev => {
if (prev.some(c => c.value === v)) return prev;
return [...prev, pending];
});
const resolved = await fetchEntity(pending);
setCols(prev => prev.map(c => c.id === id ? resolved : c));
}
}, [input, fetchEntity]);
const removeCol = (id: string) => setCols(prev => prev.filter(c => c.id !== id));
// Find shared values across all loaded cols for a given attribute key
const getSharedValues = (key: AttrKey): Set<string> => {
const loaded = cols.filter(c => c.data);
if (loaded.length < 2) return new Set();
const valueCounts = new Map<string, number>();
for (const col of loaded) {
const items = col.data?.attributes[key] ?? [];
for (const item of items.slice(0, 10)) {
valueCounts.set(item.value, (valueCounts.get(item.value) ?? 0) + 1);
}
}
const shared = new Set<string>();
valueCounts.forEach((count, val) => { if (count >= 2) shared.add(val); });
return shared;
};
const sharedByKey = Object.fromEntries(
ATTR_ROWS.map(r => [r.key, getSharedValues(r.key)])
) as Record<AttrKey, Set<string>>;
const totalCorrelations = ATTR_ROWS.reduce(
(sum, r) => sum + sharedByKey[r.key].size, 0
);
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-text-primary">🔗 Pivot Corrélation Multi-Entités</h1>
<p className="text-text-secondary text-sm mt-1">
Ajoutez des IPs ou JA4. Les valeurs partagées <span className="text-yellow-400 font-bold"></span> révèlent des campagnes coordonnées.
</p>
</div>
{/* Input bar */}
<div className="flex gap-3">
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') addEntity(); }}
placeholder="IP (ex: 1.2.3.4) ou JA4, séparés par des virgules…"
className="flex-1 bg-background-secondary border border-background-card rounded-lg px-4 py-2.5 text-text-primary placeholder-text-disabled font-mono text-sm focus:outline-none focus:border-accent-primary"
/>
<button
onClick={addEntity}
disabled={!input.trim()}
className="px-5 py-2.5 bg-accent-primary text-white rounded-lg text-sm font-medium hover:bg-accent-primary/80 disabled:opacity-40 transition-colors"
>
+ Ajouter
</button>
{cols.length > 0 && (
<button
onClick={() => setCols([])}
className="px-4 py-2.5 bg-background-card text-text-secondary rounded-lg text-sm hover:text-text-primary transition-colors"
>
Tout effacer
</button>
)}
</div>
{/* Empty state */}
{cols.length === 0 && (
<div className="bg-background-secondary rounded-xl p-12 text-center space-y-3">
<div className="text-5xl">🔗</div>
<div className="text-lg font-medium text-text-primary">Aucune entité ajoutée</div>
<div className="text-sm text-text-secondary max-w-md mx-auto">
Entrez plusieurs IPs ou fingerprints JA4 pour identifier des corrélations JA4 partagés, même pays d'origine, mêmes cibles, même ASN.
</div>
<div className="text-xs text-text-disabled pt-2">
Exemple : <span className="font-mono text-text-secondary">1.2.3.4, 5.6.7.8, 9.10.11.12</span>
</div>
</div>
)}
{/* Correlation summary badge */}
{cols.length >= 2 && cols.some(c => c.data) && (
<div className={`flex items-center gap-3 px-4 py-3 rounded-lg border ${
totalCorrelations > 0
? 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400'
: 'bg-background-secondary border-background-card text-text-secondary'
}`}>
<span className="text-xl">{totalCorrelations > 0 ? '' : ''}</span>
<div>
{totalCorrelations > 0 ? (
<>
<span className="font-bold">{totalCorrelations} corrélation{totalCorrelations > 1 ? 's' : ''} détectée{totalCorrelations > 1 ? 's' : ''}</span>
{' '}— attributs partagés par 2+ entités. Possible campagne coordonnée.
</>
) : (
'Aucune corrélation détectée entre les entités analysées.'
)}
</div>
</div>
)}
{/* Matrix */}
{cols.length > 0 && (
<div className="overflow-x-auto rounded-xl border border-background-card">
<table className="w-full min-w-max">
{/* Column headers */}
<thead>
<tr className="bg-background-secondary border-b border-background-card">
<th className="px-4 py-3 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider w-40">
Attribut
</th>
{cols.map(col => (
<th key={col.id} className="px-4 py-3 text-left w-56">
<div className="flex items-start justify-between gap-2">
<div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-text-disabled">
{col.type === 'ip' ? '🌐' : '🔐'}
</span>
<button
onClick={() => navigate(
col.type === 'ip'
? `/investigation/${col.value}`
: `/investigation/ja4/${col.value}`
)}
className="font-mono text-sm text-accent-primary hover:underline truncate max-w-[160px] text-left"
title={col.value}
>
{col.value.length > 20 ? col.value.slice(0, 20) + '' : col.value}
</button>
</div>
{col.data && (
<div className="text-xs text-text-disabled mt-0.5">
{col.data.total_detections.toLocaleString()} det.
{col.type === 'ja4' && col.data.unique_ips !== undefined && (
<> · {col.data.unique_ips} IPs</>
)}
</div>
)}
{col.loading && (
<div className="text-xs text-text-disabled mt-0.5 animate-pulse">Chargement…</div>
)}
{col.error && (
<div className="text-xs text-red-400 mt-0.5">⚠ {col.error}</div>
)}
</div>
<button
onClick={() => removeCol(col.id)}
className="text-text-disabled hover:text-red-400 transition-colors text-base leading-none shrink-0 mt-0.5"
title="Retirer"
>
×
</button>
</div>
</th>
))}
</tr>
</thead>
{/* Attribute rows */}
<tbody className="divide-y divide-background-card">
{ATTR_ROWS.map(row => {
const shared = sharedByKey[row.key];
const hasAnyData = cols.some(c => (c.data?.attributes[row.key]?.length ?? 0) > 0);
if (!hasAnyData && cols.every(c => c.data !== null && !c.loading)) return null;
return (
<tr key={row.key} className="hover:bg-background-card/20 transition-colors">
<td className="px-4 py-3 align-top">
<div className="flex items-center gap-1.5">
<span>{row.icon}</span>
<span className="text-xs font-medium text-text-secondary">{row.label}</span>
{row.tip && <InfoTip content={row.tip} />}
</div>
{shared.size > 0 && (
<div className="mt-1">
<span className="text-xs bg-yellow-500/20 text-yellow-400 px-1.5 py-0.5 rounded">
★ {shared.size} commun{shared.size > 1 ? 's' : ''}
</span>
</div>
)}
</td>
{cols.map(col => {
const items = col.data?.attributes[row.key] ?? [];
return (
<td key={col.id} className="px-3 py-3 align-top">
{col.loading ? (
<div className="h-16 bg-background-card/30 rounded animate-pulse" />
) : items.length === 0 ? (
<span className="text-xs text-text-disabled">—</span>
) : (
<div className="space-y-1.5">
{items.slice(0, MAX_VALUES_PER_CELL).map((item, i) => {
const isShared = shared.has(item.value);
return (
<div
key={i}
className={`rounded px-2 py-1.5 text-xs ${
isShared
? 'bg-yellow-500/15 border border-yellow-500/30'
: 'bg-background-card/40'
}`}
>
<div className="flex items-start justify-between gap-1">
<div className={`font-mono break-all leading-tight ${
isShared ? 'text-yellow-300' : 'text-text-primary'
}`}>
{isShared && <span className="mr-1 text-yellow-400">★</span>}
{row.key === 'countries'
? `${getCountryFlag(item.value)} ${item.value}`
: item.value.length > 60
? item.value.slice(0, 60) + ''
: item.value}
</div>
</div>
<div className="text-text-disabled mt-0.5 flex gap-2">
<span>{item.count.toLocaleString()}</span>
<span>{item.percentage.toFixed(1)}%</span>
</div>
</div>
);
})}
{items.length > MAX_VALUES_PER_CELL && (
<div className="text-xs text-text-disabled px-2">
+{items.length - MAX_VALUES_PER_CELL} autres
</div>
)}
</div>
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Legend */}
{cols.length >= 2 && (
<div className="flex items-center gap-4 text-xs text-text-disabled">
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded bg-yellow-500/20 border border-yellow-500/30 inline-block" />
Valeur partagée par 2+ entités
</span>
<span>|</span>
<span>Cliquer sur une entité Investigation complète</span>
</div>
)}
</div>
);
}

View File

@ -1,182 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
interface SearchResult {
type: 'ip' | 'ja4' | 'host' | 'asn';
value: string;
label: string;
meta: string;
url: string;
investigation_url?: string;
}
interface QuickSearchProps {
onNavigate?: () => void;
}
const TYPE_ICON: Record<string, string> = { ip: '🌐', ja4: '🔏', host: '🖥️', asn: '🏢' };
const TYPE_COLOR: Record<string, string> = {
ip: 'bg-blue-500/20 text-blue-400',
ja4: 'bg-purple-500/20 text-purple-400',
host: 'bg-green-500/20 text-green-400',
asn: 'bg-orange-500/20 text-orange-400',
};
export function QuickSearch({ onNavigate }: QuickSearchProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Recherche via le nouvel endpoint unifié
useEffect(() => {
if (query.length < 2) { setResults([]); return; }
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
setLoading(true);
try {
const res = await fetch(`/api/search/quick?q=${encodeURIComponent(query)}`);
if (res.ok) {
const data = await res.json();
setResults(data.results || []);
setSelectedIndex(-1);
}
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, 250);
}, [query]);
// Raccourci clavier Cmd+K
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
inputRef.current?.focus();
setIsOpen(true);
}
if (e.key === 'Escape') { setIsOpen(false); setQuery(''); }
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
// Navigation clavier dans les résultats
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen || results.length === 0) return;
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(i => Math.min(i + 1, results.length - 1)); }
if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => Math.max(i - 1, 0)); }
if (e.key === 'Enter' && selectedIndex >= 0) {
e.preventDefault();
handleSelect(results[selectedIndex], (e as any).metaKey || (e as any).ctrlKey);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, results, selectedIndex]);
// Click en dehors
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) setIsOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (result: SearchResult, useInvestigation = false) => {
const url = (useInvestigation && result.investigation_url) ? result.investigation_url : result.url;
navigate(url);
setIsOpen(false);
setQuery('');
onNavigate?.();
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (results[0]) handleSelect(results[0]);
};
return (
<div ref={containerRef} className="relative w-full max-w-2xl">
{/* Barre de recherche */}
<form onSubmit={handleSubmit} className="relative">
<div className="flex items-center bg-background-card border border-background-card rounded-lg focus-within:border-accent-primary transition-colors">
<span className="pl-4 text-text-secondary">{loading ? <span className="animate-pulse"></span> : '🔍'}</span>
<input
ref={inputRef}
type="text"
value={query}
onChange={e => { setQuery(e.target.value); setIsOpen(true); }}
onFocus={() => setIsOpen(true)}
placeholder="Rechercher IP, JA4, ASN, Host... (Cmd+K)"
className="flex-1 bg-transparent border-none px-4 py-3 text-text-primary placeholder-text-secondary focus:outline-none"
autoComplete="off"
/>
<kbd className="hidden md:inline-flex items-center gap-1 px-2 py-1.5 mr-2 text-xs text-text-secondary bg-background-secondary rounded border border-background-card">
<span></span>K
</kbd>
</div>
</form>
{/* Dropdown résultats */}
{isOpen && query.length >= 2 && (
<div className="absolute top-full left-0 right-0 mt-2 bg-background-secondary border border-background-card rounded-xl shadow-2xl z-50 max-h-96 overflow-y-auto">
{results.length > 0 ? (
<ul className="py-1">
{results.map((result, i) => (
<li key={`${result.type}-${result.value}-${i}`}>
<button
onClick={() => handleSelect(result)}
className={[
'w-full flex items-center gap-3 px-4 py-2.5 transition-colors text-left',
i === selectedIndex ? 'bg-accent-primary/10 border-l-2 border-accent-primary' : 'hover:bg-background-card/50 border-l-2 border-transparent',
].join(' ')}
onMouseEnter={() => setSelectedIndex(i)}
>
<span className="text-lg shrink-0">{TYPE_ICON[result.type] ?? '🔍'}</span>
<div className="flex-1 min-w-0">
<div className="font-mono text-sm text-text-primary truncate">{result.label}</div>
<div className="text-xs text-text-secondary">{result.meta}</div>
</div>
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs font-bold ${TYPE_COLOR[result.type] ?? ''}`}>
{result.type.toUpperCase()}
</span>
{result.investigation_url && (
<button
className="shrink-0 text-xs text-accent-primary hover:underline ml-1"
onClick={e => { e.stopPropagation(); handleSelect(result, true); }}
title="Investigation complète"
></button>
)}
</button>
</li>
))}
</ul>
) : !loading ? (
<div className="px-4 py-6 text-center text-text-disabled text-sm">
Aucun résultat pour <span className="font-mono text-text-secondary">"{query}"</span>
</div>
) : null}
{/* Hints */}
<div className="border-t border-background-card px-4 py-2 flex items-center gap-3 text-xs text-text-disabled">
<span><kbd className="bg-background-card px-1 rounded"></kbd> naviguer</span>
<span><kbd className="bg-background-card px-1 rounded"></kbd> ouvrir</span>
<span><kbd className="bg-background-card px-1 rounded"></kbd> investigation</span>
<span className="ml-auto opacity-60">24h</span>
</div>
</div>
)}
</div>
);
}

View File

@ -1,216 +0,0 @@
import { useEffect, useState } from 'react';
import { formatDate } from '../utils/dateUtils';
interface ReputationData {
ip: string;
timestamp: string;
sources: {
[key: string]: {
[key: string]: any;
};
};
aggregated: {
is_proxy: boolean;
is_hosting: boolean;
is_vpn: boolean;
is_tor: boolean;
threat_score: number;
threat_level: 'unknown' | 'clean' | 'low' | 'medium' | 'high' | 'critical';
country: string | null;
country_code: string | null;
asn: string | null;
asn_org: string | null;
org: string | null;
city: string | null;
warnings: string[];
};
}
interface ReputationPanelProps {
ip: string;
}
export function ReputationPanel({ ip }: ReputationPanelProps) {
const [reputation, setReputation] = useState<ReputationData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchReputation = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/reputation/ip/${encodeURIComponent(ip)}`);
if (!response.ok) {
throw new Error(`Erreur HTTP: ${response.status}`);
}
const data = await response.json();
setReputation(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
if (ip) {
fetchReputation();
}
}, [ip]);
const getThreatLevelColor = (level: string) => {
switch (level) {
case 'critical': return 'text-red-500 bg-red-500/10 border-red-500';
case 'high': return 'text-orange-500 bg-orange-500/10 border-orange-500';
case 'medium': return 'text-yellow-500 bg-yellow-500/10 border-yellow-500';
case 'low': return 'text-blue-500 bg-blue-500/10 border-blue-500';
case 'clean': return 'text-green-500 bg-green-500/10 border-green-500';
default: return 'text-gray-500 bg-gray-500/10 border-gray-500';
}
};
const getThreatLevelLabel = (level: string) => {
const labels: { [key: string]: string } = {
'critical': '🔴 Critique',
'high': '🟠 Élevé',
'medium': '🟡 Moyen',
'low': '🔵 Faible',
'clean': '🟢 Propre',
'unknown': '⚪ Inconnu'
};
return labels[level] || level;
};
if (loading) {
return (
<div className="p-4 text-center text-text-secondary">
Vérification de la réputation...
</div>
);
}
if (error) {
return (
<div className="p-4 text-center text-red-500">
Erreur: {error}
</div>
);
}
if (!reputation) {
return null;
}
const { aggregated } = reputation;
return (
<div className="space-y-4">
{/* Threat Level Badge */}
<div className={`p-4 rounded-lg border ${getThreatLevelColor(aggregated.threat_level)}`}>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium mb-1">Niveau de menace</div>
<div className="text-2xl font-bold">{getThreatLevelLabel(aggregated.threat_level)}</div>
</div>
<div className="text-right">
<div className="text-sm font-medium mb-1">Score</div>
<div className="text-2xl font-bold">{aggregated.threat_score}/100</div>
</div>
</div>
{/* Progress bar */}
<div className="mt-3 w-full bg-background-secondary rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
aggregated.threat_score >= 80 ? 'bg-red-500' :
aggregated.threat_score >= 60 ? 'bg-orange-500' :
aggregated.threat_score >= 40 ? 'bg-yellow-500' :
aggregated.threat_score >= 20 ? 'bg-blue-500' :
'bg-green-500'
}`}
style={{ width: `${aggregated.threat_score}%` }}
/>
</div>
</div>
{/* Detection Badges */}
<div className="grid grid-cols-2 gap-2">
<DetectionBadge
label="Proxy"
detected={aggregated.is_proxy}
icon="🌐"
/>
<DetectionBadge
label="Hosting"
detected={aggregated.is_hosting}
icon="☁️"
/>
<DetectionBadge
label="VPN"
detected={aggregated.is_vpn}
icon="🔒"
/>
<DetectionBadge
label="Tor"
detected={aggregated.is_tor}
icon="🧅"
/>
</div>
{/* Info Grid */}
<div className="grid grid-cols-2 gap-3">
<InfoField label="Pays" value={aggregated.country || '-'} />
<InfoField label="Ville" value={aggregated.city || '-'} />
<InfoField label="ASN" value={aggregated.asn ? `AS${aggregated.asn}` : '-'} />
<InfoField label="Organisation" value={aggregated.org || aggregated.asn_org || '-'} />
</div>
{/* Warnings */}
{aggregated.warnings.length > 0 && (
<div className="bg-orange-500/10 border border-orange-500 rounded-lg p-3">
<div className="text-sm font-medium text-orange-500 mb-2">
Avertissements ({aggregated.warnings.length})
</div>
<ul className="space-y-1">
{aggregated.warnings.map((warning, index) => (
<li key={index} className="text-xs text-text-secondary">
{warning}
</li>
))}
</ul>
</div>
)}
{/* Sources */}
<div className="text-xs text-text-secondary text-center">
Sources: {Object.keys(reputation.sources).join(', ')} {formatDate(reputation.timestamp)}
</div>
</div>
);
}
function DetectionBadge({ label, detected, icon }: { label: string; detected: boolean; icon: string }) {
return (
<div className={`p-2 rounded-lg border text-center ${
detected
? 'bg-red-500/10 border-red-500 text-red-500'
: 'bg-background-card border-background-card text-text-secondary'
}`}>
<div className="text-lg mb-1">{icon}</div>
<div className="text-xs font-medium">{label}</div>
<div className={`text-xs font-bold ${detected ? 'text-red-500' : 'text-text-secondary'}`}>
{detected ? 'DÉTECTÉ' : 'Non'}
</div>
</div>
);
}
function InfoField({ label, value }: { label: string; value: string }) {
return (
<div className="bg-background-card rounded-lg p-2">
<div className="text-xs text-text-secondary mb-1">{label}</div>
<div className="text-sm text-text-primary font-medium truncate" title={value}>
{value}
</div>
</div>
);
}

View File

@ -1,262 +0,0 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips';
import { formatDateShort } from '../utils/dateUtils';
import { getCountryFlag } from '../utils/countryUtils';
interface SubnetIP {
ip: string;
total_detections: number;
unique_ja4: number;
unique_ua: number;
primary_country: string;
primary_asn: string;
threat_level: string;
avg_score: number;
first_seen: string;
last_seen: string;
}
interface SubnetStats {
subnet: string;
total_ips: number;
total_detections: number;
unique_ja4: number;
unique_ua: number;
unique_hosts: number;
primary_country: string;
primary_asn: string;
first_seen: string;
last_seen: string;
}
export function SubnetInvestigation() {
const { subnet } = useParams<{ subnet: string }>();
const navigate = useNavigate();
const [stats, setStats] = useState<SubnetStats | null>(null);
const [ips, setIps] = useState<SubnetIP[]>([]);
const [loading, setLoading] = useState(true);
// Convertir le format d'URL (ex: 192.168.1.0_24 -> 192.168.1.0/24)
const formattedSubnet = subnet ? subnet.replace('_', '/') : null;
useEffect(() => {
const fetchSubnet = async () => {
if (!formattedSubnet) return;
setLoading(true);
try {
const response = await fetch(`/api/entities/subnet/${encodeURIComponent(formattedSubnet)}`);
if (response.ok) {
const data = await response.json();
setStats(data.stats);
setIps(data.ips || []);
}
} catch (error) {
console.error('Error fetching subnet:', error);
} finally {
setLoading(false);
}
};
fetchSubnet();
}, [formattedSubnet]);
;
const getThreatLevelColor = (level: string) => {
switch (level) {
case 'CRITICAL': return 'text-red-500 bg-red-500/10';
case 'HIGH': return 'text-orange-500 bg-orange-500/10';
case 'MEDIUM': return 'text-yellow-500 bg-yellow-500/10';
case 'LOW': return 'text-green-500 bg-green-500/10';
default: return 'text-gray-500 bg-gray-500/10';
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-text-secondary">Chargement...</div>
</div>
);
}
if (!stats) {
return (
<div className="p-6">
<button
onClick={() => navigate('/')}
className="mb-4 px-4 py-2 bg-accent-primary text-white rounded hover:bg-accent-primary/80"
>
Retour
</button>
<div className="text-red-500">
Sous-réseau non trouvé: {subnet}
</div>
</div>
);
}
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/')}
className="px-4 py-2 bg-background-card text-text-primary rounded hover:bg-background-card/80 transition-colors"
>
Retour
</button>
<div>
<h1 className="text-2xl font-bold text-text-primary">
Investigation: Sous-réseau
</h1>
<p className="font-mono text-text-secondary">{subnet}</p>
</div>
</div>
</div>
{/* Stats Summary — 4 colonnes compact */}
<div className="grid grid-cols-4 gap-4">
<div className="bg-background-card rounded-lg p-4">
<div className="text-sm text-text-secondary mb-1">Total IPs</div>
<div className="text-2xl font-bold text-text-primary">{stats.total_ips}</div>
</div>
<div className="bg-background-card rounded-lg p-4">
<div className="text-sm text-text-secondary mb-1">Total Détections</div>
<div className="text-2xl font-bold text-text-primary">{stats.total_detections.toLocaleString()}</div>
</div>
<div className="bg-background-card rounded-lg p-4">
<div className="text-sm text-text-secondary mb-1 flex items-center gap-1">JA4 Uniques<InfoTip content={TIPS.ja4} /></div>
<div className="text-2xl font-bold text-text-primary">{stats.unique_ja4}</div>
</div>
<div className="bg-background-card rounded-lg p-4">
<div className="text-sm text-text-secondary mb-1">User-Agents Uniques</div>
<div className="text-2xl font-bold text-text-primary">{stats.unique_ua}</div>
</div>
</div>
{/* Infos secondaires — 4 colonnes */}
<div className="grid grid-cols-4 gap-4">
<div className="bg-background-card rounded-lg p-4">
<div className="text-sm text-text-secondary mb-1">Hosts Uniques</div>
<div className="text-2xl font-bold text-text-primary">{stats.unique_hosts}</div>
</div>
<div className="bg-background-card rounded-lg p-4">
<div className="text-sm text-text-secondary mb-1">Pays Principal</div>
<div className="text-2xl font-bold text-text-primary">
{getCountryFlag(stats.primary_country)} {stats.primary_country}
</div>
</div>
<div className="bg-background-card rounded-lg p-4">
<div className="text-sm text-text-secondary mb-1 flex items-center gap-1">ASN Principal<InfoTip content={TIPS.asn} /></div>
<div className="text-2xl font-bold text-text-primary">AS{stats.primary_asn}</div>
</div>
<div className="bg-background-card rounded-lg p-4">
<div className="text-sm text-text-secondary mb-1">Période</div>
<div className="text-sm text-text-primary font-medium">
{formatDateShort(stats.first_seen)} {formatDateShort(stats.last_seen)}
</div>
</div>
</div>
{/* IPs Table */}
<div className="bg-background-secondary rounded-lg p-6">
<h2 className="text-xl font-semibold text-text-primary mb-4">
IPs du Sous-réseau ({ips.length})
</h2>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-background-card">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">IP</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Détections</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">JA4</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">UA</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Pays</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">ASN</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase"><span className="flex items-center gap-1">Menace<InfoTip content={TIPS.threat_level} /></span></th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase"><span className="flex items-center gap-1">Score<InfoTip content={TIPS.risk_score_inv} /></span></th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-background-card">
{ips.map((ipData) => (
<tr
key={ipData.ip}
className="hover:bg-background-card/50 transition-colors"
>
<td className="px-4 py-3 font-mono text-sm text-text-primary">
{ipData.ip}
</td>
<td className="px-4 py-3 text-text-primary">
{ipData.total_detections}
</td>
<td className="px-4 py-3 text-text-primary">
{ipData.unique_ja4}
</td>
<td className="px-4 py-3 text-text-primary">
{ipData.unique_ua}
</td>
<td className="px-4 py-3 text-text-primary">
{getCountryFlag(ipData.primary_country)} {ipData.primary_country}
</td>
<td className="px-4 py-3 text-text-primary">
AS{ipData.primary_asn}
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${getThreatLevelColor(ipData.threat_level)}`}>
{ipData.threat_level}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-24 bg-background-secondary rounded-full h-2">
<div
className={`h-2 rounded-full ${
ipData.avg_score >= 80 ? 'bg-red-500' :
ipData.avg_score >= 60 ? 'bg-orange-500' :
ipData.avg_score >= 40 ? 'bg-yellow-500' :
'bg-green-500'
}`}
style={{ width: `${Math.min(100, ipData.avg_score * 100)}%` }}
/>
</div>
<span className="text-sm text-text-primary">{Math.round(ipData.avg_score * 100)}</span>
</div>
</td>
<td className="px-4 py-3">
<div className="flex gap-2">
<button
onClick={() => navigate(`/investigation/${ipData.ip}`)}
className="px-2 py-1 bg-accent-primary text-white rounded text-xs hover:bg-accent-primary/80"
>
Investiguer
</button>
<button
onClick={() => navigate(`/entities/ip/${ipData.ip}`)}
className="px-2 py-1 bg-background-card text-text-primary rounded text-xs hover:bg-background-card/80"
>
Détails
</button>
</div>
</td>
</tr>
))}
{ips.length === 0 && (
<tr>
<td colSpan={9} className="px-4 py-12 text-center text-text-secondary">
Aucune IP trouvée dans ce sous-réseau
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@ -1,573 +0,0 @@
import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import DataTable, { Column } from './ui/DataTable';
import { TIPS } from './ui/tooltips';
import { formatNumber } from '../utils/dateUtils';
import { LoadingSpinner, ErrorMessage } from './ui/Feedback';
// ─── Types ────────────────────────────────────────────────────────────────────
interface TcpSpoofingOverview {
total_entries: number;
unique_ips: number;
no_tcp_data: number;
with_tcp_data: number;
linux_mac_fingerprint: number;
windows_fingerprint: number;
cisco_bsd_fingerprint: number;
bot_scanner_fingerprint: number;
ttl_distribution: { ttl: number; count: number; ips: number }[];
mss_distribution: { mss: number; count: number; ips: number }[];
window_size_distribution: { window_size: number; count: number }[];
}
interface TcpSpoofingItem {
ip: string;
ja4: string;
tcp_ttl: number;
tcp_window_size: number;
tcp_win_scale: number;
tcp_mss: number;
hits: number;
first_ua: string;
suspected_os: string;
initial_ttl: number;
hop_count: number;
confidence: number;
network_path: string;
is_bot_tool: boolean;
declared_os: string;
spoof_flag: boolean;
spoof_reason: string;
}
interface OsMatrixEntry {
suspected_os: string;
declared_os: string;
count: number;
is_spoof: boolean;
is_bot_tool: boolean;
}
type ActiveTab = 'detections' | 'matrix';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function confidenceBar(conf: number): JSX.Element {
const pct = Math.round(conf * 100);
const color =
pct >= 85 ? 'bg-threat-low' :
pct >= 65 ? 'bg-threat-medium' :
pct >= 45 ? 'bg-accent-primary' :
'bg-text-disabled';
return (
<div className="flex items-center gap-2">
<div className="h-1.5 w-16 bg-background-secondary rounded-full overflow-hidden">
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs text-text-secondary">{pct}%</span>
</div>
);
}
function mssLabel(mss: number): string {
if (mss >= 1460) return 'Ethernet';
if (mss >= 1452) return 'PPPoE';
if (mss >= 1420) return 'VPN';
if (mss >= 1380) return 'VPN/Tunnel';
if (mss > 0) return 'Bas débit';
return '—';
}
function mssColor(mss: number): string {
if (mss >= 1460) return 'text-threat-low';
if (mss >= 1436) return 'text-text-secondary';
if (mss >= 1380) return 'text-threat-medium';
return 'text-threat-critical';
}
function osIcon(name: string): string {
const n = name.toLowerCase();
if (n.includes('bot') || n.includes('scanner') || n.includes('mirai') || n.includes('zmap')) return '🤖';
if (n.includes('windows')) return '🪟';
if (n.includes('ios') || n.includes('macos')) return '🍎';
if (n.includes('android')) return '🤖';
if (n.includes('linux')) return '🐧';
if (n.includes('cisco') || n.includes('cdn') || n.includes('réseau')) return '🔌';
if (n.includes('bsd')) return '😈';
return '❓';
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
return (
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
<span className="text-text-secondary text-sm">{label}</span>
<span className={`text-2xl font-bold ${accent ?? 'text-text-primary'}`}>{value}</span>
</div>
);
}
// ─── Detections DataTable ─────────────────────────────────────────────────────
function TcpDetectionsTable({
items,
navigate,
}: {
items: TcpSpoofingItem[];
navigate: (path: string) => void;
}) {
const columns = useMemo((): Column<TcpSpoofingItem>[] => [
{
key: 'ip',
label: 'IP',
render: (v: string) => <span className="font-mono text-xs text-text-primary">{v}</span>,
},
{
key: 'tcp_ttl',
label: 'TTL obs. / init.',
tooltip: TIPS.ttl,
align: 'right',
render: (_: number, row: TcpSpoofingItem) => (
<span className="font-mono text-xs">
<span className="text-text-secondary">{row.tcp_ttl}</span>
<span className="text-text-disabled mx-1">/</span>
<span className="text-accent-primary font-semibold">{row.initial_ttl}</span>
{row.hop_count >= 0 && (
<span className="text-text-disabled ml-1 text-[10px]">({row.hop_count} hops)</span>
)}
</span>
),
},
{
key: 'tcp_mss',
label: 'MSS',
tooltip: TIPS.mss,
align: 'right',
render: (v: number) => (
<span className={`font-mono text-xs ${mssColor(v)}`} title={mssLabel(v)}>
{v || '—'} <span className="text-[10px] text-text-disabled">{mssLabel(v)}</span>
</span>
),
},
{
key: 'tcp_win_scale',
label: 'Scale',
tooltip: TIPS.win_scale,
align: 'right',
render: (v: number) => (
<span className="font-mono text-xs text-text-secondary">{v}</span>
),
},
{
key: 'suspected_os',
label: 'OS suspecté (TCP)',
tooltip: TIPS.os_tcp,
render: (v: string, row: TcpSpoofingItem) => (
<span className={`text-xs flex items-center gap-1 ${row.is_bot_tool ? 'text-threat-critical font-semibold' : 'text-text-primary'}`}>
<span>{osIcon(v)}</span>
<span>{v || '—'}</span>
</span>
),
},
{
key: 'confidence',
label: 'Confiance',
tooltip: TIPS.confidence,
render: (v: number) => confidenceBar(v),
},
{
key: 'network_path',
label: 'Réseau',
render: (v: string) => <span className="text-xs text-text-secondary">{v || '—'}</span>,
},
{
key: 'declared_os',
label: 'OS déclaré (UA)',
tooltip: TIPS.os_ua,
render: (v: string) => <span className="text-xs text-text-secondary">{v || '—'}</span>,
},
{
key: 'spoof_flag',
label: 'Verdict',
tooltip: TIPS.spoof_verdict,
sortable: false,
render: (v: boolean, row: TcpSpoofingItem) => {
if (row.is_bot_tool) {
return (
<span className="bg-threat-critical/20 text-threat-critical text-xs px-2 py-0.5 rounded-full whitespace-nowrap" title={row.spoof_reason}>
🤖 Bot/Scanner
</span>
);
}
if (v) {
return (
<span className="bg-threat-high/20 text-threat-high text-xs px-2 py-0.5 rounded-full whitespace-nowrap" title={row.spoof_reason}>
🚨 Spoof
</span>
);
}
return null;
},
},
{
key: '_actions',
label: '',
sortable: false,
render: (_: unknown, row: TcpSpoofingItem) => (
<button
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.ip}`); }}
className="text-xs bg-accent-primary/10 text-accent-primary px-3 py-1 rounded hover:bg-accent-primary/20 transition-colors"
>
Investiguer
</button>
),
},
], [navigate]);
return (
<DataTable
data={items}
columns={columns}
rowKey="ip"
defaultSortKey="hits"
emptyMessage="Aucune détection"
compact
/>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function TcpSpoofingView() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<ActiveTab>('detections');
const [spoofOnly, setSpoofOnly] = useState(false);
const [overview, setOverview] = useState<TcpSpoofingOverview | null>(null);
const [overviewLoading, setOverviewLoading] = useState(true);
const [overviewError, setOverviewError] = useState<string | null>(null);
const [items, setItems] = useState<TcpSpoofingItem[]>([]);
const [itemsLoading, setItemsLoading] = useState(true);
const [itemsError, setItemsError] = useState<string | null>(null);
const [matrix, setMatrix] = useState<OsMatrixEntry[]>([]);
const [matrixLoading, setMatrixLoading] = useState(false);
const [matrixError, setMatrixError] = useState<string | null>(null);
const [matrixLoaded, setMatrixLoaded] = useState(false);
const [filterText, setFilterText] = useState('');
useEffect(() => {
const fetchOverview = async () => {
setOverviewLoading(true);
try {
const res = await fetch('/api/tcp-spoofing/overview');
if (!res.ok) throw new Error('Erreur chargement overview');
const data: TcpSpoofingOverview = await res.json();
setOverview(data);
} catch (err) {
setOverviewError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setOverviewLoading(false);
}
};
fetchOverview();
}, []);
useEffect(() => {
const fetchItems = async () => {
setItemsLoading(true);
setItemsError(null);
try {
const params = new URLSearchParams({ limit: '200' });
if (spoofOnly) params.set('spoof_only', 'true');
const res = await fetch(`/api/tcp-spoofing/list?${params}`);
if (!res.ok) throw new Error('Erreur chargement des détections');
const data: { items: TcpSpoofingItem[]; total: number } = await res.json();
setItems(data.items ?? []);
} catch (err) {
setItemsError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setItemsLoading(false);
}
};
fetchItems();
}, [spoofOnly]);
const loadMatrix = async () => {
if (matrixLoaded) return;
setMatrixLoading(true);
try {
const res = await fetch('/api/tcp-spoofing/matrix');
if (!res.ok) throw new Error('Erreur chargement matrice OS');
const data: { matrix: OsMatrixEntry[] } = await res.json();
setMatrix(data.matrix ?? []);
setMatrixLoaded(true);
} catch (err) {
setMatrixError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setMatrixLoading(false);
}
};
const handleTabChange = (tab: ActiveTab) => {
setActiveTab(tab);
if (tab === 'matrix') loadMatrix();
};
const filteredItems = items.filter(
(item) =>
(!spoofOnly || item.spoof_flag || item.is_bot_tool) &&
(!filterText ||
item.ip.includes(filterText) ||
item.suspected_os.toLowerCase().includes(filterText.toLowerCase()) ||
item.declared_os.toLowerCase().includes(filterText.toLowerCase()) ||
item.network_path.toLowerCase().includes(filterText.toLowerCase()))
);
// Build matrix axes
const suspectedOSes = [...new Set(matrix.map((e) => e.suspected_os))].sort();
const declaredOSes = [...new Set(matrix.map((e) => e.declared_os))].sort();
const matrixMax = matrix.reduce((m, e) => Math.max(m, e.count), 1);
function matrixCellColor(count: number): string {
if (count === 0) return 'bg-background-card';
const ratio = count / matrixMax;
if (ratio >= 0.75) return 'bg-threat-critical/80';
if (ratio >= 0.5) return 'bg-threat-high/70';
if (ratio >= 0.25) return 'bg-threat-medium/60';
return 'bg-threat-low/40';
}
const tabs: { id: ActiveTab; label: string }[] = [
{ id: 'detections', label: '📋 Détections' },
{ id: 'matrix', label: '📊 Matrice OS' },
];
return (
<div className="p-6 space-y-6 animate-fade-in">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-text-primary">🧬 Spoofing TCP/OS</h1>
<p className="text-text-secondary mt-1">
Fingerprinting multi-signal (TTL + MSS + fenêtre + scale) détection bots, spoofs et anomalies TCP.
</p>
</div>
{/* Stat cards */}
{overviewLoading ? (
<LoadingSpinner />
) : overviewError ? (
<ErrorMessage message={overviewError} />
) : overview ? (
<>
<div className="grid grid-cols-4 gap-4">
<StatCard label="Avec données TCP" value={formatNumber(overview.with_tcp_data)} accent="text-text-primary" />
<StatCard label="Fingerprint Linux/Mac" value={formatNumber(overview.linux_mac_fingerprint)} accent="text-threat-low" />
<StatCard label="Fingerprint Windows" value={formatNumber(overview.windows_fingerprint)} accent="text-accent-primary" />
<StatCard label="🤖 Bots/Scanners détectés" value={formatNumber(overview.bot_scanner_fingerprint)} accent="text-threat-critical" />
</div>
<div className="grid grid-cols-2 gap-4">
{/* Distribution MSS */}
<div className="bg-background-card border border-border rounded-lg p-4">
<h3 className="text-sm font-semibold text-text-primary mb-3">Distribution MSS (type de réseau)</h3>
<div className="space-y-1.5">
{overview.mss_distribution.map((m) => {
const label = m.mss >= 1460 ? 'Ethernet' : m.mss >= 1452 ? 'PPPoE' : m.mss >= 1420 ? 'VPN léger' : m.mss >= 1380 ? 'VPN/Tunnel' : 'Bas débit';
const color = m.mss >= 1460 ? 'bg-threat-low' : m.mss >= 1436 ? 'bg-accent-primary' : m.mss >= 1380 ? 'bg-threat-medium' : 'bg-threat-critical';
const maxCount = overview.mss_distribution[0]?.count || 1;
return (
<div key={m.mss} className="flex items-center gap-2 text-xs">
<span className="text-text-disabled w-12 text-right font-mono">{m.mss}</span>
<div className="flex-1 h-2 bg-background-secondary rounded-full overflow-hidden">
<div className={`h-full rounded-full ${color}`} style={{ width: `${(m.count / maxCount) * 100}%` }} />
</div>
<span className="text-text-secondary w-16">{formatNumber(m.count)}</span>
<span className="text-text-disabled w-20">{label}</span>
</div>
);
})}
</div>
</div>
{/* Distribution TTL */}
<div className="bg-background-card border border-border rounded-lg p-4">
<h3 className="text-sm font-semibold text-text-primary mb-3">Distribution TTL observé</h3>
<div className="space-y-1.5">
{overview.ttl_distribution.map((t) => {
const family = t.ttl <= 64 ? 'Linux/Mac' : t.ttl <= 128 ? 'Windows' : 'Cisco/BSD';
const color = t.ttl <= 64 ? 'bg-threat-low' : t.ttl <= 128 ? 'bg-accent-primary' : 'bg-threat-medium';
const maxCount = overview.ttl_distribution[0]?.count || 1;
return (
<div key={t.ttl} className="flex items-center gap-2 text-xs">
<span className="text-text-disabled w-8 text-right font-mono">{t.ttl}</span>
<div className="flex-1 h-2 bg-background-secondary rounded-full overflow-hidden">
<div className={`h-full rounded-full ${color}`} style={{ width: `${(t.count / maxCount) * 100}%` }} />
</div>
<span className="text-text-secondary w-16">{formatNumber(t.count)}</span>
<span className="text-text-disabled w-20">{family}</span>
</div>
);
})}
</div>
</div>
</div>
<div className="bg-background-card border border-border rounded-lg px-4 py-3 text-sm text-text-secondary flex items-center gap-2">
<span className="text-threat-medium"></span>
<span>
<strong className="text-text-primary">{formatNumber(overview.no_tcp_data)}</strong> entrées sans données TCP (passées par proxy/CDN) exclues.{' '}
<strong className="text-threat-critical">{formatNumber(overview.bot_scanner_fingerprint)}</strong> entrées avec signature Masscan/scanner identifiée (win=5808, mss=1452, scale=4).
</span>
</div>
</>
) : null}
{/* Tabs */}
<div className="flex gap-2 border-b border-border">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab.id
? 'text-accent-primary border-b-2 border-accent-primary'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Détections tab */}
{activeTab === 'detections' && (
<>
<div className="flex gap-3 items-center">
<input
type="text"
placeholder="Filtrer par IP ou OS..."
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
className="bg-background-card border border-border rounded-lg px-3 py-2 text-sm text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary w-72"
/>
<label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer select-none">
<input
type="checkbox"
checked={spoofOnly}
onChange={(e) => setSpoofOnly(e.target.checked)}
className="accent-accent-primary"
/>
Spoofs &amp; Bots uniquement (corrélation confirmée)
</label>
</div>
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
{itemsLoading ? (
<LoadingSpinner />
) : itemsError ? (
<div className="p-4"><ErrorMessage message={itemsError} /></div>
) : (
<TcpDetectionsTable items={filteredItems} navigate={navigate} />
)}
</div>
</>
)}
{/* Matrice OS tab */}
{activeTab === 'matrix' && (
<div className="bg-background-secondary rounded-lg border border-border p-6">
{matrixLoading ? (
<LoadingSpinner />
) : matrixError ? (
<ErrorMessage message={matrixError} />
) : matrix.length === 0 ? (
<p className="text-text-secondary text-sm">Aucune donnée disponible.</p>
) : (
<div className="overflow-auto">
<h2 className="text-text-primary font-semibold mb-4">OS Suspecté × OS Déclaré</h2>
<table className="text-xs border-collapse">
<thead>
<tr>
<th className="px-3 py-2 text-text-secondary text-left border border-border bg-background-card">
Suspecté \ Déclaré
</th>
{declaredOSes.map((os) => (
<th key={os} className="px-3 py-2 text-text-secondary text-center border border-border bg-background-card max-w-[80px]">
<span className="block truncate w-20" title={os}>{os}</span>
</th>
))}
<th className="px-3 py-2 text-text-secondary text-center border border-border bg-background-card">Total</th>
</tr>
</thead>
<tbody>
{suspectedOSes.map((sos) => {
const rowEntries = declaredOSes.map((dos) => {
const entry = matrix.find((e) => e.suspected_os === sos && e.declared_os === dos);
return entry?.count ?? 0;
});
const rowTotal = rowEntries.reduce((s, v) => s + v, 0);
return (
<tr key={sos}>
<td className="px-3 py-2 text-text-primary border border-border bg-background-card font-medium max-w-[120px]">
<span className="block truncate w-28" title={sos}>{sos}</span>
</td>
{rowEntries.map((count, ci) => {
const dos = declaredOSes[ci];
const entry = matrix.find((e) => e.suspected_os === sos && e.declared_os === dos);
const isSpoofCell = entry?.is_spoof ?? false;
const isBotCell = entry?.is_bot_tool ?? false;
return (
<td
key={ci}
className={`px-3 py-2 text-center border border-border font-mono ${
isBotCell && count > 0
? 'bg-threat-critical/30 text-threat-critical font-bold'
: isSpoofCell && count > 0
? 'bg-threat-high/25 text-threat-high font-bold'
: matrixCellColor(count) + (count > 0 ? ' text-text-primary' : ' text-text-disabled')
}`}
title={isBotCell ? '🤖 Outil de scan/bot' : isSpoofCell ? '🚨 OS mismatch confirmé' : undefined}
>
{count > 0
? isBotCell ? `🤖 ${formatNumber(count)}`
: isSpoofCell ? `🚨 ${formatNumber(count)}`
: formatNumber(count)
: '—'}
</td>
);
})}
<td className="px-3 py-2 text-center border border-border font-semibold text-text-primary bg-background-card">
{formatNumber(rowTotal)}
</td>
</tr>
);
})}
<tr>
<td className="px-3 py-2 text-text-secondary border border-border bg-background-card font-semibold">Total</td>
{declaredOSes.map((dos) => {
const colTotal = matrix
.filter((e) => e.declared_os === dos)
.reduce((s, e) => s + e.count, 0);
return (
<td key={dos} className="px-3 py-2 text-center border border-border font-semibold text-text-primary bg-background-card">
{formatNumber(colTotal)}
</td>
);
})}
<td className="px-3 py-2 text-center border border-border font-semibold text-accent-primary bg-background-card">
{formatNumber(matrix.reduce((s, e) => s + e.count, 0))}
</td>
</tr>
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -1,326 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips';
import { formatDateShort } from '../utils/dateUtils';
interface Classification {
ip?: string;
ja4?: string;
label: 'legitimate' | 'suspicious' | 'malicious';
tags: string[];
comment: string;
confidence: number;
analyst: string;
created_at: string;
}
interface ClassificationStats {
label: string;
total: number;
unique_ips: number;
avg_confidence: number;
}
export function ThreatIntelView() {
const navigate = useNavigate();
const [classifications, setClassifications] = useState<Classification[]>([]);
const [stats, setStats] = useState<ClassificationStats[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [filterLabel, setFilterLabel] = useState<string>('all');
const [filterTag, setFilterTag] = useState<string>('');
useEffect(() => {
const fetchThreatIntel = async () => {
setLoading(true);
try {
// Fetch classifications
const classificationsResponse = await fetch('/api/analysis/classifications?page_size=100');
if (classificationsResponse.ok) {
const data = await classificationsResponse.json();
setClassifications(data.items || []);
}
// Fetch stats
const statsResponse = await fetch('/api/analysis/classifications/stats');
if (statsResponse.ok) {
const data = await statsResponse.json();
setStats(data.stats || []);
}
} catch (error) {
console.error('Error fetching threat intel:', error);
} finally {
setLoading(false);
}
};
fetchThreatIntel();
}, []);
const filteredClassifications = classifications.filter(c => {
if (filterLabel !== 'all' && c.label !== filterLabel) return false;
if (filterTag && !c.tags.includes(filterTag)) return false;
if (search) {
const searchLower = search.toLowerCase();
const ipMatch = c.ip?.toLowerCase().includes(searchLower);
const ja4Match = c.ja4?.toLowerCase().includes(searchLower);
const tagMatch = c.tags.some(t => t.toLowerCase().includes(searchLower));
const commentMatch = c.comment.toLowerCase().includes(searchLower);
if (!ipMatch && !ja4Match && !tagMatch && !commentMatch) return false;
}
return true;
});
const allTags = Array.from(new Set(classifications.flatMap(c => c.tags)));
const getLabelIcon = (label: string) => {
switch (label) {
case 'legitimate': return '✅';
case 'suspicious': return '⚠️';
case 'malicious': return '❌';
default: return '❓';
}
};
const getLabelColor = (label: string) => {
switch (label) {
case 'legitimate': return 'bg-threat-low/20 text-threat-low';
case 'suspicious': return 'bg-threat-medium/20 text-threat-medium';
case 'malicious': return 'bg-threat-high/20 text-threat-high';
default: return 'bg-gray-500/20 text-gray-400';
}
};
const getTagColor = (tag: string) => {
const colors: Record<string, string> = {
'scraping': 'bg-blue-500/20 text-blue-400',
'bot-network': 'bg-red-500/20 text-red-400',
'scanner': 'bg-orange-500/20 text-orange-400',
'hosting-asn': 'bg-purple-500/20 text-purple-400',
'distributed': 'bg-yellow-500/20 text-yellow-400',
'ja4-rotation': 'bg-pink-500/20 text-pink-400',
'ua-rotation': 'bg-cyan-500/20 text-cyan-400',
};
return colors[tag] || 'bg-gray-500/20 text-gray-400';
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-text-secondary">Chargement de la Threat Intel...</div>
</div>
);
}
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-text-primary">📚 Threat Intelligence</h1>
<p className="text-text-secondary text-sm mt-1">
Base de connaissances des classifications SOC
</p>
</div>
</div>
{/* Statistics */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard
title="🤖 Malicious"
value={stats.find(s => s.label === 'malicious')?.total || 0}
subtitle="Entités malveillantes"
color="bg-threat-high/20"
/>
<StatCard
title="⚠️ Suspicious"
value={stats.find(s => s.label === 'suspicious')?.total || 0}
subtitle="Entités suspectes"
color="bg-threat-medium/20"
/>
<StatCard
title="✅ Légitime"
value={stats.find(s => s.label === 'legitimate')?.total || 0}
subtitle="Entités légitimes"
color="bg-threat-low/20"
/>
<StatCard
title="📊 Total"
value={classifications.length}
subtitle="Classifications totales"
color="bg-accent-primary/20"
/>
</div>
{/* Main content: sidebar filtres (1/4) + table (3/4) */}
<div className="grid grid-cols-4 gap-6 items-start">
{/* Sidebar filtres + tags */}
<div className="space-y-4">
<div className="bg-background-secondary rounded-lg p-4">
<h3 className="text-sm font-semibold text-text-primary mb-3">🔍 Recherche</h3>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="IP, JA4, tag, commentaire..."
className="w-full bg-background-card border border-background-card rounded-lg px-3 py-2 text-text-primary placeholder-text-secondary focus:outline-none focus:border-accent-primary text-sm"
/>
</div>
<div className="bg-background-secondary rounded-lg p-4">
<h3 className="text-sm font-semibold text-text-primary mb-3">🏷 Label</h3>
<div className="space-y-2">
{(['all', 'malicious', 'suspicious', 'legitimate'] as const).map(lbl => (
<button
key={lbl}
onClick={() => setFilterLabel(lbl)}
className={`w-full text-left px-3 py-1.5 rounded-lg text-sm transition-colors ${
filterLabel === lbl ? 'bg-accent-primary text-white' : 'text-text-secondary hover:text-text-primary hover:bg-background-card'
}`}
>
{lbl === 'all' ? '🔹 Tous' : lbl === 'malicious' ? '❌ Malicious' : lbl === 'suspicious' ? '⚠️ Suspicious' : '✅ Légitime'}
</button>
))}
</div>
</div>
<div className="bg-background-secondary rounded-lg p-4">
<h3 className="text-sm font-semibold text-text-primary mb-3">🏷 Tags populaires</h3>
<div className="flex flex-wrap gap-1.5">
{allTags.slice(0, 20).map(tag => {
const count = classifications.filter(c => c.tags.includes(tag)).length;
return (
<button
key={tag}
onClick={() => setFilterTag(filterTag === tag ? '' : tag)}
className={`px-2 py-1 rounded text-xs transition-colors ${
filterTag === tag ? 'bg-accent-primary text-white' : getTagColor(tag)
}`}
>
{tag} <span className="opacity-70">({count})</span>
</button>
);
})}
</div>
</div>
{(search || filterLabel !== 'all' || filterTag) && (
<div className="flex items-center justify-between">
<div className="text-xs text-text-secondary">{filteredClassifications.length} résultat(s)</div>
<button
onClick={() => { setSearch(''); setFilterLabel('all'); setFilterTag(''); }}
className="text-xs text-accent-primary hover:text-accent-primary/80"
>
Effacer
</button>
</div>
)}
</div>
{/* Table classifications (3/4) */}
<div className="col-span-3 bg-background-secondary rounded-lg overflow-hidden">
<div className="p-4 border-b border-background-card">
<h3 className="text-lg font-semibold text-text-primary">
📋 Classifications Récentes
<span className="ml-2 text-sm font-normal text-text-secondary">({filteredClassifications.length})</span>
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-background-card">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Date</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Entité</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Label</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Tags</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Commentaire</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase"><span className="flex items-center gap-1">Confiance<InfoTip content={TIPS.confiance} /></span></th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Analyste</th>
</tr>
</thead>
<tbody className="divide-y divide-background-card">
{filteredClassifications.slice(0, 50).map((classification, idx) => {
const entity = classification.ip || classification.ja4;
const isIP = !!classification.ip;
return (
<tr key={idx} className="hover:bg-background-card/50 transition-colors">
<td className="px-4 py-3 text-sm text-text-secondary whitespace-nowrap">
{formatDateShort(classification.created_at)}
</td>
<td className="px-4 py-3">
<button
onClick={() => navigate(isIP ? `/investigation/${encodeURIComponent(entity!)}` : `/investigation/ja4/${encodeURIComponent(entity!)}`)}
className="font-mono text-sm text-accent-primary hover:underline text-left truncate max-w-[160px] block"
title={entity}
>
{entity}
</button>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-bold ${getLabelColor(classification.label)}`}>
{getLabelIcon(classification.label)} {(classification.label ?? '').toUpperCase()}
</span>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{classification.tags.slice(0, 4).map((tag, tagIdx) => (
<span key={tagIdx} className={`px-2 py-0.5 rounded text-xs ${getTagColor(tag)}`}>{tag}</span>
))}
{classification.tags.length > 4 && (
<span className="text-xs text-text-secondary">+{classification.tags.length - 4}</span>
)}
</div>
</td>
<td className="px-4 py-3 text-sm text-text-secondary max-w-[200px]">
<span className="truncate block" title={(classification as any).comment || ''}>
{(classification as any).comment || <span className="text-text-disabled"></span>}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="flex-1 bg-background-secondary rounded-full h-2 min-w-[60px]">
<div className="h-2 rounded-full bg-accent-primary" style={{ width: `${classification.confidence * 100}%` }} />
</div>
<span className="text-xs text-text-primary font-bold">{(classification.confidence * 100).toFixed(0)}%</span>
</div>
</td>
<td className="px-4 py-3 text-sm text-text-secondary">{classification.analyst}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{filteredClassifications.length === 0 && (
<div className="text-center text-text-secondary py-12">
<div className="text-4xl mb-2">🔍</div>
<div className="text-sm">Aucune classification trouvée</div>
</div>
)}
</div>
</div>
</div>
);
}
// Stat Card Component
function StatCard({
title,
value,
subtitle,
color
}: {
title: string;
value: number;
subtitle: string;
color: string;
}) {
return (
<div className={`${color} rounded-lg p-6`}>
<h3 className="text-text-secondary text-sm font-medium">{title}</h3>
<p className="text-3xl font-bold text-text-primary mt-2">{value.toLocaleString()}</p>
<p className="text-text-disabled text-xs mt-2">{subtitle}</p>
</div>
);
}

View File

@ -1,258 +0,0 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { VariabilityAttributes, AttributeValue } from '../api/client';
interface VariabilityPanelProps {
attributes: VariabilityAttributes;
/** When true, hides the "Voir IPs associées" button (e.g. when already on an IP page) */
hideAssociatedIPs?: boolean;
}
export function VariabilityPanel({ attributes, hideAssociatedIPs = false }: VariabilityPanelProps) {
const [modal, setModal] = useState<{
title: string;
items: string[];
total: number;
} | null>(null);
const [loading, setLoading] = useState(false);
const loadAssociatedIPs = async (attrType: string, value: string, total: number) => {
setLoading(true);
try {
const res = await fetch(`/api/variability/${attrType}/${encodeURIComponent(value)}/ips?limit=100`);
const data = await res.json();
setModal({
title: `${data.total || total} IPs associées à ${value.length > 40 ? value.substring(0, 40) + '…' : value}`,
items: data.ips || [],
total: data.total || total,
});
} catch {
// ignore
}
setLoading(false);
};
const sections: Array<{
title: string;
icon: string;
items: AttributeValue[] | undefined;
getLink: (v: AttributeValue) => string;
attrType?: string;
mono?: boolean;
}> = [
{
title: 'JA4 Fingerprints',
icon: '🔏',
items: attributes.ja4,
getLink: (v) => `/investigation/ja4/${encodeURIComponent(v.value)}`,
attrType: 'ja4',
mono: true,
},
{
title: 'Hosts ciblés',
icon: '🌐',
items: attributes.hosts,
getLink: (v) => `/detections/host/${encodeURIComponent(v.value)}`,
attrType: 'host',
mono: true,
},
{
title: 'ASN',
icon: '🏢',
items: attributes.asns,
getLink: (v) => {
const n = v.value.match(/AS(\d+)/)?.[1] || v.value;
return `/detections/asn/${encodeURIComponent(n)}`;
},
attrType: 'asn',
},
{
title: 'Pays',
icon: '🌍',
items: attributes.countries,
getLink: (v) => `/detections/country/${encodeURIComponent(v.value)}`,
attrType: 'country',
},
{
title: 'Niveaux de menace',
icon: '⚠️',
items: attributes.threat_levels,
getLink: (v) => `/detections?threat_level=${encodeURIComponent(v.value)}`,
},
];
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-text-primary">Attributs observés</h2>
{/* User-Agents — plein format avec texte long */}
{attributes.user_agents && attributes.user_agents.length > 0 && (
<UASection items={attributes.user_agents} />
)}
{/* Grille 2 colonnes pour les autres attributs */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{sections.map((s) =>
s.items && s.items.length > 0 ? (
<AttributeSection
key={s.title}
title={s.title}
icon={s.icon}
items={s.items}
getLink={s.getLink}
attrType={s.attrType}
mono={s.mono}
hideAssociatedIPs={hideAssociatedIPs}
onLoadIPs={loadAssociatedIPs}
/>
) : null
)}
</div>
{/* Modal IPs associées */}
{(modal || loading) && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-background-secondary rounded-xl max-w-2xl w-full max-h-[80vh] flex flex-col shadow-2xl">
<div className="flex items-center justify-between px-6 py-4 border-b border-background-card">
<h3 className="font-semibold text-text-primary">{modal?.title ?? 'Chargement…'}</h3>
<button onClick={() => setModal(null)} className="text-text-secondary hover:text-text-primary text-2xl leading-none">×</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<p className="text-center text-text-secondary py-8">Chargement</p>
) : modal && modal.items.length > 0 ? (
<div className="grid grid-cols-2 gap-2">
{modal.items.map((ip, i) => (
<Link
key={i}
to={`/detections/ip/${ip}`}
onClick={() => setModal(null)}
className="bg-background-card hover:bg-background-card/70 rounded px-3 py-2 font-mono text-sm text-accent-primary transition-colors"
>
{ip}
</Link>
))}
</div>
) : (
<p className="text-center text-text-secondary py-8">Aucune donnée</p>
)}
{modal && modal.total > modal.items.length && (
<p className="text-center text-text-secondary text-xs mt-4">
{modal.items.length} / {modal.total} affichées
</p>
)}
</div>
<div className="px-6 py-4 border-t border-background-card text-right">
<button onClick={() => setModal(null)} className="bg-accent-primary hover:bg-accent-primary/80 text-white px-5 py-2 rounded-lg text-sm">Fermer</button>
</div>
</div>
</div>
)}
</div>
);
}
/* ─── AttributeSection ─────────────────────────────────────────────────────── */
function AttributeSection({
title,
icon,
items,
getLink,
attrType,
mono,
hideAssociatedIPs,
onLoadIPs,
}: {
title: string;
icon: string;
items: AttributeValue[];
getLink: (v: AttributeValue) => string;
attrType?: string;
mono?: boolean;
hideAssociatedIPs?: boolean;
onLoadIPs: (type: string, value: string, count: number) => void;
}) {
const [showAll, setShowAll] = useState(false);
const LIMIT = 8;
const displayed = showAll ? items : items.slice(0, LIMIT);
return (
<div className="bg-background-secondary rounded-xl p-4">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-3 flex items-center gap-2">
<span>{icon}</span> {title} <span className="ml-auto bg-background-card px-2 py-0.5 rounded-full text-xs font-mono">{items.length}</span>
</h3>
<div className="space-y-2">
{displayed.map((item, i) => {
const pct = item.percentage || 0;
const barColor =
pct >= 50 ? 'bg-threat-critical' :
pct >= 25 ? 'bg-threat-high' :
pct >= 10 ? 'bg-threat-medium' : 'bg-threat-low';
return (
<div key={i}>
<div className="flex items-center gap-2 mb-1">
<Link
to={getLink(item)}
className={`flex-1 text-xs hover:text-accent-primary transition-colors text-text-primary truncate ${mono ? 'font-mono' : ''}`}
title={item.value}
>
{item.value}
</Link>
<span className="text-xs text-text-secondary shrink-0">{item.count} ({pct.toFixed(0)}%)</span>
{!hideAssociatedIPs && attrType && (
<button
onClick={() => onLoadIPs(attrType, item.value, item.count)}
className="shrink-0 text-xs text-text-secondary hover:text-accent-primary transition-colors"
title="Voir les IPs associées"
>
👥
</button>
)}
</div>
<div className="w-full bg-background-card rounded-full h-1.5">
<div className={`h-1.5 rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
</div>
</div>
);
})}
</div>
{items.length > LIMIT && (
<button
onClick={() => setShowAll(v => !v)}
className="mt-3 w-full text-xs text-accent-primary hover:text-accent-primary/80"
>
{showAll ? '↑ Réduire' : `${items.length - LIMIT} de plus`}
</button>
)}
</div>
);
}
/* ─── UASection ─────────────────────────────────────────────────────────────── */
function UASection({ items }: { items: AttributeValue[] }) {
return (
<div className="bg-background-secondary rounded-xl p-4">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-3 flex items-center gap-2">
<span>🖥</span> User-Agents
<span className="ml-auto bg-background-card px-2 py-0.5 rounded-full text-xs font-mono">{items.length}</span>
</h3>
<div className="space-y-3">
{items.map((item, i) => {
const pct = item.percentage || 0;
return (
<div key={i}>
<div className="flex items-start gap-2 mb-1">
<span className="flex-1 text-xs font-mono text-text-primary break-all leading-relaxed">{item.value}</span>
<span className="shrink-0 text-xs text-text-secondary">{item.count} ({pct.toFixed(1)}%)</span>
</div>
<div className="w-full bg-background-card rounded-full h-1.5">
<div className="h-1.5 rounded-full bg-threat-medium" style={{ width: `${pct}%` }} />
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -1,289 +0,0 @@
import { useEffect, useState } from 'react';
import { PREDEFINED_TAGS } from '../../utils/classifications';
interface CorrelationIndicators {
subnet_ips_count: number;
asn_ips_count: number;
country_percentage: number;
ja4_shared_ips: number;
user_agents_count: number;
bot_ua_percentage: number;
}
interface ClassificationRecommendation {
label: 'legitimate' | 'suspicious' | 'malicious';
confidence: number;
indicators: CorrelationIndicators;
suggested_tags: string[];
reason: string;
}
interface CorrelationSummaryProps {
ip: string;
onClassify?: (label: string, tags: string[], comment: string, confidence: number) => void;
}
export function CorrelationSummary({ ip, onClassify }: CorrelationSummaryProps) {
const [data, setData] = useState<ClassificationRecommendation | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedLabel, setSelectedLabel] = useState<string>('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [comment, setComment] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
const fetchRecommendation = async () => {
setLoading(true);
try {
const response = await fetch(`/api/analysis/${encodeURIComponent(ip)}/recommendation`);
if (!response.ok) throw new Error('Erreur chargement recommandation');
const result = await response.json();
setData(result);
setSelectedLabel(result.label);
setSelectedTags(result.suggested_tags || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
fetchRecommendation();
}, [ip]);
const toggleTag = (tag: string) => {
setSelectedTags(prev =>
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
);
};
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch('/api/analysis/classifications', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ip,
label: selectedLabel,
tags: selectedTags,
comment,
confidence: data?.confidence || 0.5,
features: data?.indicators || {},
analyst: 'soc_user'
})
});
if (!response.ok) throw new Error('Erreur sauvegarde');
if (onClassify) {
onClassify(selectedLabel, selectedTags, comment, data?.confidence || 0.5);
}
alert('Classification sauvegardée !');
} catch (err) {
alert(`Erreur: ${err instanceof Error ? err.message : 'Erreur inconnue'}`);
} finally {
setSaving(false);
}
};
const handleExportML = async () => {
try {
const mlData = {
ip,
label: selectedLabel,
confidence: data?.confidence || 0.5,
tags: selectedTags,
features: {
subnet_ips_count: data?.indicators.subnet_ips_count || 0,
asn_ips_count: data?.indicators.asn_ips_count || 0,
country_percentage: data?.indicators.country_percentage || 0,
ja4_shared_ips: data?.indicators.ja4_shared_ips || 0,
user_agents_count: data?.indicators.user_agents_count || 0,
bot_ua_percentage: data?.indicators.bot_ua_percentage || 0,
},
comment,
analyst: 'soc_user',
timestamp: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(mlData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `classification_${ip}_${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
alert(`Erreur export: ${err instanceof Error ? err.message : 'Erreur inconnue'}`);
}
};
if (loading) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-text-secondary">Chargement...</div>
</div>
);
}
if (error || !data) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-threat-high">Erreur: {error || 'Données non disponibles'}</div>
</div>
);
}
return (
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">5. CORRELATION SUMMARY</h3>
{/* Indicateurs */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<IndicatorCard
label="IPs subnet"
value={data.indicators.subnet_ips_count}
alert={data.indicators.subnet_ips_count > 10}
/>
<IndicatorCard
label="IPs ASN"
value={data.indicators.asn_ips_count}
alert={data.indicators.asn_ips_count > 100}
/>
<IndicatorCard
label="JA4 partagés"
value={data.indicators.ja4_shared_ips}
alert={data.indicators.ja4_shared_ips > 50}
/>
<IndicatorCard
label="Bots UA"
value={`${data.indicators.bot_ua_percentage.toFixed(0)}%`}
alert={data.indicators.bot_ua_percentage > 20}
/>
<IndicatorCard
label="UAs différents"
value={data.indicators.user_agents_count}
alert={data.indicators.user_agents_count > 5}
/>
<IndicatorCard
label="Confiance"
value={`${(data.confidence * 100).toFixed(0)}%`}
alert={false}
/>
</div>
{/* Raison */}
{data.reason && (
<div className="bg-background-card rounded-lg p-4 mb-6">
<div className="text-sm text-text-secondary mb-2">Analyse</div>
<div className="text-text-primary">{data.reason}</div>
</div>
)}
{/* Classification */}
<div className="border-t border-background-card pt-6">
<h4 className="text-md font-medium text-text-primary mb-4">CLASSIFICATION</h4>
{/* Boutons de label */}
<div className="flex gap-3 mb-6">
<button
onClick={() => setSelectedLabel('legitimate')}
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-colors ${
selectedLabel === 'legitimate'
? 'bg-threat-low text-white'
: 'bg-background-card text-text-secondary hover:text-text-primary'
}`}
>
LÉGITIME
</button>
<button
onClick={() => setSelectedLabel('suspicious')}
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-colors ${
selectedLabel === 'suspicious'
? 'bg-threat-medium text-white'
: 'bg-background-card text-text-secondary hover:text-text-primary'
}`}
>
SUSPECT
</button>
<button
onClick={() => setSelectedLabel('malicious')}
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-colors ${
selectedLabel === 'malicious'
? 'bg-threat-high text-white'
: 'bg-background-card text-text-secondary hover:text-text-primary'
}`}
>
MALVEILLANT
</button>
</div>
{/* Tags */}
<div className="mb-6">
<div className="text-sm text-text-secondary mb-3">Tags</div>
<div className="flex flex-wrap gap-2">
{PREDEFINED_TAGS.map(tag => (
<button
key={tag}
onClick={() => toggleTag(tag)}
className={`px-3 py-1 rounded text-xs transition-colors ${
selectedTags.includes(tag)
? 'bg-accent-primary text-white'
: 'bg-background-card text-text-secondary hover:text-text-primary'
}`}
>
{tag}
</button>
))}
</div>
</div>
{/* Commentaire */}
<div className="mb-6">
<div className="text-sm text-text-secondary mb-2">Commentaire</div>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Notes d'analyse..."
className="w-full bg-background-card border border-background-card rounded-lg p-3 text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary"
rows={3}
/>
</div>
{/* Actions */}
<div className="flex gap-3">
<button
onClick={handleSave}
disabled={saving || !selectedLabel}
className="flex-1 bg-accent-primary hover:bg-accent-primary/80 disabled:opacity-50 disabled:cursor-not-allowed text-white py-3 px-4 rounded-lg font-medium transition-colors"
>
{saving ? 'Sauvegarde...' : '💾 Sauvegarder'}
</button>
<button
onClick={handleExportML}
className="flex-1 bg-background-card hover:bg-background-card/80 text-text-primary py-3 px-4 rounded-lg font-medium transition-colors"
>
📤 Export ML
</button>
</div>
</div>
</div>
);
}
function IndicatorCard({ label, value, alert }: { label: string; value: string | number; alert: boolean }) {
return (
<div className={`bg-background-card rounded-lg p-3 ${alert ? 'border-2 border-threat-high' : ''}`}>
<div className="text-xs text-text-secondary mb-1">{label}</div>
<div className={`text-xl font-bold ${alert ? 'text-threat-high' : 'text-text-primary'}`}>
{value}
</div>
</div>
);
}

View File

@ -1,186 +0,0 @@
import { useEffect, useState } from 'react';
import { InfoTip } from '../ui/Tooltip';
interface CountryData {
code: string;
name: string;
count: number;
percentage: number;
}
interface CountryAnalysisProps {
ip?: string; // Si fourni, affiche stats relatives à cette IP
asn?: string; // Si fourni, affiche stats relatives à cet ASN
}
interface CountryAnalysisData {
ip_country?: { code: string; name: string };
asn_countries: CountryData[];
}
export function CountryAnalysis({ ip, asn }: CountryAnalysisProps) {
const [data, setData] = useState<CountryAnalysisData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchCountryAnalysis = async () => {
setLoading(true);
try {
if (ip) {
// Mode Investigation IP: Récupérer le pays de l'IP + répartition ASN
const response = await fetch(`/api/analysis/${encodeURIComponent(ip)}/country`);
if (!response.ok) throw new Error('Erreur chargement pays');
const result = await response.json();
setData(result);
} else if (asn) {
// Mode Investigation ASN
const response = await fetch(`/api/analysis/asn/${encodeURIComponent(asn)}/country`);
if (!response.ok) throw new Error('Erreur chargement pays');
const result = await response.json();
setData(result);
} else {
// Mode Global (stats générales)
const response = await fetch('/api/analysis/country?days=1');
if (!response.ok) throw new Error('Erreur chargement pays');
const result = await response.json();
setData({
ip_country: undefined,
asn_countries: result.top_countries || []
});
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
fetchCountryAnalysis();
}, [ip, asn]);
if (loading) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-text-secondary">Chargement...</div>
</div>
);
}
if (error || !data) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-threat-high">Erreur: {error || 'Données non disponibles'}</div>
</div>
);
}
const getFlag = (code: string) => {
return code.toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
};
// Mode Investigation IP avec pays unique
if (ip && data.ip_country) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-text-primary">2. PAYS DE L'IP</h3>
<InfoTip content={
'Source : logs de détection internes (ClickHouse).\n' +
'Le pays est enregistré au moment de l\'ingestion des logs,\n' +
'via la base GeoIP du pipeline d\'enrichissement.\n\n' +
'Peut différer des sources de réputation externes\n' +
'(ip-api.com, ipinfo.io) pour les IPs anycast/CDN\n' +
'et les grands fournisseurs cloud (Microsoft, Google,\n' +
'Amazon) dont les plages IP sont routées vers plusieurs pays.'
} />
</div>
{/* Pays de l'IP */}
<div className="bg-background-card rounded-lg p-4 mb-6">
<div className="flex items-center gap-3 mb-2">
<span className="text-4xl">{getFlag(data.ip_country.code)}</span>
<div>
<div className="text-text-primary font-bold text-lg">
{data.ip_country.name} ({data.ip_country.code})
</div>
<div className="text-text-secondary text-sm">Pays de l'IP</div>
</div>
</div>
</div>
{/* Répartition ASN par pays */}
{data.asn_countries.length > 0 && (
<div>
<div className="text-sm text-text-secondary mb-3">
Autres pays du même ASN (24h)
</div>
<div className="space-y-2">
{data.asn_countries.slice(0, 5).map((country, idx) => (
<div key={idx} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xl">{getFlag(country.code)}</span>
<span className="text-text-primary text-sm">{country.name}</span>
</div>
<div className="text-right">
<div className="text-text-primary font-bold text-sm">{country.count}</div>
<div className="text-text-secondary text-xs">{country.percentage.toFixed(1)}%</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
// Mode Global ou ASN
const getThreatColor = (percentage: number, baseline: number) => {
if (baseline > 0 && percentage > baseline * 2) return 'bg-threat-high';
if (percentage > 30) return 'bg-threat-medium';
return 'bg-accent-primary';
};
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-text-primary">
{asn ? '2. TOP Pays (ASN)' : '2. TOP Pays (Global)'}
</h3>
</div>
<div className="space-y-3">
{data.asn_countries.map((country, idx) => {
const baselinePct = 0; // Pas de baseline en mode ASN
return (
<div key={idx} className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-2xl">{getFlag(country.code)}</span>
<div>
<div className="text-text-primary font-medium text-sm">
{country.name} ({country.code})
</div>
</div>
</div>
<div className="text-right">
<div className="text-text-primary font-bold">{country.count}</div>
<div className="text-text-secondary text-xs">{country.percentage.toFixed(1)}%</div>
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${getThreatColor(country.percentage, baselinePct)}`}
style={{ width: `${Math.min(country.percentage, 100)}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -1,142 +0,0 @@
import { useEffect, useState } from 'react';
interface JA4SubnetData {
subnet: string;
count: number;
}
interface JA4Analysis {
ja4: string;
shared_ips_count: number;
top_subnets: JA4SubnetData[];
other_ja4_for_ip: string[];
}
interface JA4AnalysisProps {
ip: string;
}
export function JA4Analysis({ ip }: JA4AnalysisProps) {
const [data, setData] = useState<JA4Analysis | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchJA4Analysis = async () => {
setLoading(true);
try {
const response = await fetch(`/api/analysis/${encodeURIComponent(ip)}/ja4`);
if (!response.ok) throw new Error('Erreur chargement JA4');
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
fetchJA4Analysis();
}, [ip]);
if (loading) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-text-secondary">Chargement...</div>
</div>
);
}
if (error || !data || !data.ja4) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-text-secondary">JA4 non disponible</div>
</div>
);
}
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-text-primary">3. JA4 FINGERPRINT ANALYSIS</h3>
{data.shared_ips_count > 50 && (
<span className="bg-threat-high text-white px-3 py-1 rounded text-xs font-medium">
🔴 {data.shared_ips_count} IPs
</span>
)}
</div>
<div className="space-y-6">
{/* JA4 Fingerprint */}
<div>
<div className="text-sm text-text-secondary mb-2">JA4 Fingerprint</div>
<div className="bg-background-card rounded-lg p-3 font-mono text-sm text-text-primary break-all">
{data.ja4}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* IPs avec même JA4 */}
<div>
<div className="text-sm text-text-secondary mb-2">
IPs avec le MÊME JA4 (24h)
</div>
<div className="text-3xl font-bold text-text-primary mb-2">
{data.shared_ips_count}
</div>
{data.shared_ips_count > 50 && (
<div className="text-threat-high text-sm">
🔴 PATTERN: Même outil/bot sur {data.shared_ips_count} IPs
</div>
)}
</div>
{/* Autres JA4 pour cette IP */}
<div>
<div className="text-sm text-text-secondary mb-2">
Autres JA4 pour cette IP
</div>
{data.other_ja4_for_ip.length > 0 ? (
<div className="space-y-1">
{data.other_ja4_for_ip.slice(0, 3).map((ja4, idx) => (
<div key={idx} className="bg-background-card rounded p-2 font-mono text-xs text-text-primary truncate">
{ja4}
</div>
))}
{data.other_ja4_for_ip.length > 3 && (
<div className="text-text-secondary text-xs">
+{data.other_ja4_for_ip.length - 3} autres
</div>
)}
</div>
) : (
<div className="text-text-secondary text-sm">
1 seul JA4 Comportement stable
</div>
)}
</div>
</div>
{/* Top subnets */}
{data.top_subnets.length > 0 && (
<div>
<div className="text-sm text-text-secondary mb-2">
Top subnets pour ce JA4
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{data.top_subnets.map((subnet, idx) => (
<div
key={idx}
className="bg-background-card rounded-lg p-3 flex items-center justify-between"
>
<div className="font-mono text-sm text-text-primary">{subnet.subnet}</div>
<div className="text-text-primary font-bold">{subnet.count} IPs</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -1,348 +0,0 @@
import { useEffect, useState } from 'react';
import { PREDEFINED_TAGS_JA4 } from '../../utils/classifications';
interface CorrelationIndicators {
subnet_ips_count: number;
asn_ips_count: number;
country_percentage: number;
ja4_shared_ips: number;
user_agents_count: number;
bot_ua_percentage: number;
}
interface JA4ClassificationRecommendation {
label: 'legitimate' | 'suspicious' | 'malicious';
confidence: number;
indicators: CorrelationIndicators;
suggested_tags: string[];
reason: string;
}
interface JA4CorrelationSummaryProps {
ja4: string;
onClassify?: (label: string, tags: string[], comment: string, confidence: number) => void;
}
export function JA4CorrelationSummary({ ja4, onClassify }: JA4CorrelationSummaryProps) {
const [data, setData] = useState<JA4ClassificationRecommendation | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedLabel, setSelectedLabel] = useState<string>('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [comment, setComment] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
const fetchRecommendation = async () => {
setLoading(true);
try {
// Récupérer les IPs associées
const ipsResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4)}/ips?limit=100`);
const ipsData = await ipsResponse.json();
// Récupérer les user-agents
const uaResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4)}/user_agents?limit=100`);
const uaData = await uaResponse.json();
// Calculer les indicateurs
const indicators: CorrelationIndicators = {
subnet_ips_count: 0,
asn_ips_count: ipsData.total || 0,
country_percentage: 0,
ja4_shared_ips: ipsData.total || 0,
user_agents_count: uaData.user_agents?.length || 0,
bot_ua_percentage: 0
};
// Calculer le pourcentage de bots
if (uaData.user_agents?.length > 0) {
const botCount = uaData.user_agents
.filter((ua: any) => ua.classification === 'bot' || ua.classification === 'script')
.reduce((sum: number, ua: any) => sum + ua.count, 0);
const totalCount = uaData.user_agents.reduce((sum: number, ua: any) => sum + ua.count, 0);
indicators.bot_ua_percentage = totalCount > 0 ? (botCount / totalCount * 100) : 0;
}
// Score de confiance
let score = 0.0;
const reasons: string[] = [];
const tags: string[] = [];
// JA4 partagé > 50 IPs
if (indicators.ja4_shared_ips > 50) {
score += 0.30;
reasons.push(`${indicators.ja4_shared_ips} IPs avec même JA4`);
tags.push('ja4-rotation');
}
// Bot UA > 20%
if (indicators.bot_ua_percentage > 20) {
score += 0.25;
reasons.push(`${indicators.bot_ua_percentage.toFixed(0)}% UAs bots/scripts`);
tags.push('bot-ua');
}
// Multiple UAs
if (indicators.user_agents_count > 5) {
score += 0.15;
reasons.push(`${indicators.user_agents_count} UAs différents`);
tags.push('ua-rotation');
}
// Déterminer label
if (score >= 0.7) {
score = Math.min(score, 1.0);
tags.push('known-bot');
} else if (score >= 0.4) {
score = Math.min(score, 1.0);
}
const reason = reasons.join(' | ') || 'Aucun indicateur fort';
setData({
label: score >= 0.7 ? 'malicious' : score >= 0.4 ? 'suspicious' : 'legitimate',
confidence: score,
indicators,
suggested_tags: tags,
reason
});
setSelectedLabel(score >= 0.7 ? 'malicious' : score >= 0.4 ? 'suspicious' : 'legitimate');
setSelectedTags(tags);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
if (ja4) {
fetchRecommendation();
}
}, [ja4]);
const toggleTag = (tag: string) => {
setSelectedTags(prev =>
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
);
};
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch('/api/analysis/classifications', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ja4,
label: selectedLabel,
tags: selectedTags,
comment,
confidence: data?.confidence || 0.5,
features: data?.indicators || {},
analyst: 'soc_user'
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Erreur sauvegarde');
}
if (onClassify) {
onClassify(selectedLabel, selectedTags, comment, data?.confidence || 0.5);
}
alert('Classification JA4 sauvegardée !');
} catch (err) {
alert(`Erreur: ${err instanceof Error ? err.message : 'Erreur inconnue'}`);
} finally {
setSaving(false);
}
};
const handleExportML = async () => {
try {
const mlData = {
ja4,
label: selectedLabel,
confidence: data?.confidence || 0.5,
tags: selectedTags,
features: {
ja4_shared_ips: data?.indicators.ja4_shared_ips || 0,
user_agents_count: data?.indicators.user_agents_count || 0,
bot_ua_percentage: data?.indicators.bot_ua_percentage || 0,
},
comment,
analyst: 'soc_user',
timestamp: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(mlData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `classification_ja4_${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
alert(`Erreur export: ${err instanceof Error ? err.message : 'Erreur inconnue'}`);
}
};
if (loading) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-text-secondary">Chargement...</div>
</div>
);
}
if (error || !data) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-threat-high">Erreur: {error || 'Données non disponibles'}</div>
</div>
);
}
return (
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">5. CORRELATION SUMMARY</h3>
{/* Indicateurs */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<IndicatorCard
label="IPs partagées"
value={data.indicators.ja4_shared_ips}
alert={data.indicators.ja4_shared_ips > 50}
/>
<IndicatorCard
label="UAs différents"
value={data.indicators.user_agents_count}
alert={data.indicators.user_agents_count > 5}
/>
<IndicatorCard
label="Bots UA"
value={`${data.indicators.bot_ua_percentage.toFixed(0)}%`}
alert={data.indicators.bot_ua_percentage > 20}
/>
<IndicatorCard
label="Confiance"
value={`${(data.confidence * 100).toFixed(0)}%`}
alert={false}
/>
</div>
{/* Raison */}
{data.reason && (
<div className="bg-background-card rounded-lg p-4 mb-6">
<div className="text-sm text-text-secondary mb-2">Analyse</div>
<div className="text-text-primary">{data.reason}</div>
</div>
)}
{/* Classification */}
<div className="border-t border-background-card pt-6">
<h4 className="text-md font-medium text-text-primary mb-4">CLASSIFICATION</h4>
{/* Boutons de label */}
<div className="flex gap-3 mb-6">
<button
onClick={() => setSelectedLabel('legitimate')}
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-colors ${
selectedLabel === 'legitimate'
? 'bg-threat-low text-white'
: 'bg-background-card text-text-secondary hover:text-text-primary'
}`}
>
LÉGITIME
</button>
<button
onClick={() => setSelectedLabel('suspicious')}
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-colors ${
selectedLabel === 'suspicious'
? 'bg-threat-medium text-white'
: 'bg-background-card text-text-secondary hover:text-text-primary'
}`}
>
SUSPECT
</button>
<button
onClick={() => setSelectedLabel('malicious')}
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-colors ${
selectedLabel === 'malicious'
? 'bg-threat-high text-white'
: 'bg-background-card text-text-secondary hover:text-text-primary'
}`}
>
MALVEILLANT
</button>
</div>
{/* Tags */}
<div className="mb-6">
<div className="text-sm text-text-secondary mb-3">Tags</div>
<div className="flex flex-wrap gap-2">
{PREDEFINED_TAGS_JA4.map(tag => (
<button
key={tag}
onClick={() => toggleTag(tag)}
className={`px-3 py-1 rounded text-xs transition-colors ${
selectedTags.includes(tag)
? 'bg-accent-primary text-white'
: 'bg-background-card text-text-secondary hover:text-text-primary'
}`}
>
{tag}
</button>
))}
</div>
</div>
{/* Commentaire */}
<div className="mb-6">
<div className="text-sm text-text-secondary mb-2">Commentaire</div>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Notes d'analyse..."
className="w-full bg-background-card border border-background-card rounded-lg p-3 text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary"
rows={3}
/>
</div>
{/* Actions */}
<div className="flex gap-3">
<button
onClick={handleSave}
disabled={saving || !selectedLabel}
className="flex-1 bg-accent-primary hover:bg-accent-primary/80 disabled:opacity-50 disabled:cursor-not-allowed text-white py-3 px-4 rounded-lg font-medium transition-colors"
>
{saving ? 'Sauvegarde...' : '💾 Sauvegarder'}
</button>
<button
onClick={handleExportML}
className="flex-1 bg-background-card hover:bg-background-card/80 text-text-primary py-3 px-4 rounded-lg font-medium transition-colors"
>
📤 Export ML
</button>
</div>
</div>
</div>
);
}
function IndicatorCard({ label, value, alert }: { label: string; value: string | number; alert: boolean }) {
return (
<div className={`bg-background-card rounded-lg p-3 ${alert ? 'border-2 border-threat-high' : ''}`}>
<div className="text-xs text-text-secondary mb-1">{label}</div>
<div className={`text-xl font-bold ${alert ? 'text-threat-high' : 'text-text-primary'}`}>
{value}
</div>
</div>
);
}

View File

@ -1,120 +0,0 @@
import { useEffect, useState } from 'react';
interface SubnetAnalysisData {
ip: string;
subnet: string;
ips_in_subnet: string[];
total_in_subnet: number;
asn_number: string;
asn_org: string;
total_in_asn: number;
alert: boolean;
}
interface SubnetAnalysisProps {
ip: string;
}
export function SubnetAnalysis({ ip }: SubnetAnalysisProps) {
const [data, setData] = useState<SubnetAnalysisData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchSubnetAnalysis = async () => {
setLoading(true);
try {
const response = await fetch(`/api/analysis/${encodeURIComponent(ip)}/subnet`);
if (!response.ok) throw new Error('Erreur chargement subnet');
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
fetchSubnetAnalysis();
}, [ip]);
if (loading) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-text-secondary">Chargement...</div>
</div>
);
}
if (error || !data) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-threat-high">Erreur: {error || 'Données non disponibles'}</div>
</div>
);
}
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-text-primary">1. SUBNET / ASN ANALYSIS</h3>
{data.alert && (
<span className="bg-threat-high text-white px-3 py-1 rounded text-xs font-medium">
{data.total_in_subnet} IPs du subnet
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Subnet */}
<div>
<div className="text-sm text-text-secondary mb-2">Subnet (/24)</div>
<div className="text-text-primary font-mono text-sm">{data.subnet}</div>
<div className="mt-4">
<div className="text-sm text-text-secondary mb-2">
IPs du même subnet ({data.total_in_subnet})
</div>
<div className="flex flex-wrap gap-1">
{data.ips_in_subnet.slice(0, 15).map((ipAddr: string, idx: number) => (
<span
key={idx}
className="bg-background-card px-2 py-1 rounded text-xs font-mono text-text-primary"
>
{ipAddr.split('.').slice(0, 3).join('.')}.{ipAddr.split('.')[3]}
</span>
))}
{data.ips_in_subnet.length > 15 && (
<span className="bg-background-card px-2 py-1 rounded text-xs text-text-secondary">
+{data.ips_in_subnet.length - 15} autres
</span>
)}
</div>
</div>
</div>
{/* ASN */}
<div>
<div className="text-sm text-text-secondary mb-2">ASN</div>
<div className="text-text-primary font-medium">{data.asn_org || 'Unknown'}</div>
<div className="text-sm text-text-secondary font-mono">AS{data.asn_number}</div>
<div className="mt-4">
<div className="text-sm text-text-secondary mb-2">
Total IPs dans l'ASN (24h)
</div>
<div className="text-2xl font-bold text-text-primary">{data.total_in_asn}</div>
</div>
</div>
</div>
{data.alert && (
<div className="mt-4 bg-threat-high/10 border border-threat-high rounded-lg p-3">
<div className="text-threat-high text-sm font-medium">
🔴 PATTERN: {data.total_in_subnet} IPs du même subnet en 24h
</div>
</div>
)}
</div>
);
}

View File

@ -1,185 +0,0 @@
import { useEffect, useState } from 'react';
interface UserAgentData {
value: string;
count: number;
percentage: number;
classification: 'normal' | 'bot' | 'script';
}
interface UserAgentAnalysis {
ip_user_agents: UserAgentData[];
ja4_user_agents: UserAgentData[];
bot_percentage: number;
alert: boolean;
}
interface UserAgentAnalysisProps {
ip: string;
}
export function UserAgentAnalysis({ ip }: UserAgentAnalysisProps) {
const [data, setData] = useState<UserAgentAnalysis | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showAllIpUA, setShowAllIpUA] = useState(false);
const [showAllJa4UA, setShowAllJa4UA] = useState(false);
useEffect(() => {
const fetchUserAgentAnalysis = async () => {
setLoading(true);
try {
const response = await fetch(`/api/analysis/${encodeURIComponent(ip)}/user-agents`);
if (!response.ok) throw new Error('Erreur chargement User-Agents');
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
fetchUserAgentAnalysis();
}, [ip]);
if (loading) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-text-secondary">Chargement...</div>
</div>
);
}
if (error || !data) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-text-secondary">User-Agents non disponibles</div>
</div>
);
}
const getClassificationBadge = (classification: string) => {
switch (classification) {
case 'normal':
return <span className="bg-threat-low/20 text-threat-low px-2 py-0.5 rounded text-xs whitespace-nowrap"> Normal</span>;
case 'bot':
return <span className="bg-threat-medium/20 text-threat-medium px-2 py-0.5 rounded text-xs whitespace-nowrap"> Bot</span>;
case 'script':
return <span className="bg-threat-high/20 text-threat-high px-2 py-0.5 rounded text-xs whitespace-nowrap"> Script</span>;
default:
return null;
}
};
const INITIAL_COUNT = 5;
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-text-primary">4. USER-AGENT ANALYSIS</h3>
{data.alert && (
<span className="bg-threat-high text-white px-3 py-1 rounded text-xs font-medium">
{data.bot_percentage.toFixed(0)}% bots/scripts
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* User-Agents pour cette IP */}
<div>
<div className="text-sm text-text-secondary mb-3">
User-Agents pour cette IP ({data.ip_user_agents.length})
</div>
<div className="space-y-2">
{(showAllIpUA ? data.ip_user_agents : data.ip_user_agents.slice(0, INITIAL_COUNT)).map((ua, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="text-text-primary text-xs font-mono break-all flex-1 leading-relaxed">
{ua.value}
</div>
{getClassificationBadge(ua.classification)}
</div>
<div className="flex items-center gap-2">
<div className="text-text-secondary text-xs">{ua.count} requêtes</div>
<div className="text-text-secondary text-xs">{ua.percentage.toFixed(1)}%</div>
</div>
</div>
))}
{data.ip_user_agents.length === 0 && (
<div className="text-text-secondary text-sm">Aucun User-Agent trouvé</div>
)}
</div>
{data.ip_user_agents.length > INITIAL_COUNT && (
<button
onClick={() => setShowAllIpUA(v => !v)}
className="mt-3 w-full text-xs text-accent-primary hover:text-accent-primary/80 transition-colors"
>
{showAllIpUA
? '↑ Réduire'
: `↓ Voir les ${data.ip_user_agents.length - INITIAL_COUNT} autres`}
</button>
)}
</div>
{/* User-Agents pour le JA4 */}
<div>
<div className="text-sm text-text-secondary mb-3">
User-Agents pour le JA4 (toutes IPs)
</div>
<div className="space-y-2">
{(showAllJa4UA ? data.ja4_user_agents : data.ja4_user_agents.slice(0, INITIAL_COUNT)).map((ua, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="text-text-primary text-xs font-mono break-all flex-1 leading-relaxed">
{ua.value}
</div>
{getClassificationBadge(ua.classification)}
</div>
<div className="flex items-center gap-2">
<div className="text-text-secondary text-xs">{ua.count} IPs</div>
<div className="text-text-secondary text-xs">{ua.percentage.toFixed(1)}%</div>
</div>
</div>
))}
</div>
{data.ja4_user_agents.length > INITIAL_COUNT && (
<button
onClick={() => setShowAllJa4UA(v => !v)}
className="mt-3 w-full text-xs text-accent-primary hover:text-accent-primary/80 transition-colors"
>
{showAllJa4UA
? '↑ Réduire'
: `↓ Voir les ${data.ja4_user_agents.length - INITIAL_COUNT} autres`}
</button>
)}
</div>
</div>
{/* Stats bots */}
<div className="mt-6">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-text-secondary">Pourcentage de bots/scripts</div>
<div className={`text-lg font-bold ${data.bot_percentage > 20 ? 'text-threat-high' : 'text-text-primary'}`}>
{data.bot_percentage.toFixed(1)}%
</div>
</div>
<div className="w-full bg-background-card rounded-full h-3">
<div
className={`h-3 rounded-full transition-all ${
data.bot_percentage > 50 ? 'bg-threat-high' :
data.bot_percentage > 20 ? 'bg-threat-medium' :
'bg-threat-low'
}`}
style={{ width: `${Math.min(data.bot_percentage, 100)}%` }}
/>
</div>
{data.bot_percentage > 20 && (
<div className="mt-2 text-threat-high text-sm">
ALERT: {data.bot_percentage.toFixed(0)}% d'UAs bots/scripts
</div>
)}
</div>
</div>
);
}

View File

@ -1,26 +0,0 @@
import React from 'react';
interface CardProps {
title?: string;
actions?: React.ReactNode;
children: React.ReactNode;
className?: string;
}
export default function Card({ title, actions, children, className = '' }: CardProps) {
return (
<div
className={`bg-background-secondary border border-background-card rounded-lg overflow-hidden ${className}`}
>
{(title || actions) && (
<div className="flex items-center justify-between px-4 py-2.5 border-b border-background-card">
{title && (
<h3 className="text-sm font-semibold text-text-primary">{title}</h3>
)}
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
)}
<div className="p-0">{children}</div>
</div>
);
}

View File

@ -1,165 +0,0 @@
import React from 'react';
import { useSort, SortDir } from '../../hooks/useSort';
import { InfoTip } from './Tooltip';
export interface Column<T> {
key: string;
label: React.ReactNode;
tooltip?: string;
sortable?: boolean;
align?: 'left' | 'right' | 'center';
width?: string;
render?: (value: any, row: T) => React.ReactNode;
className?: string;
}
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
defaultSortKey?: string;
defaultSortDir?: SortDir;
onRowClick?: (row: T) => void;
onSort?: (key: string, dir: SortDir) => void;
rowKey: keyof T | ((row: T) => string);
emptyMessage?: string;
loading?: boolean;
className?: string;
compact?: boolean;
maxHeight?: string;
}
export default function DataTable<T extends Record<string, any>>({
data,
columns,
defaultSortKey,
defaultSortDir = 'desc',
onRowClick,
onSort,
rowKey,
emptyMessage = 'Aucune donnée disponible',
loading = false,
className = '',
compact = false,
maxHeight,
}: DataTableProps<T>) {
const firstSortableKey =
defaultSortKey ||
columns.find((c) => c.sortable !== false)?.key ||
columns[0]?.key ||
'id';
const { sorted, sortKey, sortDir, handleSort } = useSort<T>(
data,
firstSortableKey as keyof T,
defaultSortDir
);
const cell = compact ? 'px-3 py-1.5' : 'px-4 py-2.5';
const getRowKey = (row: T): string => {
if (typeof rowKey === 'function') return rowKey(row);
return String(row[rowKey as keyof T] ?? '');
};
const alignClass = (align?: 'left' | 'right' | 'center') => {
if (align === 'right') return 'text-right';
if (align === 'center') return 'text-center';
return 'text-left';
};
return (
<div className={`${maxHeight ? `${maxHeight} overflow-y-auto` : ''} ${className}`}>
<table className="w-full">
<thead style={{ position: 'sticky', top: 0, zIndex: 10 }}>
<tr>
{columns.map((col) => {
const isSortable = col.sortable !== false;
const isActive = String(sortKey) === col.key;
return (
<th
key={col.key}
className={[
cell,
'text-xs font-semibold text-text-disabled uppercase tracking-wider',
'bg-background-secondary border-b border-background-card',
col.width ?? '',
alignClass(col.align),
isSortable ? 'cursor-pointer hover:text-text-primary select-none' : '',
].join(' ')}
onClick={isSortable ? () => {
handleSort(col.key as keyof T);
if (onSort) {
const newDir = String(sortKey) === col.key
? (sortDir === 'asc' ? 'desc' : 'asc')
: 'desc';
onSort(col.key, newDir);
}
} : undefined}
>
<span className="inline-flex items-center gap-1">
{col.label}
{col.tooltip && <InfoTip content={col.tooltip} />}
{isSortable &&
(isActive ? (
<span className="text-accent-primary">
{sortDir === 'desc' ? '↓' : '↑'}
</span>
) : (
<span className="text-text-disabled opacity-50"></span>
))}
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{columns.map((col) => (
<td key={col.key} className={`${cell} border-b border-background-card`}>
<div className="bg-background-card/50 rounded animate-pulse h-4" />
</td>
))}
</tr>
))
) : sorted.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className="text-center py-8 text-text-disabled text-sm"
>
{emptyMessage}
</td>
</tr>
) : (
sorted.map((row) => (
<tr
key={getRowKey(row)}
className={[
'border-b border-background-card transition-colors',
'hover:bg-background-card/50',
onRowClick ? 'cursor-pointer' : '',
].join(' ')}
onClick={onRowClick ? () => onRowClick(row) : undefined}
>
{columns.map((col) => {
const value = row[col.key as keyof T];
return (
<td
key={col.key}
className={[cell, alignClass(col.align), col.className ?? ''].join(' ')}
>
{col.render ? col.render(value, row) : (value as React.ReactNode)}
</td>
);
})}
</tr>
))
)}
</tbody>
</table>
</div>
);
}

View File

@ -1,26 +0,0 @@
/**
* Composants UI réutilisables pour les états de chargement et d'erreur.
* Utiliser ces composants plutôt que de re-déclarer des versions locales.
*/
/** Spinner centré — affiché pendant le chargement d'une section. */
export function LoadingSpinner() {
return (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
</div>
);
}
interface ErrorMessageProps {
message: string;
}
/** Bandeau d'erreur rouge — affiché quand une requête échoue. */
export function ErrorMessage({ message }: ErrorMessageProps) {
return (
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical">
{message}
</div>
);
}

View File

@ -1,35 +0,0 @@
import React from 'react';
type Color = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | 'slate';
const COLOR_MAP: Record<Color, string> = {
red: 'text-red-400',
orange: 'text-orange-400',
yellow: 'text-yellow-400',
green: 'text-green-400',
blue: 'text-blue-400',
purple: 'text-purple-400',
slate: 'text-slate-400',
};
interface StatCardProps {
label: string;
value: string | number;
sub?: string;
color?: Color;
icon?: React.ReactNode;
}
export default function StatCard({ label, value, sub, color, icon }: StatCardProps) {
const valueClass = color ? COLOR_MAP[color] : 'text-text-primary';
return (
<div className="flex flex-col gap-0.5">
<div className="text-xs text-text-disabled uppercase tracking-wider flex items-center gap-1">
{icon && <span>{icon}</span>}
{label}
</div>
<div className={`text-xl font-bold ${valueClass}`}>{value}</div>
{sub && <div className="text-xs text-text-secondary mt-0.5">{sub}</div>}
</div>
);
}

View File

@ -1,24 +0,0 @@
type ThreatLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'NORMAL' | 'KNOWN_BOT';
const BADGE_STYLES: Record<ThreatLevel, string> = {
CRITICAL: 'bg-red-900/50 text-red-400 border border-red-800/50',
HIGH: 'bg-orange-900/50 text-orange-400 border border-orange-800/50',
MEDIUM: 'bg-yellow-900/50 text-yellow-400 border border-yellow-800/50',
LOW: 'bg-green-900/50 text-green-400 border border-green-800/50',
NORMAL: 'bg-slate-700/50 text-slate-400 border border-slate-600/50',
KNOWN_BOT: 'bg-purple-900/50 text-purple-400 border border-purple-800/50',
};
interface ThreatBadgeProps {
level: string;
}
export default function ThreatBadge({ level }: ThreatBadgeProps) {
const key = (level?.toUpperCase() ?? 'NORMAL') as ThreatLevel;
const cls = BADGE_STYLES[key] ?? BADGE_STYLES.NORMAL;
return (
<span className={`text-xs px-1.5 py-0.5 rounded font-medium uppercase ${cls}`}>
{level || 'NORMAL'}
</span>
);
}

View File

@ -1,145 +0,0 @@
/**
* Tooltip — composant universel de survol
*
* Rendu via createPortal dans document.body pour éviter tout clipping
* par les conteneurs overflow:hidden / overflow-y:auto.
*
* Usage :
* <Tooltip content="Explication…"><span>label</span></Tooltip>
* <InfoTip content="Explication…" /> ← ajoute un ⓘ cliquable
*/
import { useState, useRef, useCallback, useEffect } from 'react';
import { createPortal } from 'react-dom';
interface TooltipProps {
content: string | React.ReactNode;
children: React.ReactNode;
className?: string;
delay?: number;
maxWidth?: number;
}
interface TooltipPos {
x: number;
y: number;
}
export function Tooltip({ content, children, className = '', delay = 250, maxWidth = 300 }: TooltipProps) {
const [pos, setPos] = useState<TooltipPos | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const spanRef = useRef<HTMLSpanElement>(null);
const show = useCallback(
(e: React.MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
// Position au-dessus du centre de l'élément, ajustée si trop haut
const x = Math.round(rect.left + rect.width / 2);
const y = Math.round(rect.top);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setPos({ x, y }), delay);
},
[delay],
);
const hide = useCallback(() => {
if (timer.current) clearTimeout(timer.current);
setPos(null);
}, []);
// Nettoyage si le composant est démonté pendant le délai
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
if (!content) return <>{children}</>;
return (
<>
<span
ref={spanRef}
className={`inline-flex items-center ${className}`}
onMouseEnter={show}
onMouseLeave={hide}
>
{children}
</span>
{pos &&
createPortal(
<TooltipBubble x={pos.x} y={pos.y} maxWidth={maxWidth}>
{content}
</TooltipBubble>,
document.body,
)}
</>
);
}
/** Bulle de tooltip positionnée en fixed par rapport au viewport */
function TooltipBubble({
x,
y,
maxWidth,
children,
}: {
x: number;
y: number;
maxWidth: number;
children: React.ReactNode;
}) {
const bubbleRef = useRef<HTMLDivElement>(null);
const [adjust, setAdjust] = useState({ dx: 0, dy: 0 });
// Ajustement viewport pour éviter les débordements
useEffect(() => {
if (!bubbleRef.current) return;
const el = bubbleRef.current;
const rect = el.getBoundingClientRect();
let dx = 0;
let dy = 0;
if (rect.left < 8) dx = 8 - rect.left;
if (rect.right > window.innerWidth - 8) dx = window.innerWidth - 8 - rect.right;
if (rect.top < 8) dy = 16; // bascule en dessous si trop haut
setAdjust({ dx, dy });
}, [x, y]);
return (
<div
ref={bubbleRef}
className="fixed z-[9999] pointer-events-none"
style={{
left: x + adjust.dx,
top: y + adjust.dy - 8,
transform: 'translate(-50%, -100%)',
}}
>
<div
className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-xs text-slate-100 shadow-2xl leading-relaxed whitespace-pre-line text-left"
style={{ maxWidth }}
>
{children}
</div>
{/* Flèche vers le bas */}
<div className="w-0 h-0 mx-auto border-[5px] border-transparent border-t-slate-600" />
</div>
);
}
/**
* InfoTip — icône ⓘ avec tooltip intégré.
* S'insère après un label pour donner une explication au survol.
*/
export function InfoTip({
content,
className = '',
}: {
content: string | React.ReactNode;
className?: string;
}) {
return (
<Tooltip content={content} className={className}>
<span className="ml-1 text-[10px] text-slate-500 cursor-help select-none hover:text-slate-300 transition-colors leading-none">
</span>
</Tooltip>
);
}

View File

@ -1,415 +0,0 @@
/**
* tooltips.ts — Textes d'aide pour tous les termes techniques du dashboard.
*
* Toutes les chaînes sont en français, multi-lignes via \n.
* Utilisé avec <InfoTip content={TIPS.xxx} /> ou <Tooltip content={TIPS.xxx}>.
*/
export const TIPS = {
// ── Clustering ──────────────────────────────────────────────────────────────
sensitivity:
'Contrôle la granularité du clustering.\n' +
'· Grossière → grands groupes comportementaux\n' +
'· Fine → distinction fine entre sous-comportements\n' +
'· Extrême → jusqu\'à k × 5 clusters, calcul long',
k_base:
'Nombre de clusters de base (k).\n' +
'Clusters effectifs = k × sensibilité (limité à 300).\n' +
'Augmenter k permet plus de nuance dans la classification.',
k_actual:
'Nombre réel de clusters calculés = k × sensibilité.\n' +
'Exemple : k=20 × sensibilité=3 = 60 clusters effectifs.\n' +
'Limité à 300 pour rester calculable.',
show_edges:
'Affiche les relations de similarité entre clusters proches.\n' +
'Seules les paires au-dessus d\'un seuil de similarité sont reliées.\n' +
'Utile pour identifier des groupes comportementaux liés.',
ips_bots:
'IPs avec score de risque ML > 70 %.\n' +
'Présentent les signaux les plus forts de comportement automatisé.\n' +
'Action immédiate recommandée.',
high_risk:
'IPs avec score de risque entre 45 % et 70 %.\n' +
'Activité suspecte nécessitant une analyse approfondie.',
total_hits:
'Nombre total de requêtes HTTP de toutes les IPs\n' +
'dans la fenêtre d\'analyse sélectionnée.',
calc_time:
'Durée du calcul K-means++ sur l\'ensemble des IPs.\n' +
'Augmente avec k × sensibilité et le nombre d\'IPs.',
pca_2d:
'Projection PCA (Analyse en Composantes Principales).\n' +
'31 features → 2 dimensions pour la visualisation.\n' +
'Les clusters proches ont des comportements similaires.',
features_31:
'31 métriques utilisées pour le clustering :\n' +
'· TCP : TTL, MSS, fenêtre de congestion\n' +
'· ML : score de détection bot\n' +
'· TLS/JA4 : fingerprint client\n' +
'· User-Agent, OS, headless, UA-CH\n' +
'· Pays, ASN (cloud/datacenter)\n' +
'· Headers HTTP : Accept-Language, Encoding, Sec-Fetch\n' +
'· Fingerprint headers : popularité, rotation, cookie, referer',
// ── Légende risque ──────────────────────────────────────────────────────────
risk_critical:
'CRITICAL — Score > 70 %\nBot très probable. Action immédiate recommandée.',
risk_high:
'HIGH — Score 4570 %\nActivité suspecte. Investigation recommandée.',
risk_medium:
'MEDIUM — Score 2545 %\nComportement anormal. Surveillance renforcée.',
risk_low:
'LOW — Score < 25 %\nTrafic probablement légitime.',
// ── Sidebar cluster ─────────────────────────────────────────────────────────
risk_score:
'Score composite [0100 %] calculé à partir de 14 sous-scores pondérés :\n' +
'· ML score (25 %) · Fuzzing (9 %)\n' +
'· UA-CH mismatch (7 %) · Headless (6 %)\n' +
'· Pays risqué (9 %) · ASN cloud (6 %)\n' +
'· Headers HTTP (12 %) · Fingerprint (12 %)\n' +
'· Vélocité (5 %) · IP ID zéro (5 %)',
radar_profile:
'Radar comportemental sur les features principales.\n' +
'Chaque axe = un sous-score normalisé entre 0 et 1.\n' +
'Survolez un point pour voir la valeur exacte.',
mean_ttl:
'Time-To-Live TCP moyen du cluster.\n' +
'· Linux ≈ 64 · Windows ≈ 128 · Cisco ≈ 255\n' +
'Différence TTL_initial TTL_observé = nombre de sauts réseau.',
mean_mss:
'Maximum Segment Size TCP moyen.\n' +
'· Ethernet = 1460 B · PPPoE ≈ 1452 B\n' +
'· VPN ≈ 13801420 B · Bas débit < 1380 B\n' +
'MSS anormalement bas → probable tunnel ou VPN.',
mean_score:
'Score moyen du modèle ML de détection de bots.\n' +
'0 % = trafic légitime · 100 % = bot confirmé',
mean_velocity:
'Nombre moyen de requêtes par seconde (rps).\n' +
'Taux élevé → outil automatisé ou attaque volumétrique.',
mean_headless:
'Proportion d\'IPs utilisant un navigateur headless.\n' +
'(Puppeteer, Playwright, PhantomJS, Chromium sans UI…)\n' +
'Les bots utilisent fréquemment des navigateurs sans interface.',
mean_ua_ch:
'User-Agent / Client Hints mismatch.\n' +
'Le UA déclaré (ex: Chrome/Windows) contredit les hints\n' +
'(ex: Linux, version différente).\n' +
'Signal fort de spoofing d\'identité navigateur.',
// ── ML Features ─────────────────────────────────────────────────────────────
fuzzing:
'Score de fuzzing : variété anormale de paramètres ou payloads.\n' +
'Caractéristique des scanners de vulnérabilités\n' +
'(SQLi, XSS, path traversal, injection d\'en-têtes…).',
velocity:
'Score de vélocité : taux de requêtes / unité de temps normalisé.\n' +
'Au-dessus du seuil → outil automatisé confirmé.',
fake_nav:
'Fausse navigation : séquences de pages non conformes au comportement humain.\n' +
'Ex : accès direct à des API sans passer par les pages d\'entrée.',
ua_mismatch:
'User-Agent / Client Hints mismatch.\n' +
'Contradiction entre l\'OS/navigateur déclaré dans le UA\n' +
'et les Client Hints envoyés par le navigateur réel.',
sni_mismatch:
'SNI mismatch : le nom dans le SNI TLS ≠ le Host HTTP.\n' +
'Signe de proxying, de bot, ou de spoofing TLS.',
orphan_ratio:
'Orphan ratio : proportion de requêtes sans referer ni session.\n' +
'Les bots accèdent souvent directement aux URLs\n' +
'sans parcours préalable sur le site.',
path_repetition:
'Répétition URL : taux de requêtes sur les mêmes endpoints.\n' +
'Les bots ciblés répètent des patterns d\'URLs précis\n' +
'(ex : /login, /api/search, /admin…).',
payload_anomaly:
'Payload anormal : ratio de requêtes avec contenu inhabituel.\n' +
'(taille hors norme, encoding bizarre, corps non standard)\n' +
'Peut indiquer une injection ou une tentative de bypass.',
fuzzing_index:
'Indice brut de fuzzing mesuré sur les paramètres des requêtes.\n' +
'Valeur haute → tentative d\'injection ou fuzzing actif.',
hit_velocity_scatter:
'Taux de requêtes par seconde de cette IP.\n' +
'Valeur haute → outil automatisé ou attaque volumétrique.',
temporal_entropy:
'Entropie temporelle : irrégularité des intervalles entre requêtes.\n' +
'· Faible = bot régulier (machine, intervalles constants)\n' +
'· Élevée = humain ou bot à timing aléatoire',
anomalous_payload_ratio:
'Ratio de requêtes avec payload anormal / total de l\'IP.\n' +
'Ex : headers malformés, corps non HTTP standard.',
attack_brute_force:
'Brute Force : tentatives répétées d\'authentification\n' +
'ou d\'énumération de ressources (login, tokens, IDs…).',
attack_flood:
'Flood : envoi massif de requêtes pour saturer le service.\n' +
'(DoS/DDoS, rate limit bypass…)',
attack_scraper:
'Scraper : extraction systématique de contenu.\n' +
'(web scraping, crawling non autorisé, récolte de données)',
attack_spoofing:
'Spoofing : usurpation d\'identité UA/TLS\n' +
'pour contourner la détection de bots.',
attack_scanner:
'Scanner : exploration automatique des endpoints\n' +
'pour découvrir des vulnérabilités (CVE, misconfigs…).',
// ── TCP Spoofing ─────────────────────────────────────────────────────────────
ttl:
'TTL (Time-To-Live) : valeur observée vs initiale estimée.\n' +
'· Initiale typique : Linux=64, Windows=128, Cisco=255\n' +
'· Hops réseau = TTL_init TTL_observé',
mss:
'Maximum Segment Size TCP.\n' +
'· Ethernet = 1460 B · PPPoE ≈ 1452 B\n' +
'· VPN ≈ 13801420 B · Bas débit < 1380 B\n' +
'Révèle le type de réseau sous-jacent.',
win_scale:
'Window Scale TCP (RFC 1323).\n' +
'Facteur d\'échelle de la fenêtre de congestion.\n' +
'· Linux ≈ 7 · Windows ≈ 8 · Absent = vieux OS ou bot',
os_tcp:
'OS suspecté via fingerprinting TCP passif.\n' +
'Analyse combinée : TTL + MSS + Window Scale + options TCP.\n' +
'Indépendant du User-Agent déclaré.',
os_ua:
'OS déclaré dans le User-Agent HTTP.\n' +
'Comparé à l\'OS TCP pour détecter les usurpations d\'identité.',
confidence:
'Niveau de confiance du fingerprinting TCP [0100 %].\n' +
'Basé sur le nombre de signaux concordants\n' +
'(TTL, MSS, Window Scale, options TCP).',
spoof_verdict:
'Verdict de spoofing : OS TCP (réel) vs OS User-Agent (déclaré).\n' +
'Un écart indique une probable usurpation d\'identité.\n' +
'Ex : TCP→Linux mais UA→Windows/Chrome.',
// ── Général ──────────────────────────────────────────────────────────────────
ja4:
'JA4 : fingerprint TLS client.\n' +
'Basé sur : version TLS, suites chiffrées, extensions,\n' +
'algorithmes de signature et SNI.\n' +
'Identifie un client TLS de façon quasi-unique.',
asn:
'ASN (Autonomous System Number).\n' +
'Identifiant du réseau auquel appartient l\'IP.\n' +
'Permet d\'identifier l\'opérateur (AWS, GCP, Azure, hébergeur, FAI…).',
header_fingerprint:
'Fingerprint des headers HTTP.\n' +
'· Popularité : fréquence de ce profil dans le trafic global\n' +
' (rare = suspect, populaire = navigateur standard)\n' +
'· Rotation : le client change fréquemment de profil headers\n' +
' (signal fort de bot rotatif)',
header_count:
'Nombre de headers HTTP envoyés par le client.\n' +
'Navigateur standard ≈ 1015 headers.\n' +
'Bot HTTP basique ≈ 25 headers.',
accept_language:
'Header Accept-Language.\n' +
'Absent chez les bots HTTP basiques.\n' +
'Présent chez les navigateurs légitimes (fr-FR, en-US…).',
accept_encoding:
'Header Accept-Encoding.\n' +
'Absent → client HTTP basique ou bot simple.\n' +
'Présent (gzip, br…) → navigateur ou client HTTP moderne.',
sec_fetch:
'Headers Sec-Fetch-* (Site, Mode, Dest, User).\n' +
'Envoyés uniquement par les navigateurs Chromium/Firefox réels.\n' +
'Absent → bot, curl, ou client HTTP non-navigateur.',
alertes_24h:
'Alertes générées par le moteur de détection ML\n' +
'dans les dernières 24 heures, classifiées par niveau de menace.',
threat_level:
'Niveau de menace composite :\n' +
'· CRITICAL > 70 % · HIGH 4570 %\n' +
'· MEDIUM 2545 % · LOW < 25 %',
// ── Nouveau ──────────────────────────────────────────────────────────────────
risk_score_inv:
'Score de risque composite [0100] calculé à partir de multiples sources :\n' +
'détections ML, TCP spoofing, brute force, persistance,\n' +
'réputation IP, géolocalisation, fingerprint JA4.',
browser_score:
'Score de légitimité navigateur [0100].\n' +
'Basé sur la cohérence TLS/JA4, User-Agent, Client Hints.\n' +
'100 = navigateur parfaitement légitime · 0 = outil scriptant.',
spoofing_score:
'Score de spoofing [0100] : probabilité que le UA déclaré\n' +
'ne corresponde pas au client réel.\n' +
'Basé sur UA/CH mismatch, SNI mismatch, JA4 rareté, rotation.',
ja4_rare_pct:
'Pourcentage de requêtes utilisant un JA4 fingerprint rare.\n' +
'Un JA4 rare (vu par < 0,1 % du trafic) peut indiquer\n' +
'un outil custom, un bot ou un scanner.',
ja4_rotation:
'Rotation JA4 : le client change fréquemment de fingerprint TLS.\n' +
'Signal fort de bot rotatif ou d\'évasion de détection.\n' +
'> 3 JA4 distincts par heure = rotation anormale.',
ua_rotation:
'Rotation de User-Agent : le client change fréquemment d\'identité navigateur.\n' +
'Technique utilisée par les bots pour éviter la détection.\n' +
'> 3 UA distincts / heure = rotation suspecte.',
persistence:
'Persistance : l\'IP est réapparue sur plusieurs fenêtres temporelles.\n' +
'Une IP persistante est plus susceptible d\'être un bot opérationnel\n' +
'qu\'une attaque ponctuelle.',
credential_stuffing:
'Credential Stuffing : test automatisé de couples login/mot de passe\n' +
'issus de bases de données volées.\n' +
'Caractérisé par de nombreux paramètres distincts sur les mêmes endpoints.',
enumeration:
'Énumération : exploration automatique de ressources ou d\'identifiants\n' +
'(comptes, IDs, chemins). Diffère du brute force\n' +
'car vise la découverte plutôt que l\'authentification.',
params_combos:
'Nombre de combinaisons de paramètres uniques envoyées.\n' +
'Valeur haute → outil automatisé testant des payloads variés\n' +
'(fuzzing, credential stuffing, énumération).',
confiance:
'Niveau de confiance de la détection [0100 %].\n' +
'Basé sur le nombre de signaux concordants.\n' +
'> 80 % = très fiable · < 40 % = signal faible.',
botnet_global:
'Botnet Global : IPs réparties dans > 10 pays distincts.\n' +
'Caractéristique d\'un réseau de machines compromises (botnet) distribué mondialement.',
botnet_regional:
'Botnet Régional : IPs concentrées dans 310 pays.\n' +
'Peut indiquer un réseau de proxies régionaux ou une campagne ciblée.',
botnet_concentrated:
'Botnet Concentré : IPs majoritairement dans 12 pays.\n' +
'Peut être un datacenter, un VPN ou un opérateur malveillant local.',
hash_cluster:
'Cluster d\'empreinte headers : groupe d\'IPs partageant\n' +
'exactement le même profil de headers HTTP.\n' +
'IPs dans le même cluster utilisent probablement le même outil/bot.',
sec_fetch_dest:
'Sec-Fetch-Dest : destination de la requête selon le navigateur.\n' +
'Valeurs : document, image, script, font, xhr…\n' +
'Absent = client non-navigateur (bot, curl, outil HTTP).',
sec_fetch_site:
'Sec-Fetch-Site : origine de la requête par rapport au contexte.\n' +
'Valeurs : same-origin, cross-site, none.\n' +
'Absent = client non-navigateur (bot, curl, outil HTTP).',
tendance:
'Tendance sur les dernières 24h par rapport à la période précédente.\n' +
'↑ +X% = augmentation du volume de détections.\n' +
'↓ -X% = diminution.',
subnet_cidr:
'Sous-réseau CIDR /24 : plage de 256 adresses IP contiguës.\n' +
'Plusieurs IPs malveillantes dans le même /24 suggèrent\n' +
'un datacenter, un opérateur ou un réseau compromis.',
total_detections_stat:
'Nombre total d\'événements de détection enregistrés\n' +
'par le moteur ML dans la fenêtre d\'analyse.',
unique_ips_stat:
'Nombre d\'adresses IP distinctes ayant généré des détections\n' +
'dans la fenêtre d\'analyse.',
ja4_distinct:
'Nombre de fingerprints JA4 distincts utilisés par cette IP.\n' +
'> 3 = rotation de fingerprint TLS (signal de bot évasif).',
baseline_ja4:
'JA4 légitimes (baseline) : fingerprints TLS observés chez\n' +
'des navigateurs légitimes confirmés (Chrome, Firefox, Safari…).\n' +
'Comparez le JA4 de l\'IP avec cette baseline pour évaluer le risque.',
correlation_node:
'Nœud de corrélation : entité reliée à l\'IP analysée.\n' +
'Les connexions (arêtes) représentent des relations directes\n' +
'(même subnet, même ASN, même JA4, même host cible).',
anubis_identification:
'Identification des bots par les règles Anubis\n' +
'(github.com/TecharoHQ/anubis) :\n\n' +
'• User-Agent : correspondance par expression régulière\n' +
' (ex. Googlebot, GPTBot, AhrefsBot…)\n' +
'• IP / CIDR : plages d\'adresses connues des crawlers\n' +
'• ASN : numéro de système autonome (ex. AS15169 = Google)\n' +
'• Pays : code ISO du pays source\n\n' +
'La règle la plus spécifique prend la priorité.\n\n' +
'Actions :\n' +
' ALLOW → bot légitime, exclu de l\'analyse ML\n' +
' DENY → menace connue, flaggée directement\n' +
' WEIGH → suspect, scoré par l\'IsolationForest',
};

View File

@ -1,39 +0,0 @@
// ─────────────────────────────────────────────────────────────────────────────
// Configuration centrale du dashboard JA4 SOC
// Toutes les valeurs modifiables sont regroupées ici.
// ─────────────────────────────────────────────────────────────────────────────
export const CONFIG = {
// ── API ──────────────────────────────────────────────────────────────────
/** URL de base de l'API backend (relative, proxifiée par Vite en dev) */
API_BASE_URL: '/api' as const,
// ── Thème ─────────────────────────────────────────────────────────────────
/** Thème appliqué au premier chargement si aucune préférence n'est sauvegardée.
* 'auto' = suit prefers-color-scheme du navigateur */
DEFAULT_THEME: 'auto' as 'dark' | 'light' | 'auto',
/** Clé localStorage pour la préférence de thème */
THEME_STORAGE_KEY: 'soc_theme',
// ── Rafraîchissement ──────────────────────────────────────────────────────
/** Intervalle de rafraîchissement automatique des métriques (ms). */
METRICS_REFRESH_MS: 30_000,
// ── Anubis ────────────────────────────────────────────────────────────────
/**
* Les bots sont identifiés par les règles Anubis (https://github.com/TecharoHQ/anubis).
* Chaque règle peut correspondre sur :
* - User-Agent (expression régulière)
* - Adresse IP ou plage CIDR (IP_TRIE ClickHouse)
* - Numéro ASN (Autonomous System Number)
* - Code pays
* La règle la plus spécifique (ID le plus bas dans le REGEXP_TREE) est appliquée en premier.
* Actions possibles :
* ALLOW → bot légitime identifié (Googlebot, Bingbot…) — exclu de l'analyse IF
* DENY → menace connue — flaggée directement, bypass IsolationForest
* WEIGH → trafic suspect — scoré par l'IsolationForest avec signal anubis_is_flagged=1
*/
ANUBIS_RULES_URL: 'https://github.com/TecharoHQ/anubis/tree/main/data',
} as const;

Some files were not shown because too many files have changed in this diff Show More